Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions src/components/ai-edition/RightPanes.layout.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<I18nProvider>
<LayoutPane />
</I18nProvider>,
);
}

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();
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
21 changes: 11 additions & 10 deletions src/components/ai-edition/RightPanes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1500,33 +1500,34 @@ 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 (
<Pane title={ts("layout.title")} icon={<LayoutIcon size={14} />} helpText={ts("layout.help")}>
<div className={styles.sectionLabel}>{ts("layout.preset")}</div>
<div className={styles.field}>
<label>{ts("layout.title")}</label>
<select
value={settings.webcamLayoutPreset}
value={effectiveLayoutPreset}
disabled={layoutControlsDisabled}
onChange={(e) =>
void set({ webcamLayoutPreset: e.target.value as typeof settings.webcamLayoutPreset })
Expand Down
Loading