diff --git a/src/components/ai-edition/PreviewCanvas.tsx b/src/components/ai-edition/PreviewCanvas.tsx
index 3bf042a0..6f59e066 100644
--- a/src/components/ai-edition/PreviewCanvas.tsx
+++ b/src/components/ai-edition/PreviewCanvas.tsx
@@ -47,6 +47,7 @@ import { locateVirtualPosition } from "@/lib/ai-edition/timeline/virtual-preview
import {
computeCameraFullscreenRect,
computeCompositeLayout,
+ resolveWebcamLayoutPreset,
type WebcamCompositeLayout,
} from "@/lib/compositeLayout";
import { classifyWallpaper, resolveImageWallpaperUrl } from "@/lib/wallpaper";
@@ -231,9 +232,10 @@ export function PreviewCanvas(props: PreviewCanvasProps) {
// A clip with no camera lays out as "no-webcam", whatever the panel says. Hiding
// only the webcam slot is not enough: the block presets size the SCREEN off the
// block, so the screen stayed squeezed into its half with nothing beside it.
- const preset = (
- activeClipHasCamera ? settings.webcamLayoutPreset : "no-webcam"
- ) as WebcamLayoutPreset;
+ const preset = resolveWebcamLayoutPreset(
+ settings.webcamLayoutPreset as WebcamLayoutPreset,
+ activeClipHasCamera,
+ );
const mask = settings.webcamMaskShape as WebcamMaskShape;
// ponytail: padding shrinks the available content area for ALL layouts
// (PiP/dual/stack) so the screen doesn't fill the canvas edge-to-edge.
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 00000000..4056d13c
--- /dev/null
+++ b/src/components/ai-edition/RightPanes.layout.test.tsx
@@ -0,0 +1,127 @@
+// @vitest-environment jsdom
+// The layout preset is a global setting but the camera is per clip, so a project can
+// hold no camera at all (#248). These pin what the pane shows in that case: the
+// controls go dead, the preset reads "No Webcam", and — the part that is easy to break —
+// the saved preference is left untouched on disk and the help popover says so.
+
+import "@testing-library/jest-dom";
+import { cleanup, render, screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { I18nProvider } from "@/contexts/I18nContext";
+import { LOCALE_STORAGE_KEY } from "@/i18n/config";
+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" },
+ };
+}
+
+// `doc` rather than `document`: this is a jsdom file, and shadowing the global would
+// silently redirect any `document.querySelector` a later test adds.
+function renderLayout(doc: AxcutDocument) {
+ useProjectStore.setState({
+ projectId: doc.project.id,
+ document: doc,
+ revision: 1,
+ status: "ready",
+ });
+ return render(
+
+
+ ,
+ );
+}
+
+beforeEach(() => {
+ // The assertions below are on English copy; without pinning they would ride on
+ // jsdom's implicit en-US and pass vacuously if the fallback ever changed.
+ localStorage.clear();
+ localStorage.setItem(LOCALE_STORAGE_KEY, "en");
+});
+
+afterEach(() => {
+ cleanup();
+ localStorage.clear();
+ useProjectStore.getState().clear();
+});
+
+describe("LayoutPane camera availability", () => {
+ it("shows No webcam without overwriting the saved camera preset", () => {
+ renderLayout(seedProject(false));
+
+ const preset = screen.getByRole("combobox", { name: "Layout" });
+ 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("tells the user the saved preset was kept rather than thrown away", async () => {
+ const user = userEvent.setup();
+ renderLayout(seedProject(false));
+
+ await user.click(screen.getByRole("button", { name: "Help" }));
+ expect(screen.getByRole("note")).toHaveTextContent(/saved layout is kept/i);
+ });
+
+ it("keeps the saved preset active when a timeline clip has a camera", async () => {
+ const user = userEvent.setup();
+ renderLayout(seedProject(true));
+
+ const preset = screen.getByRole("combobox", { name: "Layout" });
+ 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();
+
+ // The camera-less hint must not leak into the normal case.
+ await user.click(screen.getByRole("button", { name: "Help" }));
+ expect(screen.getByRole("note")).not.toHaveTextContent(/saved layout is kept/i);
+ });
+});
diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx
index 5ac70471..1c5bded1 100644
--- a/src/components/ai-edition/RightPanes.tsx
+++ b/src/components/ai-edition/RightPanes.tsx
@@ -56,7 +56,7 @@ import { formatMs } from "@/lib/ai-edition/timeline/format";
import { locateVirtualPosition } from "@/lib/ai-edition/timeline/virtual-preview";
import type { TranscriptGateReason } from "@/lib/ai-edition/transcription/status";
import { getAssetPath } from "@/lib/assetPath";
-import { supportsWebcamReactiveZoom } from "@/lib/compositeLayout";
+import { resolveWebcamLayoutPreset, supportsWebcamReactiveZoom } from "@/lib/compositeLayout";
import { supportsCursorClickEffects } from "@/lib/cursor/cursorCapabilities";
import { CURSOR_THEMES, DEFAULT_CURSOR_THEME_ID } from "@/lib/cursor/cursorThemes";
import { buildGradientFromEditor } from "@/lib/gradientBuilder";
@@ -1500,33 +1500,52 @@ 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 whether this project has any camera at all.
+ //
+ // The preset is global while the camera is per clip, so a MIXED project shows the
+ // saved preset here while the playhead may sit over a camera-less clip — the
+ // preview and the scene answer `hasCamera` per clip, this panel answers it for the
+ // project. Deliberately `hasAnyClipWithCamera` (is a camera attached?) and not
+ // `assetCameraSource` (attached AND visible): a hidden camera keeps its saved preset
+ // on display, because this panel is the surface you would use to un-hide it.
+ //
+ // Memoised because the pane subscribes to the whole document, and `setLive` during a
+ // slider drag replaces it every frame — this scan is O(clips x assets).
+ const hasAnyCamera = useMemo(
+ () => (document ? hasAnyClipWithCamera(document.assets, document.timeline.clips) : false),
+ [document],
+ );
+ const effectiveLayoutPreset = resolveWebcamLayoutPreset(
+ settings.webcamLayoutPreset,
+ hasAnyCamera,
+ );
// 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;
+ // The controls go dead and the preset reads "No Webcam", but the saved preference is
+ // still on disk. Say so, otherwise the only signal the user gets is their setting
+ // apparently having been thrown away.
+ const helpText = hasDocument && !hasAnyCamera ? ts("layout.helpNoWebcam") : ts("layout.help");
return (
- } helpText={ts("layout.help")}>
+ } helpText={helpText}>