From c63a8d0e285823898e96dab2ed587f7530cfebbd Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Wed, 14 Oct 2026 21:24:36 +0200 Subject: [PATCH] feat(effects): the Effects lane and the effect inspector One Effects lane under Full Camera, shown only while the project has an effect. Its pills move, resize, copy, paste and delete like the other regions, and a paste onto another effect is refused. The inspector edits the effect's parameters and target, shows its cost tier and its code, and reports a compile error the compositor finds. --- docs/shader-effects.md | 9 +- electron/ipc/nativeBridge.ts | 8 + src/components/ai-edition/NewEditorShell.tsx | 59 ++++- .../ai-edition/v4/EditorShellV4.module.css | 3 + .../ai-edition/v4/FloatingInspector.test.tsx | 81 ++++++ .../ai-edition/v4/FloatingInspector.tsx | 238 +++++++++++++++++- .../v4/V4Timeline.geometry.test.tsx | 25 ++ src/components/ai-edition/v4/V4Timeline.tsx | 27 +- .../v4/V4Timeline.waveform.test.tsx | 2 + src/i18n/locales/ar/editor.json | 15 +- src/i18n/locales/cs/editor.json | 15 +- src/i18n/locales/de/editor.json | 15 +- src/i18n/locales/en/editor.json | 15 +- src/i18n/locales/es/editor.json | 15 +- src/i18n/locales/fr/editor.json | 15 +- src/i18n/locales/it/editor.json | 15 +- src/i18n/locales/ja-JP/editor.json | 15 +- src/i18n/locales/ko-KR/editor.json | 15 +- src/i18n/locales/pt-BR/editor.json | 15 +- src/i18n/locales/ru/editor.json | 15 +- src/i18n/locales/tr/editor.json | 15 +- src/i18n/locales/vi/editor.json | 15 +- src/i18n/locales/zh-CN/editor.json | 15 +- src/i18n/locales/zh-TW/editor.json | 15 +- src/lib/ai-edition/shaderEffects.test.ts | 42 ++++ src/lib/ai-edition/shaderEffects.ts | 24 ++ .../store/documentWriteAudit.test.ts | 11 +- src/lib/ai-edition/store/regionClipboard.ts | 4 + src/lib/ai-edition/store/useTimeline.test.ts | 104 ++++++++ src/lib/ai-edition/store/useTimeline.ts | 139 +++++++++- src/native/compositorViewClient.ts | 19 ++ src/native/contracts.ts | 7 + .../architecture/editor-shell.md | 30 +-- 33 files changed, 1007 insertions(+), 50 deletions(-) create mode 100644 src/lib/ai-edition/shaderEffects.test.ts diff --git a/docs/shader-effects.md b/docs/shader-effects.md index 8a1dd6833c..598ff01790 100644 --- a/docs/shader-effects.md +++ b/docs/shader-effects.md @@ -642,7 +642,8 @@ dit `validated: false`, et les checks lisent le code comme du texte. ### F.1 Une piste « Effets » dans la timeline - **Où.** Une seule rangée « Effets », sous la piste Full Camera : les effets ne se chevauchent - pas (K.6). + pas (K.6). Elle n'apparaît que si le projet a un effet, puisqu'aucun bouton n'en ajoute, et + la timeline grandit alors de sa hauteur (`EFFECT_LANE_EXPANSION_PX`). - **La pilule** porte le nom de la définition. La glisser, l'étirer, la sélectionner et la supprimer se font comme pour un zoom (`selectPill`, `startPillDrag`, `removeRegions`). - **L'annulation** passe par l'historique habituel (`saveDocument({ history: true })`). Une @@ -675,8 +676,10 @@ faire, et ce serait une vraie surprise. ### F.3 Textes de l'interface -Les libellés fixes vont dans `timeline.json` (`labels.shaderEffect`) et dans `editor.json` -(`inspector.effectCode`, `inspector.effectLayer`…), pour les 15 locales. +Les libellés fixes vont dans `editor.json`, pour les 15 locales : `inspector.effect*` (cible, +coût et ses quatre paliers, code, erreur) et `regionClipboard.pasteEffectOverlap`. La pilule +et l'en-tête de l'inspecteur portent le nom de l'effet, que l'agent écrit : la piste n'a pas de +libellé propre. `localeParity.test.ts` et `npm run i18n:check` vérifient leur parité. Les noms et libellés écrits par l'agent n'y passent pas. diff --git a/electron/ipc/nativeBridge.ts b/electron/ipc/nativeBridge.ts index 065aaa4b56..46e75ae58a 100644 --- a/electron/ipc/nativeBridge.ts +++ b/electron/ipc/nativeBridge.ts @@ -381,6 +381,14 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) { return createSuccessResponse(requestId, { mask: await compositorViewService.segmentFrame(request.payload.rgba), }); + case "checkShaderEffect": + // The inspector shows why an effect does not draw on this machine. + return createSuccessResponse(requestId, { + check: await compositorViewService.checkShaderEffect( + request.payload.code, + request.payload.params, + ), + }); case "setRect": compositorViewService.setRect(request.payload.id, request.payload.rect); return createSuccessResponse(requestId, { ok: true }); diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx index 5c1974a2d5..4375d0c882 100644 --- a/src/components/ai-edition/NewEditorShell.tsx +++ b/src/components/ai-edition/NewEditorShell.tsx @@ -20,7 +20,13 @@ import { setDocumentWordText, } from "@/lib/ai-edition/document/transcript"; import { isModalOpen } from "@/lib/ai-edition/modalGuard"; -import type { AxcutAudioTrack, AxcutClip, AxcutDocument } from "@/lib/ai-edition/schema"; +import type { + AxcutAudioTrack, + AxcutClip, + AxcutDocument, + AxcutShaderEffectRange, +} from "@/lib/ai-edition/schema"; +import { withPastedShaderEffect } from "@/lib/ai-edition/shaderEffects"; import { useMcpDocumentHost } from "@/lib/ai-edition/store/mcpDocumentHost"; import { saveWithDeadline, useProjectStore } from "@/lib/ai-edition/store/projectStore"; import { @@ -114,6 +120,9 @@ function NativePlaybackSync({ export const DEFAULT_TIMELINE_HEIGHT_PX = 392; export const MIN_TIMELINE_HEIGHT_PX = 160; export const MAX_TIMELINE_HEIGHT_PX = 560; +/** The Effects lane's height (`.tlLane`: 24px and a 5px margin), added while the project has an + * effect: the lane only shows then (`V4Timeline`). */ +export const EFFECT_LANE_EXPANSION_PX = 29; /** * What one `loadedmetadata` event does, from the front of the write queue. @@ -243,10 +252,13 @@ export function NewEditorShell() { () => computeAudioRowCount(document?.audioTracks ?? []), [document?.audioTracks], ); - const extraAudioHeightPx = Math.max(0, audioRowCount - 1) * AUDIO_ROW_EXPANSION_PX; + const hasEffects = (document?.shaderEffectRanges?.length ?? 0) > 0; + const extraLaneHeightPx = + Math.max(0, audioRowCount - 1) * AUDIO_ROW_EXPANSION_PX + + (hasEffects ? EFFECT_LANE_EXPANSION_PX : 0); const timelineHeightPx = Math.min( MAX_TIMELINE_HEIGHT_PX, - Math.max(MIN_TIMELINE_HEIGHT_PX, timelineBaseHeightPx + extraAudioHeightPx), + Math.max(MIN_TIMELINE_HEIGHT_PX, timelineBaseHeightPx + extraLaneHeightPx), ); const [inspectorOpen, setInspectorOpen] = useState(true); const [facet, setFacet] = useState("effects"); @@ -1058,7 +1070,12 @@ export function NewEditorShell() { // Land it at the playhead, keeping the copied length. const timeMs = Math.round(useProjectStore.getState().currentTimeSec * 1000); const src = snapshot.region as { startMs: number; endMs: number }; - const prefix = snapshot.kind === "annotation" ? "ann" : snapshot.kind; + const prefix = + snapshot.kind === "annotation" + ? "ann" + : snapshot.kind === "shaderEffect" + ? "fxr" + : snapshot.kind; // Audio re-ventilates through its own anchorer, which advances each // fragment's source offset — the generic one would copy the offset into @@ -1112,6 +1129,18 @@ export function NewEditorShell() { }, { history: true }, ); + } else if (snapshot.kind === "shaderEffect") { + // With its definition; refused onto another effect, as the agent's would be. + const next = withPastedShaderEffect( + doc, + snapshot.definition, + anchored as AxcutShaderEffectRange[], + ); + if (!next) { + toast.error(te("regionClipboard.pasteEffectOverlap")); + return; + } + await saveDocument(next, { history: true }); } else { // speed and cameraFullscreen are both plain spans on legacyEditor. const key = snapshot.kind === "speed" ? "speedRegions" : "cameraFullscreenRegions"; @@ -1173,8 +1202,20 @@ export function NewEditorShell() { return; } - // The editor has no effect lane yet, so nothing selects a shader effect to copy. - if (sel.kind === "shaderEffect") return; + // An effect region goes with its definition (`RegionSnapshot`). + if (sel.kind === "shaderEffect") { + const region = tl.shaderEffectRanges.find((r) => r.id === sel.id); + const definition = region && tl.shaderEffects.find((e) => e.id === region.effectId); + if (!region || !definition) return; + copyRegion({ + kind: "shaderEffect", + region: region as unknown as Record, + definition, + }); + setCopiedClipId(null); + toast.success("Region copied"); + return; + } const source = sel.kind === "zoom" ? tl.zoomRegions @@ -1455,9 +1496,9 @@ export function NewEditorShell() { // timeline, since it sits below the handle. const renderedTarget = Math.min( MAX_TIMELINE_HEIGHT_PX, - Math.max(MIN_TIMELINE_HEIGHT_PX, startBase + extraAudioHeightPx - (ev.clientY - startY)), + Math.max(MIN_TIMELINE_HEIGHT_PX, startBase + extraLaneHeightPx - (ev.clientY - startY)), ); - latestBase = Math.max(MIN_TIMELINE_HEIGHT_PX, renderedTarget - extraAudioHeightPx); + latestBase = Math.max(MIN_TIMELINE_HEIGHT_PX, renderedTarget - extraLaneHeightPx); setTimelineBaseHeightPx(latestBase); }; const up = () => { @@ -1468,7 +1509,7 @@ export function NewEditorShell() { window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); }, - [timelineBaseHeightPx, extraAudioHeightPx], + [timelineBaseHeightPx, extraLaneHeightPx], ); const transcriptProps = { diff --git a/src/components/ai-edition/v4/EditorShellV4.module.css b/src/components/ai-edition/v4/EditorShellV4.module.css index 862ac1c68f..77bc77fd4e 100644 --- a/src/components/ai-edition/v4/EditorShellV4.module.css +++ b/src/components/ai-edition/v4/EditorShellV4.module.css @@ -1665,6 +1665,9 @@ .laneCameraFullscreen { --_c: #a855f7; } +.laneShaderEffect { + --_c: #14b8a6; +} /* A zoom's camera moves (#1028): a trail on each side of the pill, fading away from it, BELOW it (a chained neighbour's pill stays on top). Thinner than the pill so it reads as part of it, not as another region. The part a trim cuts is hatched in the trim lane's red. */ diff --git a/src/components/ai-edition/v4/FloatingInspector.test.tsx b/src/components/ai-edition/v4/FloatingInspector.test.tsx index 2bbe078bb1..090f87fbc7 100644 --- a/src/components/ai-edition/v4/FloatingInspector.test.tsx +++ b/src/components/ai-edition/v4/FloatingInspector.test.tsx @@ -37,6 +37,7 @@ vi.mock("../RightPanes", async (importOriginal) => ({ CursorPane: () =>
CursorPane
, LayoutPane: () =>
LayoutPane
, SliderCell: () =>
SliderCell
, + Toggle: () =>
Toggle
, TranscriptPane: () =>
TranscriptPane
, VideoEffectsPane: () =>
VideoEffectsPane
, })); @@ -55,6 +56,13 @@ vi.mock("@/lib/ai-edition/store/useEditorSettings", async (importOriginal) => { }; }); +// What this machine's compositor says of an effect's code, as the inspector asks it. +const checkShaderEffectCode = vi.hoisted(() => vi.fn()); +vi.mock("@/native/compositorViewClient", async (importOriginal) => ({ + ...(await importOriginal()), + checkShaderEffectCode, +})); + vi.mock("../CaptionsPane", () => ({ CaptionsPane: () =>
CaptionsPane
, })); @@ -615,6 +623,79 @@ describe("FloatingInspector", () => { }); }); +describe("FloatingInspector on an agent-written effect", () => { + const effect = { + id: "fx_1", + name: "Dim", + description: "", + contract: 1, + code: "fn effect(uv: vec2) -> vec4 { return frame(uv) * params.level; }", + params: [{ type: "number", name: "level", label: "Level", default: 1, min: 0, max: 2 }], + origin: "agent", + cost: { tier: "light", frameReads: 1 }, + }; + const region = { + id: "fxr_1", + startMs: 0, + endMs: 2000, + effectId: "fx_1", + values: {}, + target: "image", + }; + const renderEffect = () => { + const updateShaderEffect = vi.fn(); + const tl = { + selection: { kind: "shaderEffect", id: "fxr_1" }, + clearSelection: vi.fn(), + selectedAudioTrackId: null, + shaderEffectRanges: [region], + shaderEffects: [effect], + updateShaderEffect, + updateShaderEffectLive: vi.fn(), + commitShaderEffectChange: vi.fn(), + removeRegion: vi.fn(), + } as unknown as React.ComponentProps["tl"]; + render( + ["transcriptProps"] + } + tl={tl} + />, + ); + return { updateShaderEffect }; + }; + + beforeEach(() => { + checkShaderEffectCode.mockReset(); + }); + + it("shows its name, its cost and its code, and why it does not draw on this machine", async () => { + checkShaderEffectCode.mockResolvedValue({ error: "1:12 no definition in scope for `nope`" }); + renderEffect(); + expect(screen.getByRole("heading", { name: "Dim" })).toBeInTheDocument(); + expect(await screen.findByRole("alert")).toHaveTextContent("no definition in scope for `nope`"); + expect(screen.getByText("editor.inspector.effectTierLight")).toBeInTheDocument(); + expect(screen.getByText(effect.code)).toBeInTheDocument(); + expect(checkShaderEffectCode).toHaveBeenCalledWith(effect.code, effect.params); + }); + + it("retargets the effect in one write, and says nothing of code that compiles", async () => { + checkShaderEffectCode.mockResolvedValue({ tier: "light", frameReads: 1, iterations: 0 }); + const { updateShaderEffect } = renderEffect(); + fireEvent.click(screen.getByRole("button", { name: "editor.inspector.effectTargetText" })); + expect(updateShaderEffect).toHaveBeenCalledWith("fxr_1", { target: "text" }); + await waitFor(() => expect(checkShaderEffectCode).toHaveBeenCalled()); + expect(screen.queryByRole("alert")).toBeNull(); + }); +}); + describe("AnnotationSizeField", () => { const commitTyped = (typed: string) => { const onCommit = vi.fn(); diff --git a/src/components/ai-edition/v4/FloatingInspector.tsx b/src/components/ai-edition/v4/FloatingInspector.tsx index 96070498be..b82a0e9cd8 100644 --- a/src/components/ai-edition/v4/FloatingInspector.tsx +++ b/src/components/ai-edition/v4/FloatingInspector.tsx @@ -10,6 +10,7 @@ import { AudioLines, Camera, ChevronRight, + Copy, EyeOff, FileText, ImageIcon, @@ -19,6 +20,7 @@ import { Pencil, Scissors, SlidersHorizontal, + Sparkles, Trash2, Type, Undo2, @@ -54,15 +56,23 @@ import { TEXT_ANIMATION_VALUES, } from "@/lib/ai-edition/annotations/textAnimation"; import { resolveAspectRatioValue } from "@/lib/ai-edition/document/outputFormat"; -import type { AxcutAnnotationRegion, AxcutClip } from "@/lib/ai-edition/schema"; +import type { + AxcutAnnotationRegion, + AxcutClip, + AxcutShaderEffect, + AxcutShaderEffectParam, + AxcutShaderEffectRange, +} from "@/lib/ai-edition/schema"; +import { resolveShaderEffectValue } from "@/lib/ai-edition/shaderEffects"; import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; import { rafCoalesce } from "@/lib/ai-edition/store/rafCoalesce"; import { useEditorSettings } from "@/lib/ai-edition/store/useEditorSettings"; -import type { useTimeline } from "@/lib/ai-edition/store/useTimeline"; +import type { ShaderEffectPatch, useTimeline } from "@/lib/ai-edition/store/useTimeline"; import { formatSeconds } from "@/lib/ai-edition/timeline/format"; import { coalescedTrimGroups } from "@/lib/ai-edition/timeline/trim-mapping"; import { zoomLevelFor } from "@/lib/ai-edition/timeline/zoom-scale"; import { clampToBound } from "@/lib/projectDefaults"; +import { checkShaderEffectCode } from "@/native/compositorViewClient"; import { annotationFootageRect, zoomScaleLimit } from "@/native/sceneDescription"; import { ColorField } from "../ColorField"; import shell from "../NewEditorShell.module.css"; @@ -73,6 +83,7 @@ import { CursorPane, LayoutPane, SliderCell, + Toggle, TranscriptPane, VideoEffectsPane, } from "../RightPanes"; @@ -1264,6 +1275,23 @@ function SelectionPane({ tl, onClose }: { tl: TimelineApi; onClose: () => void } ); } + if (selection.kind === "shaderEffect") { + const region = tl.shaderEffectRanges.find((r) => r.id === selection.id); + const effect = region ? tl.shaderEffects.find((e) => e.id === region.effectId) : undefined; + if (!region || !effect) return null; + return ( + + ); + } + if (selection.kind === "cameraFullscreen") { const region = tl.cameraFullscreenRegions.find((c) => c.id === selection.id); if (!region) return null; @@ -1320,6 +1348,212 @@ function SelectionPane({ tl, onClose }: { tl: TimelineApi; onClose: () => void } * slab outshouted every setting above it. */ const PANE_BUTTON = `${shell.btn} ${shell.btnSecondary}`; +/** The word an effect's cost tier shows as (docs/shader-effects.md, C.7). */ +const EFFECT_TIER_KEY = { + light: "inspector.effectTierLight", + medium: "inspector.effectTierMedium", + heavy: "inspector.effectTierHeavy", + "very-heavy": "inspector.effectTierVeryHeavy", +} as const; + +const EFFECT_CODE_STYLE: React.CSSProperties = { + margin: 0, + padding: 10, + borderRadius: 8, + background: "var(--surface-1)", + color: "var(--fg-2)", + fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace", + fontSize: 11, + lineHeight: 1.5, + whiteSpace: "pre-wrap", + wordBreak: "break-word", + maxHeight: 240, + overflow: "auto", +}; + +/** + * An agent-written effect (docs/shader-effects.md, F.2): one control per declared parameter, its + * target, its cost tier, its code (read only: an agent edits it), and the compiler's error when + * it does not draw on this machine. A component of its own for the check's state. + */ +function ShaderEffectPane({ + tl, + region, + effect, + onClose, + onDelete, + bodyStyle, +}: { + tl: TimelineApi; + region: AxcutShaderEffectRange; + effect: AxcutShaderEffect; + onClose: () => void; + onDelete: () => void; + bodyStyle: React.CSSProperties; +}) { + const te = useScopedT("editor"); + const tc = useScopedT("common"); + // Asked of this machine's compositor: a region whose code does not compile here draws + // nothing, and only the compiler's error says why. + const [error, setError] = useState(null); + useEffect(() => { + let current = true; + setError(null); + void checkShaderEffectCode(effect.code, effect.params).then((check) => { + if (current) setError(check?.error ?? null); + }); + return () => { + current = false; + }; + }, [effect.code, effect.params]); + // One live write per frame while a slider or a colour moves, one save on release. + const liveRef = useRef(tl.updateShaderEffectLive); + liveRef.current = tl.updateShaderEffectLive; + const live = useMemo( + () => rafCoalesce((id: string, patch: ShaderEffectPatch) => liveRef.current(id, patch)), + [], + ); + const commit = () => { + live.flush(); + void tl.commitShaderEffectChange(); + }; + const setLive = (name: string, value: unknown) => live(region.id, { values: { [name]: value } }); + const paramValue = (param: AxcutShaderEffectParam) => + resolveShaderEffectValue(param, region.values[param.name]); + + return ( +
+ {paneHeader(, effect.name, onClose, tc("actions.close"))} +
+ {error ? ( +
+ + {te("inspector.effectError")} + +
{error}
+
+ ) : null} + {effect.params.map((param) => { + if (param.type === "number") { + return ( + setLive(param.name, next)} + onCommit={commit} + /> + ); + } + if (param.type === "toggle") { + return ( +
+ {paneRow( + param.label, + + void tl.updateShaderEffect(region.id, { values: { [param.name]: next } }) + } + />, + )} +
+ ); + } + if (param.type === "color") { + return ( +
+ {paneRow( + param.label, + setLive(param.name, next)} + onCommit={commit} + />, + )} +
+ ); + } + const point = paramValue(param) as { x: number; y: number }; + return ( +
+ setLive(param.name, { ...point, x })} + onCommit={commit} + /> + setLive(param.name, { ...point, y })} + onCommit={commit} + /> +
+ ); + })} + void tl.updateShaderEffect(region.id, { target })} + /> + {effect.cost + ? paneRow( + te("inspector.effectCost"), + + {te(EFFECT_TIER_KEY[effect.cost.tier])} + , + ) + : null} +
+ + {te("inspector.effectCode")} + +
+
{effect.code}
+ +
+
+ +
+
+ ); +} + function FacetBody({ facet, onCollapse, diff --git a/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx index 3eba711971..bebe463b1d 100644 --- a/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx +++ b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx @@ -120,6 +120,8 @@ function renderTimeline( annotationRegions: [annotation], speedRegions: [], cameraFullscreenRegions: [], + shaderEffectRanges: [], + shaderEffects: [], zoomRegions: [], trimRanges: [], hasEditRegions: true, @@ -360,6 +362,27 @@ describe("V4Timeline lane pills", () => { }); }); +describe("V4Timeline effects lane", () => { + // CSS modules hash the class to `_tlLane_` under Vitest. + const lanes = () => + Array.from(document.querySelectorAll("div")).filter((el) => + Array.from(el.classList).some((c) => /^_tlLane_/.test(c)), + ).length; + + it("shows only while the project has an effect, and names the pill after it", () => { + renderTimeline(); + const without = lanes(); + cleanup(); + + renderTimeline(undefined, undefined, undefined, undefined, { + shaderEffects: [{ id: "fx_1", name: "Dim" }], + shaderEffectRanges: [{ id: "fxr_1", startMs: 20_000, endMs: 21_000, effectId: "fx_1" }], + }); + expect(lanes()).toBe(without + 1); + expect(screen.getByTitle("Dim").className).toContain("laneShaderEffect"); + }); +}); + describe("V4Timeline lane pill keyboard", () => { // A pill carries `role="button"` and `tabIndex={0}`, so it is reachable by Tab and // announced as activatable. Selection was pointer-only, which meant a keyboard user @@ -700,6 +723,8 @@ describe("V4Timeline audio lane drag", () => { annotationRegions: [], speedRegions: [], cameraFullscreenRegions: [], + shaderEffectRanges: [], + shaderEffects: [], zoomRegions: [], trimRanges: [], selection: null, diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx index 8ca8158835..bb026fcd8d 100644 --- a/src/components/ai-edition/v4/V4Timeline.tsx +++ b/src/components/ai-edition/v4/V4Timeline.tsx @@ -596,7 +596,7 @@ const AudioLanePill = memo(function AudioLanePill({ interface LanePill { id: string; - kind: "annotation" | "speed" | "trim" | "zoom" | "cameraFullscreen"; + kind: "annotation" | "speed" | "trim" | "zoom" | "cameraFullscreen" | "shaderEffect"; start: number; end: number; label: string; @@ -767,6 +767,15 @@ export function V4Timeline({ sourceIds: p.ids, }), ); + const effectNames = new Map(tl.shaderEffects.map((effect) => [effect.id, effect.name])); + const shaderEffectPills: LanePill[] = coalesceRegionsForRuler(tl.shaderEffectRanges).map((p) => ({ + id: p.ids[0], + kind: "shaderEffect", + start: p.start, + end: p.end, + label: effectNames.get(p.member.effectId) ?? "", + sourceIds: p.ids, + })); const zoomRuns = coalesceRegionsForRuler(tl.zoomRegions); const zoomPills: LanePill[] = zoomRuns.map((p) => ({ id: p.ids[0], @@ -940,6 +949,7 @@ export function V4Timeline({ speed: tl.speedRegions, zoom: tl.zoomRegions, cameraFullscreen: tl.cameraFullscreenRegions, + shaderEffect: tl.shaderEffectRanges, // Nothing to edit on a trim but its span, and a span edit moves the whole pill. trim: [], }; @@ -1015,6 +1025,8 @@ export function V4Timeline({ await tl.updateAnnotationSpan(grabbed, s * 1000, en * 1000); else if (pill.kind === "cameraFullscreen") await tl.updateCameraFullscreenSpan(grabbed, s * 1000, en * 1000); + else if (pill.kind === "shaderEffect") + await tl.updateShaderEffectSpan(grabbed, s * 1000, en * 1000); else { // Trims are stored in source-time per asset but manipulated on the // timeline like every other pill. Ventilate the new span across the @@ -1444,7 +1456,9 @@ export function V4Timeline({ ? styles.laneTrim : kind === "cameraFullscreen" ? styles.laneCameraFullscreen - : styles.laneZoom; + : kind === "shaderEffect" + ? styles.laneShaderEffect + : styles.laneZoom; const pillIcon = (kind: LanePill["kind"]) => kind === "annotation" ? ( @@ -1454,6 +1468,8 @@ export function V4Timeline({ ) : kind === "cameraFullscreen" ? ( + ) : kind === "shaderEffect" ? ( + ) : ( ); @@ -2307,6 +2323,13 @@ export function V4Timeline({ : ts("layout.noWebcam"), )} + {/* Agent-written shader effects: one at a time, so one row. Only when the project + has one: an agent writes them, nothing in the editor adds one, so an empty + row would offer nothing. The shell grows by its height + (`EFFECT_LANE_EXPANSION_PX`). */} + {tl.shaderEffectRanges.length > 0 ? ( +
{renderPills(shaderEffectPills, "")}
+ ) : null} {/* Imported audio tracks (issue #350). Always shown, like every other lane — "Add audio" is a toolbar peer of the region tools now (and has a keyboard shortcut), so an empty lane advertises the shortcut diff --git a/src/components/ai-edition/v4/V4Timeline.waveform.test.tsx b/src/components/ai-edition/v4/V4Timeline.waveform.test.tsx index b84915af2d..37e9c32c62 100644 --- a/src/components/ai-edition/v4/V4Timeline.waveform.test.tsx +++ b/src/components/ai-edition/v4/V4Timeline.waveform.test.tsx @@ -120,6 +120,8 @@ function renderBars(atGainDb: number): string[] { annotationRegions: [], speedRegions: [], cameraFullscreenRegions: [], + shaderEffectRanges: [], + shaderEffects: [], zoomRegions: [], trimRanges: [], selection: null, diff --git a/src/i18n/locales/ar/editor.json b/src/i18n/locales/ar/editor.json index 766dccb9e3..143f19e57a 100644 --- a/src/i18n/locales/ar/editor.json +++ b/src/i18n/locales/ar/editor.json @@ -80,6 +80,7 @@ "nothingToCopy": "حدد منطقة لنسخ سماتها", "nothingToPaste": "لم يتم نسخ أي سمات بعد", "pasteAssetMissing": "ملف هذا المسار الصوتي غير موجود في هذا المشروع", + "pasteEffectOverlap": "يوجد تأثير آخر هناك بالفعل: تأثير واحد في كل مرة", "kinds": { "zoom": "تكبير", "speed": "سرعة", @@ -228,7 +229,19 @@ "cropDescription": "أعد تأطير التسجيل — اختر نسبة العرض إلى الارتفاع وقم بالتكبير على المنطقة التي تريد الاحتفاظ بها.", "openCrop": "فتح القص…", "deleteRegion": "حذف المنطقة", - "freehandRendersAsBox": "الشكل الحر يُغطى بمستطيله المحيط في التصدير — تغطية زائدة، وليست ناقصة." + "freehandRendersAsBox": "الشكل الحر يُغطى بمستطيله المحيط في التصدير — تغطية زائدة، وليست ناقصة.", + "effectError": "لا يُترجَم برمجيًا على هذا الحاسوب", + "effectTarget": "يُطبَّق على", + "effectTargetImage": "الصورة", + "effectTargetText": "النصوص", + "effectTargetBoth": "كلاهما", + "effectCost": "التكلفة", + "effectTierLight": "خفيف", + "effectTierMedium": "متوسط", + "effectTierHeavy": "ثقيل", + "effectTierVeryHeavy": "ثقيل جدًا", + "effectCode": "الشيفرة", + "copyEffectCode": "نسخ الشيفرة" }, "chat": { "changeProvider": "تغيير المزوّد", diff --git a/src/i18n/locales/cs/editor.json b/src/i18n/locales/cs/editor.json index d1166a803b..53ba126672 100644 --- a/src/i18n/locales/cs/editor.json +++ b/src/i18n/locales/cs/editor.json @@ -80,6 +80,7 @@ "nothingToCopy": "Vyberte oblast pro zkopírování jejích atributů", "nothingToPaste": "Zatím nebyly zkopírovány žádné atributy", "pasteAssetMissing": "Soubor této zvukové stopy není v tomto projektu", + "pasteEffectOverlap": "Tam už běží jiný efekt: vždy jen jeden efekt", "kinds": { "zoom": "Přiblížení", "speed": "Rychlost", @@ -228,7 +229,19 @@ "cropDescription": "Přeformátujte nahrávku: vyberte poměr stran a přibližte oblast, kterou chcete zachovat.", "openCrop": "Otevřít oříznutí…", "deleteRegion": "Smazat oblast", - "freehandRendersAsBox": "Při exportu je volně kreslený tvar pokryt svým ohraničujícím rámečkem: vždy překrytý, nikdy nepokrytý." + "freehandRendersAsBox": "Při exportu je volně kreslený tvar pokryt svým ohraničujícím rámečkem: vždy překrytý, nikdy nepokrytý.", + "effectError": "Na tomto počítači se nezkompiluje", + "effectTarget": "Platí pro", + "effectTargetImage": "Obraz", + "effectTargetText": "Texty", + "effectTargetBoth": "Obojí", + "effectCost": "Náročnost", + "effectTierLight": "Lehký", + "effectTierMedium": "Střední", + "effectTierHeavy": "Těžký", + "effectTierVeryHeavy": "Velmi těžký", + "effectCode": "Kód", + "copyEffectCode": "Kopírovat kód" }, "chat": { "changeProvider": "Změnit poskytovatele", diff --git a/src/i18n/locales/de/editor.json b/src/i18n/locales/de/editor.json index 79619528fc..196e06f513 100644 --- a/src/i18n/locales/de/editor.json +++ b/src/i18n/locales/de/editor.json @@ -80,6 +80,7 @@ "nothingToCopy": "Wähle einen Bereich, um seine Attribute zu kopieren", "nothingToPaste": "Noch keine Attribute kopiert", "pasteAssetMissing": "Die Datei dieser Audiospur liegt nicht in diesem Projekt", + "pasteEffectOverlap": "Dort läuft bereits ein anderer Effekt: immer nur ein Effekt", "kinds": { "zoom": "Zoom", "speed": "Tempo", @@ -228,7 +229,19 @@ "cropDescription": "Rahme die Aufnahme neu — wähle ein Seitenverhältnis und zoome auf den Bereich, den du behalten willst.", "openCrop": "Zuschneiden öffnen…", "deleteRegion": "Bereich löschen", - "freehandRendersAsBox": "Im Export wird eine Freihandform von ihrem umschließenden Rechteck abgedeckt — eher zu viel als zu wenig." + "freehandRendersAsBox": "Im Export wird eine Freihandform von ihrem umschließenden Rechteck abgedeckt — eher zu viel als zu wenig.", + "effectError": "Lässt sich auf diesem Computer nicht kompilieren", + "effectTarget": "Gilt für", + "effectTargetImage": "Bild", + "effectTargetText": "Texte", + "effectTargetBoth": "Beides", + "effectCost": "Kosten", + "effectTierLight": "Leicht", + "effectTierMedium": "Mittel", + "effectTierHeavy": "Schwer", + "effectTierVeryHeavy": "Sehr schwer", + "effectCode": "Code", + "copyEffectCode": "Code kopieren" }, "chat": { "changeProvider": "Anbieter wechseln", diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index 45d50e5c00..fb0cb89170 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -80,6 +80,7 @@ "nothingToCopy": "Select a region to copy its attributes", "nothingToPaste": "No attributes copied yet", "pasteAssetMissing": "That audio track's file isn't in this project", + "pasteEffectOverlap": "Another effect already plays there: one effect at a time", "kinds": { "zoom": "Zoom", "speed": "Speed", @@ -228,7 +229,19 @@ "cropDescription": "Reframe the recording — pick an aspect ratio and zoom into the region you want to keep.", "openCrop": "Open crop…", "deleteRegion": "Delete region", - "freehandRendersAsBox": "In the export, the blur fills the rectangle around your freehand shape, so it may hide a little more than you drew." + "freehandRendersAsBox": "In the export, the blur fills the rectangle around your freehand shape, so it may hide a little more than you drew.", + "effectError": "Doesn't compile on this computer", + "effectTarget": "Applies to", + "effectTargetImage": "Image", + "effectTargetText": "Text", + "effectTargetBoth": "Both", + "effectCost": "Cost", + "effectTierLight": "Light", + "effectTierMedium": "Medium", + "effectTierHeavy": "Heavy", + "effectTierVeryHeavy": "Very heavy", + "effectCode": "Code", + "copyEffectCode": "Copy code" }, "chat": { "changeProvider": "Change provider", diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json index 1ed25722c4..89bf7d390c 100644 --- a/src/i18n/locales/es/editor.json +++ b/src/i18n/locales/es/editor.json @@ -80,6 +80,7 @@ "nothingToCopy": "Selecciona una región para copiar sus atributos", "nothingToPaste": "Aún no se han copiado atributos", "pasteAssetMissing": "El archivo de esa pista de audio no está en este proyecto", + "pasteEffectOverlap": "Ya hay otro efecto ahí: un solo efecto a la vez", "kinds": { "zoom": "Zoom", "speed": "Velocidad", @@ -228,7 +229,19 @@ "cropDescription": "Reencuadra la grabación: elige una relación de aspecto y haz zoom en la región que deseas conservar.", "openCrop": "Abrir recorte…", "deleteRegion": "Eliminar región", - "freehandRendersAsBox": "En la exportación, una forma libre se cubre con su caja contenedora: cubre de más, nunca de menos." + "freehandRendersAsBox": "En la exportación, una forma libre se cubre con su caja contenedora: cubre de más, nunca de menos.", + "effectError": "No compila en este ordenador", + "effectTarget": "Se aplica a", + "effectTargetImage": "Imagen", + "effectTargetText": "Textos", + "effectTargetBoth": "Ambos", + "effectCost": "Coste", + "effectTierLight": "Ligero", + "effectTierMedium": "Medio", + "effectTierHeavy": "Pesado", + "effectTierVeryHeavy": "Muy pesado", + "effectCode": "Código", + "copyEffectCode": "Copiar código" }, "chat": { "changeProvider": "Cambiar proveedor", diff --git a/src/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json index c65efa693c..92ec22bcb3 100644 --- a/src/i18n/locales/fr/editor.json +++ b/src/i18n/locales/fr/editor.json @@ -80,6 +80,7 @@ "nothingToCopy": "Sélectionnez une région pour copier ses attributs", "nothingToPaste": "Aucun attribut copié pour l'instant", "pasteAssetMissing": "Le fichier de cette piste audio n'est pas dans ce projet", + "pasteEffectOverlap": "Un autre effet joue déjà à cet endroit : un seul effet à la fois", "kinds": { "zoom": "Zoom", "speed": "Vitesse", @@ -228,7 +229,19 @@ "cropDescription": "Recadrer l'enregistrement — choisissez un format et zoomez sur la région à conserver.", "openCrop": "Ouvrir le recadrage…", "deleteRegion": "Supprimer la zone", - "freehandRendersAsBox": "À l'export, un tracé libre est couvert par sa boîte englobante — on couvre trop, jamais trop peu." + "freehandRendersAsBox": "À l'export, un tracé libre est couvert par sa boîte englobante — on couvre trop, jamais trop peu.", + "effectError": "Ne compile pas sur cet ordinateur", + "effectTarget": "S'applique à", + "effectTargetImage": "Image", + "effectTargetText": "Textes", + "effectTargetBoth": "Les deux", + "effectCost": "Coût", + "effectTierLight": "Léger", + "effectTierMedium": "Moyen", + "effectTierHeavy": "Lourd", + "effectTierVeryHeavy": "Très lourd", + "effectCode": "Code", + "copyEffectCode": "Copier le code" }, "chat": { "changeProvider": "Changer de fournisseur", diff --git a/src/i18n/locales/it/editor.json b/src/i18n/locales/it/editor.json index 547217a876..e9a61562ee 100644 --- a/src/i18n/locales/it/editor.json +++ b/src/i18n/locales/it/editor.json @@ -80,6 +80,7 @@ "nothingToCopy": "Seleziona una regione per copiarne gli attributi", "nothingToPaste": "Nessun attributo copiato", "pasteAssetMissing": "Il file di questa traccia audio non è in questo progetto", + "pasteEffectOverlap": "Lì c'è già un altro effetto: un effetto alla volta", "kinds": { "zoom": "Zoom", "speed": "Velocità", @@ -228,7 +229,19 @@ "cropDescription": "Reinquadra la registrazione — scegli un rapporto d'aspetto e ingrandisci l'area da mantenere.", "openCrop": "Apri ritaglio…", "deleteRegion": "Elimina regione", - "freehandRendersAsBox": "Nell'esportazione, una forma libera è coperta dal suo rettangolo di delimitazione: copre di più, mai di meno." + "freehandRendersAsBox": "Nell'esportazione, una forma libera è coperta dal suo rettangolo di delimitazione: copre di più, mai di meno.", + "effectError": "Non compila su questo computer", + "effectTarget": "Si applica a", + "effectTargetImage": "Immagine", + "effectTargetText": "Testi", + "effectTargetBoth": "Entrambi", + "effectCost": "Costo", + "effectTierLight": "Leggero", + "effectTierMedium": "Medio", + "effectTierHeavy": "Pesante", + "effectTierVeryHeavy": "Molto pesante", + "effectCode": "Codice", + "copyEffectCode": "Copia codice" }, "chat": { "changeProvider": "Cambia provider", diff --git a/src/i18n/locales/ja-JP/editor.json b/src/i18n/locales/ja-JP/editor.json index c2fd846ffe..10d8f7aab5 100644 --- a/src/i18n/locales/ja-JP/editor.json +++ b/src/i18n/locales/ja-JP/editor.json @@ -80,6 +80,7 @@ "nothingToCopy": "属性をコピーする領域を選択してください", "nothingToPaste": "コピーされた属性がありません", "pasteAssetMissing": "このオーディオトラックのファイルはこのプロジェクトにありません", + "pasteEffectOverlap": "そこには別のエフェクトがあります。エフェクトは一度に1つだけです", "kinds": { "zoom": "ズーム", "speed": "速度", @@ -228,7 +229,19 @@ "cropDescription": "録画をリフレーム — アスペクト比を選び、保持したい範囲にズームします。", "openCrop": "切り抜きを開く…", "deleteRegion": "区間を削除", - "freehandRendersAsBox": "書き出しでは、フリーハンドの形は外接する矩形で覆われます。覆いすぎることはあっても、足りないことはありません。" + "freehandRendersAsBox": "書き出しでは、フリーハンドの形は外接する矩形で覆われます。覆いすぎることはあっても、足りないことはありません。", + "effectError": "このコンピューターではコンパイルできません", + "effectTarget": "適用先", + "effectTargetImage": "画像", + "effectTargetText": "テキスト", + "effectTargetBoth": "両方", + "effectCost": "負荷", + "effectTierLight": "軽い", + "effectTierMedium": "中", + "effectTierHeavy": "重い", + "effectTierVeryHeavy": "非常に重い", + "effectCode": "コード", + "copyEffectCode": "コードをコピー" }, "chat": { "changeProvider": "プロバイダーを変更", diff --git a/src/i18n/locales/ko-KR/editor.json b/src/i18n/locales/ko-KR/editor.json index efe680db06..fa1dbc148b 100644 --- a/src/i18n/locales/ko-KR/editor.json +++ b/src/i18n/locales/ko-KR/editor.json @@ -80,6 +80,7 @@ "nothingToCopy": "속성을 복사할 영역을 선택하세요", "nothingToPaste": "복사된 속성이 없습니다", "pasteAssetMissing": "이 오디오 트랙의 파일이 이 프로젝트에 없습니다", + "pasteEffectOverlap": "그 위치에 이미 다른 효과가 있습니다. 효과는 한 번에 하나만 사용할 수 있습니다", "kinds": { "zoom": "줌", "speed": "속도", @@ -228,7 +229,19 @@ "cropDescription": "녹화 화면을 다시 프레이밍 — 화면 비율을 선택하고 유지할 영역으로 확대합니다.", "openCrop": "자르기 열기…", "deleteRegion": "구간 삭제", - "freehandRendersAsBox": "내보내기에서 자유형 도형은 외곽 사각형으로 덮입니다. 더 많이 덮되, 부족하게 덮지는 않습니다." + "freehandRendersAsBox": "내보내기에서 자유형 도형은 외곽 사각형으로 덮입니다. 더 많이 덮되, 부족하게 덮지는 않습니다.", + "effectError": "이 컴퓨터에서 컴파일되지 않습니다", + "effectTarget": "적용 대상", + "effectTargetImage": "이미지", + "effectTargetText": "텍스트", + "effectTargetBoth": "둘 다", + "effectCost": "부하", + "effectTierLight": "가벼움", + "effectTierMedium": "보통", + "effectTierHeavy": "무거움", + "effectTierVeryHeavy": "매우 무거움", + "effectCode": "코드", + "copyEffectCode": "코드 복사" }, "chat": { "changeProvider": "제공업체 변경", diff --git a/src/i18n/locales/pt-BR/editor.json b/src/i18n/locales/pt-BR/editor.json index c0e7102932..8dcaff24a8 100644 --- a/src/i18n/locales/pt-BR/editor.json +++ b/src/i18n/locales/pt-BR/editor.json @@ -80,6 +80,7 @@ "nothingToCopy": "Selecione uma região para copiar seus atributos", "nothingToPaste": "Nenhum atributo copiado ainda", "pasteAssetMissing": "O arquivo dessa faixa de áudio não está neste projeto", + "pasteEffectOverlap": "Já há outro efeito ali: um efeito por vez", "kinds": { "zoom": "Zoom", "speed": "Velocidade", @@ -228,7 +229,19 @@ "cropDescription": "Reenquadre a gravação — escolha uma proporção e dê zoom na região que deseja manter.", "openCrop": "Abrir corte…", "deleteRegion": "Excluir região", - "freehandRendersAsBox": "Na exportação, uma forma livre é coberta pela sua caixa delimitadora — cobre demais, nunca de menos." + "freehandRendersAsBox": "Na exportação, uma forma livre é coberta pela sua caixa delimitadora — cobre demais, nunca de menos.", + "effectError": "Não compila neste computador", + "effectTarget": "Aplica-se a", + "effectTargetImage": "Imagem", + "effectTargetText": "Textos", + "effectTargetBoth": "Ambos", + "effectCost": "Custo", + "effectTierLight": "Leve", + "effectTierMedium": "Médio", + "effectTierHeavy": "Pesado", + "effectTierVeryHeavy": "Muito pesado", + "effectCode": "Código", + "copyEffectCode": "Copiar código" }, "chat": { "changeProvider": "Trocar provedor", diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json index 16fa07d248..827488c319 100644 --- a/src/i18n/locales/ru/editor.json +++ b/src/i18n/locales/ru/editor.json @@ -80,6 +80,7 @@ "nothingToCopy": "Выберите регион, чтобы скопировать его атрибуты", "nothingToPaste": "Атрибуты ещё не скопированы", "pasteAssetMissing": "Файл этой аудиодорожки отсутствует в проекте", + "pasteEffectOverlap": "Там уже есть другой эффект: только один эффект одновременно", "kinds": { "zoom": "Масштаб", "speed": "Скорость", @@ -228,7 +229,19 @@ "cropDescription": "Перекадрируйте запись — выберите соотношение сторон и увеличьте область, которую нужно сохранить.", "openCrop": "Открыть обрезку…", "deleteRegion": "Удалить участок", - "freehandRendersAsBox": "При экспорте произвольная форма закрывается своим ограничивающим прямоугольником — с запасом, а не с недостатком." + "freehandRendersAsBox": "При экспорте произвольная форма закрывается своим ограничивающим прямоугольником — с запасом, а не с недостатком.", + "effectError": "Не компилируется на этом компьютере", + "effectTarget": "Применяется к", + "effectTargetImage": "Изображение", + "effectTargetText": "Тексты", + "effectTargetBoth": "Оба", + "effectCost": "Нагрузка", + "effectTierLight": "Лёгкий", + "effectTierMedium": "Средний", + "effectTierHeavy": "Тяжёлый", + "effectTierVeryHeavy": "Очень тяжёлый", + "effectCode": "Код", + "copyEffectCode": "Копировать код" }, "chat": { "changeProvider": "Сменить провайдера", diff --git a/src/i18n/locales/tr/editor.json b/src/i18n/locales/tr/editor.json index f31f975151..78efe59a15 100644 --- a/src/i18n/locales/tr/editor.json +++ b/src/i18n/locales/tr/editor.json @@ -80,6 +80,7 @@ "nothingToCopy": "Özniteliklerini kopyalamak için bir bölge seçin", "nothingToPaste": "Henüz öznitelik kopyalanmadı", "pasteAssetMissing": "Bu ses parçasının dosyası bu projede yok", + "pasteEffectOverlap": "Orada zaten başka bir efekt var: aynı anda tek efekt", "kinds": { "zoom": "Yakınlaştırma", "speed": "Hız", @@ -228,7 +229,19 @@ "cropDescription": "Kaydı yeniden çerçeveleyin — bir en boy oranı seçin ve saklamak istediğiniz bölgeye yakınlaşın.", "openCrop": "Kırpmayı aç…", "deleteRegion": "Bölgeyi sil", - "freehandRendersAsBox": "Dışa aktarmada serbest çizim, sınırlayıcı dikdörtgeniyle kapatılır — eksik değil, fazla kapatır." + "freehandRendersAsBox": "Dışa aktarmada serbest çizim, sınırlayıcı dikdörtgeniyle kapatılır — eksik değil, fazla kapatır.", + "effectError": "Bu bilgisayarda derlenmiyor", + "effectTarget": "Uygulandığı yer", + "effectTargetImage": "Görüntü", + "effectTargetText": "Metinler", + "effectTargetBoth": "İkisi de", + "effectCost": "Maliyet", + "effectTierLight": "Hafif", + "effectTierMedium": "Orta", + "effectTierHeavy": "Ağır", + "effectTierVeryHeavy": "Çok ağır", + "effectCode": "Kod", + "copyEffectCode": "Kodu kopyala" }, "chat": { "changeProvider": "Sağlayıcıyı değiştir", diff --git a/src/i18n/locales/vi/editor.json b/src/i18n/locales/vi/editor.json index 133380596b..5341e70214 100644 --- a/src/i18n/locales/vi/editor.json +++ b/src/i18n/locales/vi/editor.json @@ -80,6 +80,7 @@ "nothingToCopy": "Chọn một vùng để sao chép thuộc tính của nó", "nothingToPaste": "Chưa sao chép thuộc tính nào", "pasteAssetMissing": "Tệp của bản âm thanh này không có trong dự án", + "pasteEffectOverlap": "Ở đó đã có một hiệu ứng khác: mỗi lúc chỉ một hiệu ứng", "kinds": { "zoom": "Thu phóng", "speed": "Tốc độ", @@ -228,7 +229,19 @@ "cropDescription": "Đóng khung lại bản ghi — chọn tỷ lệ khung hình và phóng to vào vùng bạn muốn giữ lại.", "openCrop": "Mở cắt…", "deleteRegion": "Xóa vùng", - "freehandRendersAsBox": "Khi xuất, hình vẽ tay được che bằng khung bao của nó — che dư, không bao giờ che thiếu." + "freehandRendersAsBox": "Khi xuất, hình vẽ tay được che bằng khung bao của nó — che dư, không bao giờ che thiếu.", + "effectError": "Không biên dịch được trên máy này", + "effectTarget": "Áp dụng cho", + "effectTargetImage": "Hình ảnh", + "effectTargetText": "Văn bản", + "effectTargetBoth": "Cả hai", + "effectCost": "Chi phí", + "effectTierLight": "Nhẹ", + "effectTierMedium": "Trung bình", + "effectTierHeavy": "Nặng", + "effectTierVeryHeavy": "Rất nặng", + "effectCode": "Mã", + "copyEffectCode": "Sao chép mã" }, "chat": { "changeProvider": "Đổi nhà cung cấp", diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json index a5799cda7b..a50819a0f4 100644 --- a/src/i18n/locales/zh-CN/editor.json +++ b/src/i18n/locales/zh-CN/editor.json @@ -80,6 +80,7 @@ "nothingToCopy": "选择一个区域以复制其属性", "nothingToPaste": "尚未复制任何属性", "pasteAssetMissing": "该音频轨道的文件不在此项目中", + "pasteEffectOverlap": "该处已有另一个效果:同一时间只能有一个效果", "kinds": { "zoom": "缩放", "speed": "速度", @@ -228,7 +229,19 @@ "cropDescription": "重新构图录制内容 — 选择宽高比并缩放到要保留的区域。", "openCrop": "打开裁剪…", "deleteRegion": "删除区间", - "freehandRendersAsBox": "导出时,手绘形状会以其外接矩形遮挡——宁可多遮,绝不少遮。" + "freehandRendersAsBox": "导出时,手绘形状会以其外接矩形遮挡——宁可多遮,绝不少遮。", + "effectError": "无法在这台电脑上编译", + "effectTarget": "作用于", + "effectTargetImage": "画面", + "effectTargetText": "文字", + "effectTargetBoth": "两者", + "effectCost": "开销", + "effectTierLight": "轻", + "effectTierMedium": "中", + "effectTierHeavy": "重", + "effectTierVeryHeavy": "很重", + "effectCode": "代码", + "copyEffectCode": "复制代码" }, "chat": { "changeProvider": "更换提供方", diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json index 0528b1b319..f0a7c97036 100644 --- a/src/i18n/locales/zh-TW/editor.json +++ b/src/i18n/locales/zh-TW/editor.json @@ -80,6 +80,7 @@ "nothingToCopy": "選擇一個區域以複製其屬性", "nothingToPaste": "尚未複製任何屬性", "pasteAssetMissing": "該音訊軌道的檔案不在此專案中", + "pasteEffectOverlap": "該處已有另一個效果:同一時間只能有一個效果", "kinds": { "zoom": "縮放", "speed": "速度", @@ -228,7 +229,19 @@ "cropDescription": "重新構圖錄製內容 — 選擇長寬比並縮放至想保留的區域。", "openCrop": "開啟裁切…", "deleteRegion": "刪除區間", - "freehandRendersAsBox": "匯出時,手繪形狀會以其外接矩形遮蔽——寧可多遮,絕不少遮。" + "freehandRendersAsBox": "匯出時,手繪形狀會以其外接矩形遮蔽——寧可多遮,絕不少遮。", + "effectError": "無法在這台電腦上編譯", + "effectTarget": "作用於", + "effectTargetImage": "畫面", + "effectTargetText": "文字", + "effectTargetBoth": "兩者", + "effectCost": "開銷", + "effectTierLight": "輕", + "effectTierMedium": "中", + "effectTierHeavy": "重", + "effectTierVeryHeavy": "很重", + "effectCode": "程式碼", + "copyEffectCode": "複製程式碼" }, "chat": { "changeProvider": "更換提供者", diff --git a/src/lib/ai-edition/shaderEffects.test.ts b/src/lib/ai-edition/shaderEffects.test.ts new file mode 100644 index 0000000000..41d082a0ee --- /dev/null +++ b/src/lib/ai-edition/shaderEffects.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import type { AxcutDocument, AxcutShaderEffect, AxcutShaderEffectRange } from "./schema"; +import { createEmptyDocument } from "./schema"; +import { withPastedShaderEffect } from "./shaderEffects"; + +const definition: AxcutShaderEffect = { + id: "fx_1", + name: "Dim", + description: "", + contract: 1, + code: "fn effect(uv: vec2) -> vec4 { return frame(uv); }", + params: [], + origin: "agent", +}; +const fragment = (id: string, startMs: number, endMs: number): AxcutShaderEffectRange => ({ + id, + startMs, + endMs, + effectId: "fx_1", + values: {}, + target: "image", +}); + +describe("withPastedShaderEffect", () => { + const empty: AxcutDocument = createEmptyDocument({ projectId: "p", title: "t" }); + + it("recreates the definition in a project that does not have it", () => { + const next = withPastedShaderEffect(empty, definition, [fragment("fxr_1", 0, 1000)]); + expect(next?.shaderEffects).toEqual([definition]); + expect(next?.shaderEffectRanges).toHaveLength(1); + }); + + it("reuses the definition the project already has, and refuses a paste onto an effect", () => { + const doc = withPastedShaderEffect(empty, definition, [ + fragment("fxr_1", 0, 1000), + ]) as AxcutDocument; + const again = withPastedShaderEffect(doc, definition, [fragment("fxr_2", 2000, 3000)]); + expect(again?.shaderEffects).toHaveLength(1); + expect(again?.shaderEffectRanges).toHaveLength(2); + expect(withPastedShaderEffect(doc, definition, [fragment("fxr_3", 500, 1500)])).toBeNull(); + }); +}); diff --git a/src/lib/ai-edition/shaderEffects.ts b/src/lib/ai-edition/shaderEffects.ts index d9592198d5..53293df592 100644 --- a/src/lib/ai-edition/shaderEffects.ts +++ b/src/lib/ai-edition/shaderEffects.ts @@ -2,6 +2,7 @@ import type { AxcutDocument, AxcutShaderEffect, AxcutShaderEffectParam, + AxcutShaderEffectRange, AxcutShaderEffectValue, } from "./schema"; @@ -151,3 +152,26 @@ export function shaderEffectCostAdvice(tier: ShaderEffectTier, spanSec: number): tier === "very-heavy" ? "heavy or lighter" : tier === "heavy" ? "medium or light" : "light"; return `${tier} over ${spanSec.toFixed(1)} s: this tier is meant for at most ${TIER_SPAN_SEC[tier]} s; for a region this long, aim ${lighter}. It is a recommendation, not a limit.`; } + +/** + * `document` with a pasted effect region (its fragments, already anchored) and its definition, + * recreated when the project does not have it: a region copied from another project. `null` + * when the region would overlap an effect already there, since one effect runs at a time. + */ +export function withPastedShaderEffect( + document: AxcutDocument, + definition: AxcutShaderEffect, + fragments: AxcutShaderEffectRange[], +): AxcutDocument | null { + const ranges = document.shaderEffectRanges ?? []; + const overlaps = fragments.some((f) => + ranges.some((r) => r.startMs < f.endMs && r.endMs > f.startMs), + ); + if (overlaps) return null; + const known = (document.shaderEffects ?? []).some((e) => e.id === definition.id); + return { + ...document, + shaderEffects: known ? document.shaderEffects : [...(document.shaderEffects ?? []), definition], + shaderEffectRanges: [...ranges, ...fragments], + }; +} diff --git a/src/lib/ai-edition/store/documentWriteAudit.test.ts b/src/lib/ai-edition/store/documentWriteAudit.test.ts index 3f11af6847..575d96f5b0 100644 --- a/src/lib/ai-edition/store/documentWriteAudit.test.ts +++ b/src/lib/ai-edition/store/documentWriteAudit.test.ts @@ -167,6 +167,7 @@ const DECLARED: WritePath[] = [ w("src/components/ai-edition/NewEditorShell.tsx", "pasteRegion", "save", "gesture"), w("src/components/ai-edition/NewEditorShell.tsx", "pasteRegion", "save", "gesture"), w("src/components/ai-edition/NewEditorShell.tsx", "pasteRegion", "save", "gesture"), + w("src/components/ai-edition/NewEditorShell.tsx", "pasteRegion", "save", "gesture"), // The window is closing and the user answered "save". w("src/components/ai-edition/NewEditorShell.tsx", "unsubSaveBeforeClose", "save", "gesture"), @@ -259,13 +260,16 @@ const DECLARED: WritePath[] = [ w("src/lib/ai-edition/store/useTimeline.ts", "addZoomsBulk", "save", "gesture"), // The Clear timeline button: every edit region in one write, so one undo step. w("src/lib/ai-edition/store/useTimeline.ts", "clearTimeline", "save", "gesture"), - // The two drag commits. One undo step per gesture, recorded on release and only + // The drag commits. One undo step per gesture, recorded on release and only // if the write lands — `historyBase` carries the pre-drag document. w("src/lib/ai-edition/store/useTimeline.ts", "commitAnnotationChange", "save", "gesture"), // And the rollback each of them runs when that write does not land: the pre-drag // document back into the store, around the writers, so it records nothing. There is // nothing to take off either — the commit records only on success. w("src/lib/ai-edition/store/useTimeline.ts", "commitAnnotationChange", "state", "unrecorded"), + // The effect inspector's sliders and colours, committed as the annotations' are. + w("src/lib/ai-edition/store/useTimeline.ts", "commitShaderEffectChange", "save", "gesture"), + w("src/lib/ai-edition/store/useTimeline.ts", "commitShaderEffectChange", "state", "unrecorded"), w("src/lib/ai-edition/store/useTimeline.ts", "commitZoomFocus", "save", "gesture"), w("src/lib/ai-edition/store/useTimeline.ts", "commitZoomFocus", "state", "unrecorded"), w("src/lib/ai-edition/store/useTimeline.ts", "duplicateClip", "save", "gesture"), @@ -292,10 +296,13 @@ const DECLARED: WritePath[] = [ w("src/lib/ai-edition/store/useTimeline.ts", "removeRegions", "save", "gesture"), w("src/lib/ai-edition/store/useTimeline.ts", "saveZoomPatch", "save", "gesture"), w("src/lib/ai-edition/store/useTimeline.ts", "setTrimEntries", "save", "gesture"), - // The live halves of the two drags. + // The live halves of the drags. w("src/lib/ai-edition/store/useTimeline.ts", "updateAnnotationLive", "set", "automatic"), w("src/lib/ai-edition/store/useTimeline.ts", "updateAnnotationSpan", "save", "gesture"), w("src/lib/ai-edition/store/useTimeline.ts", "updateCameraFullscreenSpan", "save", "gesture"), + w("src/lib/ai-edition/store/useTimeline.ts", "updateShaderEffect", "save", "gesture"), + w("src/lib/ai-edition/store/useTimeline.ts", "updateShaderEffectLive", "set", "automatic"), + w("src/lib/ai-edition/store/useTimeline.ts", "updateShaderEffectSpan", "save", "gesture"), w("src/lib/ai-edition/store/useTimeline.ts", "updateSpeedSpan", "save", "gesture"), w("src/lib/ai-edition/store/useTimeline.ts", "updateSpeedValue", "save", "gesture"), w("src/lib/ai-edition/store/useTimeline.ts", "updateTrim", "save", "gesture"), diff --git a/src/lib/ai-edition/store/regionClipboard.ts b/src/lib/ai-edition/store/regionClipboard.ts index f0d6d7d104..9ae66a8ab0 100644 --- a/src/lib/ai-edition/store/regionClipboard.ts +++ b/src/lib/ai-edition/store/regionClipboard.ts @@ -5,6 +5,7 @@ // this path). import { useCallback, useEffect, useState } from "react"; +import type { AxcutShaderEffect } from "../schema"; export type RegionSnapshot = | { kind: "zoom"; region: Record } @@ -15,6 +16,9 @@ export type RegionSnapshot = // paste is a second placement of the same audio, like every other kind. The // snapshot is the COLLAPSED pill, never a stored fragment. | { kind: "audio"; region: Record } + // An effect region carries its definition: pasted into another project, the definition is + // recreated there, since the region alone would name an effect that project does not have. + | { kind: "shaderEffect"; region: Record; definition: AxcutShaderEffect } // A trim carries no user-visible properties, so all there is to copy is how // LONG it was — `{ durationSec }`. That is not a special case so much as the // general one made obvious: every paste keeps the copied properties and takes diff --git a/src/lib/ai-edition/store/useTimeline.test.ts b/src/lib/ai-edition/store/useTimeline.test.ts index 09564eb76b..f8e6ba8a39 100644 --- a/src/lib/ai-edition/store/useTimeline.test.ts +++ b/src/lib/ai-edition/store/useTimeline.test.ts @@ -680,6 +680,110 @@ describe("useTimeline.addCameraFullscreen (camera gate)", () => { }); }); +describe("useTimeline shader effects (the Effects lane and its inspector)", () => { + const effect = { + id: "fx_1", + name: "Dim", + description: "", + contract: 1, + code: "fn effect(uv: vec2) -> vec4 { return frame(uv) * params.level; }", + params: [ + { type: "number" as const, name: "level", label: "Level", default: 1, min: 0, max: 2 }, + ], + origin: "agent" as const, + }; + const range = (id: string, startMs: number, endMs: number) => ({ + id, + startMs, + endMs, + clipId: "clip_a", + sourceStartSec: startMs / 1000, + sourceEndSec: endMs / 1000, + effectId: "fx_1", + values: {}, + target: "image" as const, + }); + const effectDoc: AxcutDocument = { + ...sampleDoc, + shaderEffects: [effect], + // Another value on the second, so it stays a pill of its own beside the first. + shaderEffectRanges: [ + range("fxr_1", 1000, 3000), + { ...range("fxr_2", 5000, 7000), values: { level: 0.5 } }, + ], + }; + const ranges = () => useProjectStore.getState().document?.shaderEffectRanges ?? []; + + beforeEach(() => { + useProjectStore.getState().clear(); + clearHistory(); + for (const mock of Object.values(bridgeMocks)) mock.mockReset(); + bridgeMocks.save.mockImplementation(async (doc: typeof sampleDoc) => ({ + success: true, + document: doc, + })); + useProjectStore.setState({ + projectId: "proj_test", + document: effectDoc, + currentTimeSec: 1, + revision: 1, + status: "ready", + error: null, + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("moves an effect up to its neighbour, never over it: one effect at a time", async () => { + const { result } = renderTimeline(); + await act(async () => { + await result.current.updateShaderEffectSpan("fxr_1", 2000, 6000); + }); + const [moved, wall] = coalesceRegionsForRuler(ranges()); + expect(moved.end).toBeLessThanOrEqual(5); + expect([wall.start, wall.end]).toEqual([5, 7]); + }); + + it("re-tunes a value live, then saves it as one undo step; retargets in one write", async () => { + const { result } = renderTimeline(); + const before = past.length; + act(() => { + result.current.updateShaderEffectLive("fxr_1", { values: { level: 1.5 } }); + result.current.updateShaderEffectLive("fxr_1", { values: { level: 0.5 } }); + }); + await act(async () => { + await result.current.commitShaderEffectChange(); + }); + expect(ranges().find((r) => r.id === "fxr_1")?.values).toEqual({ level: 0.5 }); + expect(past.length).toBe(before + 1); + await act(async () => { + await result.current.updateShaderEffect("fxr_1", { target: "text", values: { level: 1 } }); + }); + // Back at its default, the value is not stored. + expect(ranges().find((r) => r.id === "fxr_1")).toMatchObject({ target: "text", values: {} }); + await act(async () => { + await undo(); + await undo(); + }); + expect(ranges().find((r) => r.id === "fxr_1")).toMatchObject({ target: "image", values: {} }); + }); + + it("deletes effects in a batch, and their definition with the last", async () => { + const { result } = renderTimeline(); + await act(async () => { + await result.current.removeRegions([ + { kind: "shaderEffect", id: "fxr_1" }, + { kind: "shaderEffect", id: "fxr_2" }, + ]); + }); + const doc = useProjectStore.getState().document; + expect(doc?.shaderEffectRanges).toEqual([]); + expect(doc?.shaderEffects).toEqual([]); + }); +}); + describe("useTimeline.addAnnotation", () => { beforeEach(() => { useProjectStore.getState().clear(); diff --git a/src/lib/ai-edition/store/useTimeline.ts b/src/lib/ai-edition/store/useTimeline.ts index 7c173f870f..7d650ed85b 100644 --- a/src/lib/ai-edition/store/useTimeline.ts +++ b/src/lib/ai-edition/store/useTimeline.ts @@ -37,7 +37,17 @@ import { resequenceClips, setClipSourceRange, } from "../document/timeline"; -import type { AxcutAudioTrack, AxcutClipCropRegion, AxcutDocument } from "../schema"; +import type { + AxcutAudioTrack, + AxcutClipCropRegion, + AxcutDocument, + AxcutShaderEffectRange, +} from "../schema"; +import { + findShaderEffect, + pruneUnusedShaderEffects, + storedShaderEffectValues, +} from "../shaderEffects"; import { appendAutoZoomSuggestions } from "../timeline/apply-auto-zooms"; import { hasAnyClipWithCamera } from "../timeline/camera"; import { probeAudioDuration, probeVideoDimensions, probeVideoDuration } from "../timeline/duration"; @@ -45,6 +55,7 @@ import { anchorRegionsWithDerivedMs, dropPillsByIds, replacePillSpan, + resolvePillIds, resolveRegionIds, } from "../timeline/timelineMap"; import { dropTrimPillsByIds, resolveTimelineSpanToTrim } from "../timeline/trim-mapping"; @@ -116,6 +127,41 @@ function playheadSec(): number { return useProjectStore.getState().currentTimeSec; } +/** What the inspector changes on an effect region: its parameter values, or its target. */ +export interface ShaderEffectPatch { + values?: Record; + target?: AxcutShaderEffectRange["target"]; +} + +/** Every fragment of the effect pill `id` (one region, cut at clip boundaries), patched alike so + * it stays one pill. Values keep only what differs from the definition's defaults. `null` when + * the region or its definition is gone. */ +function patchShaderEffectPill( + doc: AxcutDocument, + id: string, + patch: ShaderEffectPatch, +): AxcutDocument | null { + const ranges = doc.shaderEffectRanges ?? []; + const region = ranges.find((r) => r.id === id); + const definition = region ? findShaderEffect(doc, region.effectId) : null; + if (!region || !definition) return null; + const ids = new Set(resolvePillIds(ranges, id)); + return { + ...doc, + shaderEffectRanges: ranges.map((r) => + ids.has(r.id) + ? { + ...r, + ...(patch.values + ? { values: storedShaderEffectValues(definition, { ...r.values, ...patch.values }) } + : {}), + ...(patch.target ? { target: patch.target } : {}), + } + : r, + ), + }; +} + export function useTimeline() { const ts = useScopedT("settings"); const document = useProjectStore((s) => s.document); @@ -151,6 +197,8 @@ export function useTimeline() { } | null>(null); const annotationRollbackRef = useRef(null); const annotationLiveRef = useRef(null); + const shaderEffectRollbackRef = useRef(null); + const shaderEffectLiveRef = useRef(null); // A drag does not always end in a commit: `ZoomFocusOverlay` unmounts the moment // `focusMode` flips to "auto", so `endDrag` never runs and the snapshot outlives the @@ -165,6 +213,8 @@ export function useTimeline() { zoomFocusEditRef.current = null; annotationRollbackRef.current = null; annotationLiveRef.current = null; + shaderEffectRollbackRef.current = null; + shaderEffectLiveRef.current = null; }, [projectId]); const hasDoc = document !== null && projectId !== null; @@ -1052,6 +1102,68 @@ export function useTimeline() { [document, saveDocument], ); + const updateShaderEffectSpan = useCallback( + async (id: string, startMs: number, endMs: number) => { + if (!document) return; + const s = finiteMs(startMs); + const e = finiteMs(endMs); + // Clamped against the neighbouring effects: one effect runs at a time. + const next: AxcutDocument = { + ...document, + shaderEffectRanges: replacePillSpan( + document.shaderEffectRanges ?? [], + id, + Math.min(s, e), + Math.max(s, e), + document.timeline.clips, + () => createId("fxr"), + ), + }; + await saveDocument(next, { history: true }); + }, + [document, saveDocument], + ); + + // A discrete inspector edit (the target, a toggle): one save, one undo step. + const updateShaderEffect = useCallback( + async (id: string, patch: ShaderEffectPatch) => { + if (!document) return; + const next = patchShaderEffectPill(document, id, patch); + if (next) await saveDocument(next, { history: true }); + }, + [document, saveDocument], + ); + + // A continuous one (a slider, a colour): local writes while it lasts, then one save and one + // undo step from `commitShaderEffectChange`. The annotations' live path, for effects. + const updateShaderEffectLive = useCallback( + (id: string, patch: ShaderEffectPatch) => { + const doc = useProjectStore.getState().document; + if (!doc) return; + const next = patchShaderEffectPill(doc, id, patch); + if (!next) return; + if (shaderEffectLiveRef.current !== doc) shaderEffectRollbackRef.current = doc; + setDocument(next, { history: false }); + shaderEffectLiveRef.current = next; + }, + [setDocument], + ); + + // `commitAnnotationChange`, for the effect refs: the pre-gesture document is the undo + // target, recorded only if the write lands, and put back if it does not. + const commitShaderEffectChange = useCallback(async () => { + const doc = useProjectStore.getState().document; + if (!doc) return; + const rollback = shaderEffectLiveRef.current === doc ? shaderEffectRollbackRef.current : null; + shaderEffectRollbackRef.current = null; + shaderEffectLiveRef.current = null; + if (!(await saveDocument(doc, { history: true, historyBase: rollback })) && rollback) { + useProjectStore.setState((state) => + state.document === doc ? { document: rollback, revision: state.revision + 1 } : {}, + ); + } + }, [saveDocument]); + const updateSpeedValue = useCallback( async (id: string, speed: number) => { if (!document) return; @@ -1100,6 +1212,9 @@ export function useTimeline() { const cameraFullscreenIds = new Set( handles.filter((h) => h.kind === "cameraFullscreen").map((h) => h.id), ); + const shaderEffectIds = new Set( + handles.filter((h) => h.kind === "shaderEffect").map((h) => h.id), + ); const legacy = (document.legacyEditor as Record) ?? {}; const prevSpeed = dropPillsByIds( (legacy.speedRegions as Array<{ id: string; startMs: number; endMs: number }>) ?? [], @@ -1110,10 +1225,18 @@ export function useTimeline() { [], cameraFullscreenIds, ); - const next: AxcutDocument = { + const next: AxcutDocument = pruneUnusedShaderEffects({ ...document, zoomRanges: dropPillsByIds(document.zoomRanges, zoomIds) as AxcutDocument["zoomRanges"], annotations: dropPillsByIds(document.annotations, annotationIds), + ...(shaderEffectIds.size > 0 + ? { + shaderEffectRanges: dropPillsByIds( + document.shaderEffectRanges ?? [], + shaderEffectIds, + ), + } + : {}), timeline: { ...document.timeline, // Whole-pill delete, same as the zoom/annotation lines above — a trim grown @@ -1129,7 +1252,7 @@ export function useTimeline() { speedIds.size > 0 || cameraFullscreenIds.size > 0 ? { ...legacy, speedRegions: prevSpeed, cameraFullscreenRegions: prevCameraFullscreen } : document.legacyEditor, - }; + }); if (!(await saveDocument(next, { history: true }))) return; setSelection(null); setMultiSelection([]); @@ -1430,6 +1553,10 @@ export function useTimeline() { }>) ?? []) : []; + // One region per effect, never two at once; each names its definition. + const shaderEffectRanges = hasDoc ? (document.shaderEffectRanges ?? []) : []; + const shaderEffects = hasDoc ? (document.shaderEffects ?? []) : []; + // --- Timeline audio tracks (issue #350) ------------------------------------- // CLIP-ANCHORED like every region above: one user-visible track is one pill // over one-or-more stored fragments, so these ops go through the shared pill @@ -1615,6 +1742,8 @@ export function useTimeline() { annotationRegions: (document?.annotations ?? []) as unknown as AnnotationRegion[], speedRegions, cameraFullscreenRegions, + shaderEffectRanges, + shaderEffects, clips: document?.timeline.clips ?? [], assets: document?.assets ?? [], // The timeline marks where the user has ADDED words — text with no audio behind it. @@ -1668,6 +1797,10 @@ export function useTimeline() { updateSpeedSpan, updateSpeedValue, updateCameraFullscreenSpan, + updateShaderEffectSpan, + updateShaderEffect, + updateShaderEffectLive, + commitShaderEffectChange, // T19 — drives the preview video during trim-edge resize. setCurrentTime: useProjectStore((s) => s.setCurrentTime), }; diff --git a/src/native/compositorViewClient.ts b/src/native/compositorViewClient.ts index 131d8909cb..990a08674c 100644 --- a/src/native/compositorViewClient.ts +++ b/src/native/compositorViewClient.ts @@ -7,6 +7,7 @@ * `readCompositorFrame` and paints them into a ``. */ +import type { ShaderEffectCheck } from "@/lib/ai-edition/shaderEffects"; import { requireNativeBridgeData } from "./client"; import type { CompositorBackend, @@ -60,6 +61,24 @@ export async function probeSegmentationSupport(): Promise { } } +/** An effect's code checked by this machine's compositor, as the agent tools check it: the + * compiler's error, or the cost tier. `null` when nothing could check it (no bridge, no addon). */ +export async function checkShaderEffectCode( + code: string, + params: ReadonlyArray<{ name: string; type: string }>, +): Promise { + try { + const { check } = await requireNativeBridgeData<{ check: ShaderEffectCheck | null }>({ + domain: "compositor", + action: "checkShaderEffect", + payload: { code, params: params.map(({ name, type }) => ({ name, type })) }, + }); + return check; + } catch { + return null; + } +} + /** The segmentation model's input size (`segmentation::MODEL_WIDTH` x `MODEL_HEIGHT`). */ export const SEGMENTATION_WIDTH = 256; export const SEGMENTATION_HEIGHT = 144; diff --git a/src/native/contracts.ts b/src/native/contracts.ts index 38bfaf1dfb..c837b216ea 100644 --- a/src/native/contracts.ts +++ b/src/native/contracts.ts @@ -841,6 +841,13 @@ export type NativeBridgeRequest = action: "probeSegmentation"; requestId?: string; } + | { + domain: "compositor"; + action: "checkShaderEffect"; + /** An effect's code and declared parameters, as the agent tools check them. */ + payload: { code: string; params: Array<{ name: string; type: string }> }; + requestId?: string; + } | { domain: "compositor"; action: "segmentFrame"; diff --git a/technical-documentation/architecture/editor-shell.md b/technical-documentation/architecture/editor-shell.md index 47f18c4e96..05d8cb62f2 100644 --- a/technical-documentation/architecture/editor-shell.md +++ b/technical-documentation/architecture/editor-shell.md @@ -107,19 +107,21 @@ An ordered checklist of every file that must move when a new modifier (`"blur"`, `"captionHighlight"`, …) joins the existing ones. The kind is added to `RegionKind` in `src/lib/ai-edition/document/timeline.ts` (`"zoom" | "trim" | "annotation" | "speed" | "cameraFullscreen" | "audio" | "shaderEffect"`); -every site that switches on it is checked below. `"shaderEffect"` went through -the document and agent steps but has no lane or inspector in the editor yet: -agents write effects ([docs/shader-effects.md](../../docs/shader-effects.md)). Each path has been verified on this branch. +every site that switches on it is checked below. `"shaderEffect"` is the latest +kind through every step. Its lane shows only while the project has an effect, since +an agent writes them, and the shell grows by its height (`EFFECT_LANE_EXPANSION_PX`) +([docs/shader-effects.md](../../docs/shader-effects.md), F). Line numbers refreshed +on 14/10/2026. 1. **Schema** — `src/lib/ai-edition/schema/index.ts`. Add a `xxxRegionSchema` Zod object that extends the modifier base (`{id, startMs, endMs, clipId?, sourceStartSec?, sourceEndSec?, …payload}`), and add the array to - `documentSchemaShape` next to `zoomRanges` and `annotations` (`:496-497`). - The v4→v5 preprocess at `:555-595` re-anchors every region kind — add the + `documentSchemaShape` next to `zoomRanges` and `annotations` (`:736-737`). + The v4→v5 preprocess at `:806-834` re-anchors every region kind — add the new array to the `anchor(...)` block so freshly-loaded v4 documents convert on parse. 2. **Identity** — `timelineMap.regionIdentityKey` already does the right thing: - `NON_IDENTITY_FIELDS` (`:122`) lists only position + provenance, so every + `NON_IDENTITY_FIELDS` (`:141`) lists only position + provenance, so every property of the new kind participates in identity automatically. Confirm with a test in `src/lib/ai-edition/timeline/timelineMap.test.ts`. 3. **Store action in `useTimeline`** — `src/lib/ai-edition/store/useTimeline.ts`. @@ -127,13 +129,13 @@ agents write effects ([docs/shader-effects.md](../../docs/shader-effects.md)). E `anchorRegionsWithDerivedMs` (`:134, :163, :278, :305, :329`), `replacePillSpan`, and `dropPillsByIds` (imports at `:23-28`); expose the array as `tl.xxxRegions` on the returned object. Add the new kind to - `RegionKind` (`document/timeline.ts:18`); add a `removeRegion` case - (`document/timeline.ts:591`) for batch / single deletes. + `RegionKind` (`document/timeline.ts:47`); add a `removeRegion` case + (`document/timeline.ts:1173`) for batch / single deletes. 4. **Lane in `V4Timeline`** — `src/components/ai-edition/v4/V4Timeline.tsx`. Compute the pills at the same call site as the four existing lanes - (`coalesceRegionsForRuler(tl.xxxRegions).map(...)` near `:463-511`), render + (`coalesceRegionsForRuler(tl.xxxRegions).map(...)` near `:743-779`), render them through `renderPills` inside a `
` - block (`:1504-1512`), and extend the `kind` union at `:334` so drag, + block (`:2285-2332`), and extend the `kind` union at `:599` so drag, resize, and delete handler switches route correctly. **Coordinates on the timeline canvas obey one rule**: position and size are @@ -149,13 +151,13 @@ agents write effects ([docs/shader-effects.md](../../docs/shader-effects.md)). E `PILL_SNAP_PX` and `CLIP_GUTTER_PX`, and the invariants in `V4Timeline.geometry.test.tsx`. 5. **Inspector selection pane** — `src/components/ai-edition/v4/FloatingInspector.tsx`. - The `SelectionPane` (`:444`) is the kind-switch site, not the facets; add - an `if (selection.kind === "xxx") { ... }` branch alongside `:513, :612, - :629, :956` that reads `tl.xxxRegions`, calls the store's `updateXxx*` + The `SelectionPane` (`:746`) is the kind-switch site, not the facets; add + an `if (selection.kind === "xxx") { ... }` branch alongside `:834, :955, + :972, :1278, :1295` that reads `tl.xxxRegions`, calls the store's `updateXxx*` methods, and renders a delete button matching the existing style. 6. **Preview path** — `src/components/ai-edition/Preview.tsx` mounts only zoom, annotation, speed, camera-fullscreen, and trim regions (see - `NewEditorShell.tsx` `:1185-1189`). Add the new array to `Preview`'s + `NewEditorShell.tsx` `:1617-1639`). Add the new array to `Preview`'s props, pass it through, and have the renderer consume it from `tl.xxxRegions`. 7. **Native preview scene** —