From bb8570761d864c7969914f158da365c7577e7852 Mon Sep 17 00:00:00 2001 From: Arham Wani Date: Sun, 9 Aug 2026 00:00:27 +0530 Subject: [PATCH 1/4] 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() {
diff --git a/src/lib/compositeLayout.ts b/src/lib/compositeLayout.ts index eaf389e90..11eb35a50 100644 --- a/src/lib/compositeLayout.ts +++ b/src/lib/compositeLayout.ts @@ -144,6 +144,24 @@ export function resolveWebcamReactiveZoom( return Boolean(enabled) && supportsWebcamReactiveZoom(preset); } +/** + * Effective layout preset: the stored setting, gated by whether there is a camera to lay + * out at all. Without one the answer is `no-webcam` whatever the panel holds — hiding + * just the webcam slot is not enough, because the block presets size the SCREEN off the + * block and would leave it squeezed into half an empty canvas. One rule shared by the + * settings panel, the preview and the native scene, so the three cannot drift. + * + * `hasCamera` is the caller's question to answer, and they do not all ask it the same + * way on purpose: the preview and the scene resolve it per clip, while the settings + * panel asks whether the project has any camera at all. + */ +export function resolveWebcamLayoutPreset( + preset: WebcamLayoutPreset | undefined, + hasCamera: boolean, +): WebcamLayoutPreset { + return hasCamera ? (preset ?? "picture-in-picture") : "no-webcam"; +} + export interface WebcamCompositeLayout { screenRect: RenderRect; webcamRect: StyledRenderRect | null; diff --git a/src/native/sceneDescription.ts b/src/native/sceneDescription.ts index 7186d66e5..02246f58d 100644 --- a/src/native/sceneDescription.ts +++ b/src/native/sceneDescription.ts @@ -35,6 +35,7 @@ import { projectRegionsToSource } from "@/lib/ai-edition/timeline/timelineMap"; import { computeCompositeLayout, type RenderRect, + resolveWebcamLayoutPreset, resolveWebcamReactiveZoom, webcamSizeToFraction, } from "@/lib/compositeLayout"; @@ -586,7 +587,7 @@ export function buildSceneDescription( * gates the camera's own draw — it cannot give the screen its frame back. */ const layoutForClip = (screenSize: { width: number; height: number }, hasCamera: boolean) => { - const preset = hasCamera ? settings.webcamLayoutPreset : "no-webcam"; + const preset = resolveWebcamLayoutPreset(settings.webcamLayoutPreset, hasCamera); return computeCompositeLayout({ canvasSize: outputDims, maxContentSize: { From 583fe51c7a29dfe81abb1e6793b33644d8c5b561 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Thu, 20 Aug 2026 11:44:58 +0200 Subject: [PATCH 4/4] fix(layout): name the preset control, and say the saved layout survives Two gaps left by the camera-less fix, both on the same control. The select had no accessible name. Its `