Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions docs/shader-effects.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
8 changes: 8 additions & 0 deletions electron/ipc/nativeBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
59 changes: 50 additions & 9 deletions src/components/ai-edition/NewEditorShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<Facet>("effects");
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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<string, unknown>,
definition,
});
setCopiedClipId(null);
toast.success("Region copied");
return;
}
const source =
sel.kind === "zoom"
? tl.zoomRegions
Expand Down Expand Up @@ -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 = () => {
Expand All @@ -1468,7 +1509,7 @@ export function NewEditorShell() {
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
},
[timelineBaseHeightPx, extraAudioHeightPx],
[timelineBaseHeightPx, extraLaneHeightPx],
);

const transcriptProps = {
Expand Down
3 changes: 3 additions & 0 deletions src/components/ai-edition/v4/EditorShellV4.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
81 changes: 81 additions & 0 deletions src/components/ai-edition/v4/FloatingInspector.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ vi.mock("../RightPanes", async (importOriginal) => ({
CursorPane: () => <div data-testid="cursor-pane">CursorPane</div>,
LayoutPane: () => <div data-testid="layout-pane">LayoutPane</div>,
SliderCell: () => <div data-testid="slider-cell">SliderCell</div>,
Toggle: () => <div data-testid="toggle">Toggle</div>,
TranscriptPane: () => <div data-testid="transcript-pane">TranscriptPane</div>,
VideoEffectsPane: () => <div data-testid="effects-pane">VideoEffectsPane</div>,
}));
Expand All @@ -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<typeof import("@/native/compositorViewClient")>()),
checkShaderEffectCode,
}));

vi.mock("../CaptionsPane", () => ({
CaptionsPane: () => <div data-testid="captions-pane">CaptionsPane</div>,
}));
Expand Down Expand Up @@ -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<f32>) -> vec4<f32> { 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<typeof FloatingInspector>["tl"];
render(
<FloatingInspector
facet="layout"
open
onFacetChange={vi.fn()}
onToggleOpen={vi.fn()}
clips={[]}
onEditClip={vi.fn()}
transcriptProps={
{} as unknown as React.ComponentProps<typeof FloatingInspector>["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();
Expand Down
Loading
Loading