Skip to content
Merged
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
8 changes: 5 additions & 3 deletions src/components/ai-edition/PreviewCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand Down
127 changes: 127 additions & 0 deletions src/components/ai-edition/RightPanes.layout.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<I18nProvider>
<LayoutPane />
</I18nProvider>,
);
}

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);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
45 changes: 32 additions & 13 deletions src/components/ai-edition/RightPanes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<Pane title={ts("layout.title")} icon={<LayoutIcon size={14} />} helpText={ts("layout.help")}>
<Pane title={ts("layout.title")} icon={<LayoutIcon size={14} />} helpText={helpText}>
<div className={styles.sectionLabel}>{ts("layout.preset")}</div>
<div className={styles.field}>
<label>{ts("layout.title")}</label>
<label htmlFor="layout-preset">{ts("layout.title")}</label>
<select
value={settings.webcamLayoutPreset}
id="layout-preset"
value={effectiveLayoutPreset}
disabled={layoutControlsDisabled}
onChange={(e) =>
void set({ webcamLayoutPreset: e.target.value as typeof settings.webcamLayoutPreset })
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/ar/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"reactiveWebcam": "تصغير عند التكبير",
"reactiveWebcamDescription": "تتقلص الكاميرا بسلاسة أثناء تكبير الفيديو حتى لا تعيق الرؤية.",
"help": "طريقة دمج كاميرا الويب مع الشاشة: صورة داخل صورة، أو تكديس عمودي، أو إطار مزدوج، وشكل القناع والحجم والانعكاس.",
"helpNoWebcam": "لا يحتوي هذا المشروع على كاميرا، لذلك فإن عناصر التحكم في التخطيط معطّلة ويعرض الإعداد المسبق «بدون كاميرا». يُحتفظ بالتخطيط المحفوظ لحين إضافة كاميرا.",
"shapes": {
"rectangle": "مستطيل",
"circle": "دائرة",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"reactiveWebcam": "Shrink on Zoom",
"reactiveWebcamDescription": "Camera smoothly shrinks while the video is zoomed in, so it stays out of the way.",
"help": "How the webcam is composed with the screen: picture-in-picture, vertical stack, dual frame, mask shape, size, and mirroring.",
"helpNoWebcam": "This project has no camera, so the layout controls are off and the preset reads “No Webcam”. Your saved layout is kept for when a camera is added.",
"shapes": {
"rectangle": "Rect",
"circle": "Circle",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/es/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"reactiveWebcam": "Reducir al ampliar",
"reactiveWebcamDescription": "La cámara se reduce suavemente mientras el vídeo está ampliado, para no estorbar.",
"help": "Cómo se compone la webcam con la pantalla: imagen en imagen, pila vertical, marco doble, forma de máscara, tamaño y efecto espejo.",
"helpNoWebcam": "Este proyecto no tiene cámara, así que los controles de diseño están desactivados y el preajuste muestra «Sin cámara». Tu diseño guardado se conserva para cuando añadas una cámara.",
"shapes": {
"rectangle": "Rect.",
"circle": "Círculo",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/fr/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"reactiveWebcam": "Réduire au zoom",
"reactiveWebcamDescription": "La caméra rétrécit doucement pendant le zoom, pour ne pas gêner.",
"help": "Composition de la webcam avec l'écran : incrustation, empilement vertical, double cadre, forme du masque, taille et effet miroir.",
"helpNoWebcam": "Ce projet n’a pas de caméra : les réglages de disposition sont désactivés et le préréglage affiche « Sans webcam ». Votre disposition enregistrée est conservée pour le jour où une caméra sera ajoutée.",
"shapes": {
"rectangle": "Rect.",
"circle": "Cercle",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/it/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"reactiveWebcam": "Riduci con lo zoom",
"reactiveWebcamDescription": "La webcam si riduce dolcemente durante lo zoom, così non intralcia.",
"help": "Come la webcam viene composta con lo schermo: picture-in-picture, pila verticale, doppio riquadro, forma della maschera, dimensione e effetto specchio.",
"helpNoWebcam": "Questo progetto non ha una webcam, quindi i controlli di layout sono disattivati e il preset mostra «Nessuna webcam». Il layout salvato viene conservato per quando aggiungerai una webcam.",
"shapes": {
"rectangle": "Rett.",
"circle": "Cerchio",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/ja-JP/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"reactiveWebcam": "ズーム時に縮小",
"reactiveWebcamDescription": "ズーム中はカメラがスムーズに縮小し、邪魔になりません。",
"help": "ウェブカメラと画面の合成方法: ピクチャインピクチャ、縦積み、デュアルフレーム、マスク形状、サイズ、左右反転。",
"helpNoWebcam": "このプロジェクトにはカメラがないため、レイアウトの設定は無効で、プリセットは「Webカメラなし」と表示されます。保存されたレイアウトは、カメラを追加したときのために保持されます。",
"shapes": {
"rectangle": "長方形",
"circle": "円",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/ko-KR/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"reactiveWebcam": "확대 시 축소",
"reactiveWebcamDescription": "확대하는 동안 카메라가 부드럽게 작아져 방해되지 않습니다.",
"help": "웹캠과 화면을 합성하는 방식: PIP, 세로 배치, 이중 프레임, 마스크 모양, 크기, 좌우 반전.",
"helpNoWebcam": "이 프로젝트에는 카메라가 없어 레이아웃 설정이 비활성화되고 프리셋이 “웹캠 없음”으로 표시됩니다. 저장된 레이아웃은 카메라를 추가할 때를 위해 유지됩니다.",
"shapes": {
"rectangle": "직사각형",
"circle": "원형",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/pt-BR/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"reactiveWebcam": "Encolher ao ampliar",
"reactiveWebcamDescription": "A câmera diminui suavemente enquanto o vídeo está ampliado, para não atrapalhar.",
"help": "Como a webcam é composta com a tela: picture-in-picture, pilha vertical, quadro duplo, formato da máscara, tamanho e espelhamento.",
"helpNoWebcam": "Este projeto não tem câmera, então os controles de layout estão desativados e a predefinição mostra “Sem Webcam”. Seu layout salvo é mantido para quando você adicionar uma câmera.",
"shapes": {
"rectangle": "Ret.",
"circle": "Círculo",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/ru/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"reactiveWebcam": "Уменьшать при зуме",
"reactiveWebcamDescription": "Камера плавно уменьшается во время приближения, чтобы не мешать.",
"help": "Как камера совмещается с экраном: «картинка в картинке», вертикальная стопка, двойной кадр, форма маски, размер и зеркальное отражение.",
"helpNoWebcam": "В этом проекте нет камеры, поэтому настройки компоновки отключены, а пресет показывает «Без веб-камеры». Сохранённая компоновка останется на случай, если вы добавите камеру.",
"shapes": {
"rectangle": "Прямоуг.",
"circle": "Круг",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/tr/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"reactiveWebcam": "Yakınlaştırınca küçült",
"reactiveWebcamDescription": "Video yakınlaştırıldığında kamera yumuşakça küçülür, böylece yoldan çekilir.",
"help": "Web kamerasının ekranla birleştirilme biçimi: resim içinde resim, dikey yığın, çift çerçeve, maske şekli, boyut ve aynalama.",
"helpNoWebcam": "Bu projede kamera yok; bu yüzden yerleşim denetimleri kapalı ve ön ayar “Web kamerası yok” gösteriyor. Kaydettiğiniz yerleşim, bir kamera eklediğinizde kullanılmak üzere korunur.",
"shapes": {
"rectangle": "Dikdörtgen",
"circle": "Daire",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/vi/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"reactiveWebcam": "Thu nhỏ khi phóng to",
"reactiveWebcamDescription": "Camera thu nhỏ mượt mà khi video được phóng to, để không che khuất.",
"help": "Cách ghép webcam với màn hình: hình trong hình, xếp dọc, khung đôi, hình dạng mặt nạ, kích thước và lật gương.",
"helpNoWebcam": "Dự án này không có camera nên các tùy chọn bố cục bị tắt và thiết lập sẵn hiển thị “Không có webcam”. Bố cục đã lưu của bạn vẫn được giữ lại cho khi bạn thêm camera.",
"shapes": {
"rectangle": "Chữ nhật",
"circle": "Tròn",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/zh-CN/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"reactiveWebcam": "缩放时缩小",
"reactiveWebcamDescription": "放大视频时摄像头会平滑缩小,以免遮挡内容。",
"help": "摄像头与屏幕的合成方式:画中画、垂直堆叠、双画面、遮罩形状、大小和镜像。",
"helpNoWebcam": "此项目没有摄像头,因此布局控件已禁用,预设显示为“无摄像头”。你保存的布局会保留,以便添加摄像头后使用。",
"shapes": {
"rectangle": "矩形",
"circle": "圆形",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/zh-TW/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"reactiveWebcam": "縮放時縮小",
"reactiveWebcamDescription": "放大影片時鏡頭會平滑縮小,以免遮擋內容。",
"help": "攝影機與螢幕的合成方式:子母畫面、垂直堆疊、雙畫面、遮罩形狀、大小與鏡像。",
"helpNoWebcam": "此專案沒有攝影機,因此版面配置控制項已停用,預設顯示為「無網路攝影機」。你儲存的版面配置會保留,以便新增攝影機後使用。",
"shapes": {
"rectangle": "矩形",
"circle": "圓形",
Expand Down
18 changes: 18 additions & 0 deletions src/lib/compositeLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading