From ff2f1c6602cf73c140cc5b6f0eac58ca98125dd1 Mon Sep 17 00:00:00 2001 From: Arham Wani Date: Sun, 9 Aug 2026 00:00:27 +0530 Subject: [PATCH 1/2] fix(layout): show no-webcam for camera-less projects --- .../ai-edition/RightPanes.layout.test.tsx | 89 +++++++++++++++++++ src/components/ai-edition/RightPanes.tsx | 21 ++--- 2 files changed, 100 insertions(+), 10 deletions(-) create mode 100644 src/components/ai-edition/RightPanes.layout.test.tsx 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..be898e422 --- /dev/null +++ b/src/components/ai-edition/RightPanes.layout.test.tsx @@ -0,0 +1,89 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom"; +import { cleanup, render, screen } 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(); + }); + + 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(); + }); +}); 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() {