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}>
{ts("layout.preset")}
- +