diff --git a/src/components/ai-edition/RightPanes.layout.test.tsx b/src/components/ai-edition/RightPanes.layout.test.tsx
new file mode 100644
index 000000000..d55f5cec6
--- /dev/null
+++ b/src/components/ai-edition/RightPanes.layout.test.tsx
@@ -0,0 +1,98 @@
+// @vitest-environment jsdom
+import "@testing-library/jest-dom";
+import { cleanup, render, screen, within } from "@testing-library/react";
+import { afterEach, describe, expect, it } from "vitest";
+import { I18nProvider } from "@/contexts/I18nContext";
+import { type AxcutDocument, createEmptyDocument } from "@/lib/ai-edition/schema";
+import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
+import { LayoutPane } from "./RightPanes";
+
+function seedProject(hasCamera: boolean): AxcutDocument {
+ const base = createEmptyDocument({ projectId: "project_layout", title: "Layout" });
+ return {
+ ...base,
+ assets: [
+ {
+ id: "asset_1",
+ kind: "video",
+ label: "screen.webm",
+ originalPath: "/tmp/screen.webm",
+ durationSec: 10,
+ video: { codec: "unknown", width: 1920, height: 1080, fps: 30 },
+ cameraTrack: hasCamera
+ ? { sourcePath: "/tmp/camera.webm", startMs: 0, offsetMs: 0, visible: true }
+ : null,
+ },
+ ],
+ project: { ...base.project, primaryAssetId: "asset_1" },
+ timeline: {
+ ...base.timeline,
+ clips: [
+ {
+ id: "clip_1",
+ assetId: "asset_1",
+ sourceStartSec: 0,
+ sourceEndSec: 10,
+ timelineStartSec: 0,
+ timelineEndSec: 10,
+ wordRefs: [],
+ origin: "user",
+ reason: "test",
+ },
+ ],
+ },
+ legacyEditor: { webcamLayoutPreset: "picture-in-picture" },
+ };
+}
+
+function renderLayout(document: AxcutDocument) {
+ useProjectStore.setState({
+ projectId: document.project.id,
+ document,
+ revision: 1,
+ status: "ready",
+ });
+ return render(
+
+
+ ,
+ );
+}
+
+afterEach(() => {
+ cleanup();
+ useProjectStore.getState().clear();
+});
+
+describe("LayoutPane camera availability", () => {
+ it("shows No webcam without overwriting the saved camera preset", () => {
+ const document = seedProject(false);
+ renderLayout(document);
+
+ const preset = screen.getByRole("combobox");
+ expect(preset).toBeDisabled();
+ expect(preset).toHaveValue("no-webcam");
+ expect(useProjectStore.getState().document?.legacyEditor).toMatchObject({
+ webcamLayoutPreset: "picture-in-picture",
+ });
+ expect(screen.queryByText("Camera Shape")).not.toBeInTheDocument();
+ expect(screen.queryByText("Shrink on Zoom")).not.toBeInTheDocument();
+ expect(screen.queryByText("Webcam Size")).not.toBeInTheDocument();
+ const mirrorRow = screen.getByText("Mirror Webcam").closest("div");
+ expect(mirrorRow).not.toBeNull();
+ expect(within(mirrorRow as HTMLElement).getByRole("button")).toBeDisabled();
+ });
+
+ it("keeps the saved preset active when a timeline clip has a camera", () => {
+ renderLayout(seedProject(true));
+
+ const preset = screen.getByRole("combobox");
+ expect(preset).toBeEnabled();
+ expect(preset).toHaveValue("picture-in-picture");
+ expect(screen.getByText("Camera Shape")).toBeInTheDocument();
+ expect(screen.getByText("Shrink on Zoom")).toBeInTheDocument();
+ expect(screen.getByText("Webcam Size")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Rounded" })).toBeEnabled();
+ expect(screen.getByRole("slider")).toBeEnabled();
+ });
+});
diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx
index 5ac70471a..f86c78e4d 100644
--- a/src/components/ai-edition/RightPanes.tsx
+++ b/src/components/ai-edition/RightPanes.tsx
@@ -1500,25 +1500,26 @@ export function LayoutPane() {
const ts = useScopedT("settings");
const { settings, set, setLive, commit, hasDocument } = useEditorSettings();
const document = useProjectStore((s) => s.document);
+ // A project can hold clips with no camera attached at all (plain imports or
+ // a recording made without a webcam). Keep the saved camera preference for
+ // later, but make the disabled control describe what the preview/export
+ // actually render right now.
+ const hasAnyCamera = document
+ ? hasAnyClipWithCamera(document.assets, document.timeline.clips)
+ : false;
+ const effectiveLayoutPreset = hasAnyCamera ? settings.webcamLayoutPreset : "no-webcam";
// Synchro initiale : cf. NativeCompositorOverlay (`pushAllNativeParams`).
// the mask shape picker only makes sense for Picture-in-Picture.
// Dual-frame (side-by-side) and vertical-stack (top/bottom) weld the camera
// to the screen as one block — the mask is rectangular and sized off the
// screen capture — so we hide those controls when the preset isn't PiP.
- const isPip = settings.webcamLayoutPreset === "picture-in-picture";
+ const isPip = effectiveLayoutPreset === "picture-in-picture";
// Same reason for "Shrink on zoom": shrinking the camera mid-zoom would tear a
// hole in the block, so the block layouts force it off (see
// `supportsWebcamReactiveZoom`) and the toggle is dropped rather than shown
// as a control that does nothing.
- const supportsReactiveZoom = supportsWebcamReactiveZoom(settings.webcamLayoutPreset);
- // P4 — a project can hold clips with no camera attached at all (plain
- // imported videos, or a recording made without a webcam). The layout
- // controls have nothing to act on in that case, so they're disabled
- // rather than left live for a preset that will never show anything.
- const hasAnyCamera = document
- ? hasAnyClipWithCamera(document.assets, document.timeline.clips)
- : false;
+ const supportsReactiveZoom = supportsWebcamReactiveZoom(effectiveLayoutPreset);
const layoutControlsDisabled = !hasDocument || !hasAnyCamera;
return (
} helpText={ts("layout.help")}>
@@ -1526,7 +1527,7 @@ export function LayoutPane() {