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
33 changes: 26 additions & 7 deletions src/components/ai-edition/PreviewCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
// as the user resizes the workbench.

import type { PointerEvent as ReactPointerEvent } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import {
type CameraFullscreenRegion,
type CropRegion,
Expand Down Expand Up @@ -62,6 +62,12 @@ import { webcamAnchorAt } from "@/lib/projectDefaults";
import { wallpaperStyle } from "@/lib/wallpaper";
import { getCssClipPath } from "@/lib/webcamMaskShapes";
import { computeCameraFullscreenProgress } from "@/lib/zoomMath/cameraFullscreenUtils";
import { webcamBoxSourceSize } from "@/native/sceneDescription";
import {
getWebcamNativeSize,
getWebcamNativeSizeRevision,
subscribeWebcamNativeSize,
} from "@/native/webcamSizeCache";
import { AnnotationLayer } from "./AnnotationLayer";
import { NativeCompositorOverlay } from "./NativeCompositorOverlay";
import styles from "./NewEditorShell.module.css";
Expand Down Expand Up @@ -119,11 +125,6 @@ interface PreviewCanvasProps {
// (the <video> contain-fits its true ratio within a box sized for the wrong
// one), on top of the intentional `settings.padding` margin.
const SCREEN_SOURCE_SIZE = { width: 1920, height: 1080 };
// ponytail: live preview defaults until the camera <video> reports its real
// dimensions via loadedmetadata. 4:3 is the legacy default — typical webcams
// capture at 1.33, and using a 16:9 default collapses vertical-stack to a
// degenerate full-bleed camera with 0px screen height.
const WEBCAM_SOURCE_SIZE = { width: 960, height: 720 };

export function PreviewCanvas(props: PreviewCanvasProps) {
const te = useScopedT("editor");
Expand Down Expand Up @@ -241,6 +242,23 @@ export function PreviewCanvas(props: PreviewCanvasProps) {
[assets, props.clips, props.currentTimeSec],
);
const activeClipHasCamera = Boolean(activeCameraTrack?.visible && activeCameraTrack.sourcePath);
// The drag box takes the camera's real proportions, the ones the native canvas draws
// (a portrait camera in "Original" is a portrait box), not a fixed 4:3.
const webcamSizeRevision = useSyncExternalStore(
subscribeWebcamNativeSize,
getWebcamNativeSizeRevision,
() => 0,
);
// biome-ignore lint/correctness/useExhaustiveDependencies: the revision re-reads the probed-size cache
const webcamSourceSize = useMemo(
() =>
webcamBoxSourceSize(
activeCameraTrack,
activeCameraTrack?.sourcePath ? getWebcamNativeSize(activeCameraTrack.sourcePath) : null,
settings.webcamCropRegion,
),
[activeCameraTrack, settings.webcamCropRegion, webcamSizeRevision],
);

const formatFill = useMemo(() => (document ? isFormatFillActive(document) : false), [document]);
const layout = useMemo(() => {
Expand Down Expand Up @@ -273,7 +291,7 @@ export function PreviewCanvas(props: PreviewCanvasProps) {
maxContentSize,
// Same box as the scene: a filled format gives the screen the whole padded area.
screenSize: formatFill ? maxContentSize : croppedScreenSize,
webcamSize: preset === "no-webcam" ? null : WEBCAM_SOURCE_SIZE,
webcamSize: preset === "no-webcam" ? null : webcamSourceSize,
layoutPreset: preset,
webcamSizePreset: settings.webcamSizePreset,
// Picture-in-picture only: the block layouts place and round their own camera.
Expand All @@ -287,6 +305,7 @@ export function PreviewCanvas(props: PreviewCanvasProps) {
screenNativeSize,
cropRegion,
activeClipHasCamera,
webcamSourceSize,
settings.webcamLayoutPreset,
settings.webcamMaskShape,
settings.webcamSizePreset,
Expand Down
20 changes: 18 additions & 2 deletions src/components/ai-edition/RightPanes.layout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -237,8 +237,10 @@ describe("LayoutPane picture-in-picture camera", () => {
const shapes = within(screen.getByRole("group", { name: "Camera shape" })).getAllByRole(
"button",
);
expect(shapes.map((b) => b.textContent)).toEqual(["Rectangle", "Square"]);
fireEvent.click(screen.getByRole("button", { name: "Square" }));
expect(shapes.map((b) => b.textContent)).toEqual(["Square", "Original"]);
fireEvent.click(within(shapes[1]).getByText("Original"));
expect(stored()).toMatchObject({ webcamMaskShape: "rectangle" });
fireEvent.click(within(shapes[0]).getByText("Square"));
expect(stored()).toMatchObject({ webcamMaskShape: "square" });
fireEvent.change(screen.getByRole("slider", { name: "Roundness" }), {
target: { value: "100" },
Expand All @@ -247,6 +249,20 @@ describe("LayoutPane picture-in-picture camera", () => {
expect(stored()).toMatchObject({ webcamMaskShape: "square", webcamRoundness: 1 });
});

it("moves an untouched roundness to each shape's own default, and keeps a tuned one", () => {
renderLayout(seedProject(true));
const shapes = within(screen.getByRole("group", { name: "Camera shape" })).getAllByRole(
"button",
);
const roundness = () => screen.getByRole("slider", { name: "Roundness" });
expect(roundness()).toHaveValue("70");
fireEvent.click(shapes[1]);
expect(stored()).toMatchObject({ webcamMaskShape: "rectangle", webcamRoundness: 0.4 });
fireEvent.change(roundness(), { target: { value: "55" } });
fireEvent.click(shapes[0]);
expect(stored()).toMatchObject({ webcamMaskShape: "square", webcamRoundness: 0.55 });
});

it("places the camera on one of eight anchors, bottom right by default", () => {
renderLayout(seedProject(true));
const grid = screen.getByRole("group", { name: "Position" });
Expand Down
5 changes: 4 additions & 1 deletion src/components/ai-edition/RightPanes.tooltips.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,8 +174,11 @@ describe("inspector labels", () => {
it("hangs no tooltip on the camera background labels or the crop zoom value", () => {
const root = mount(<LayoutPane />);

// "Original" names a camera shape too: neither carries a tooltip.
for (const name of ["Original", "Blur", "Cutout", "Custom"]) {
expect(screen.getByRole("button", { name }).querySelector("[title]")).toBeNull();
for (const button of screen.getAllByRole("button", { name })) {
expect(button.querySelector("[title]")).toBeNull();
}
}
expect(root.querySelector('[title="Zoom"]')).toBeNull();
});
Expand Down
13 changes: 10 additions & 3 deletions src/components/ai-edition/RightPanes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ import {
} from "@/lib/cursor/cursorThemes";
import { gradientSeedColor, oneColorGradient } from "@/lib/gradientBuilder";
import {
DEFAULT_WEBCAM_ROUNDNESS,
FRAME_THEMES,
type FrameTheme,
RECORDING_FRAMES,
Expand Down Expand Up @@ -2688,8 +2689,8 @@ const NATIVE_WEBCAM_BASE_PCT = 16.7;

// The camera's two proportions. Its rounding is the slider under them, and each icon draws it.
const CAMERA_SHAPES = [
{ value: "rectangle", labelKey: "layout.shapes.rectangle", x: 3, y: 6, w: 18, h: 12 },
{ value: "square", labelKey: "layout.shapes.square", x: 4, y: 4, w: 16, h: 16 },
{ value: "rectangle", labelKey: "layout.shapes.original", x: 3, y: 6, w: 18, h: 12 },
] as const satisfies ReadonlyArray<{ value: WebcamMask } & Record<string, unknown>>;

const ANCHOR_KEYS: Record<WebcamAnchor, string> = {
Expand Down Expand Up @@ -2948,7 +2949,13 @@ export function LayoutPane() {
value={settings.webcamMaskShape}
disabled={layoutControlsDisabled}
onChange={(shape) => {
void set({ webcamMaskShape: shape });
// An untouched roundness follows the shape to its own default; a tuned one stays.
const untouched =
settings.webcamRoundness === DEFAULT_WEBCAM_ROUNDNESS[settings.webcamMaskShape];
void set({
webcamMaskShape: shape,
...(untouched ? { webcamRoundness: DEFAULT_WEBCAM_ROUNDNESS[shape] } : {}),
});
if (isNativeCompositorActive()) {
setNativeParam("webcamShape", shape);
}
Expand All @@ -2962,7 +2969,7 @@ export function LayoutPane() {
value={Math.round(settings.webcamRoundness * 100)}
min={0}
max={100}
defaultValue={Math.round(DEFAULT_EDITOR_SETTINGS.webcamRoundness * 100)}
defaultValue={Math.round(DEFAULT_WEBCAM_ROUNDNESS[settings.webcamMaskShape] * 100)}
suffix="%"
disabled={layoutControlsDisabled}
onChange={(next) => setLive({ webcamRoundness: next / 100 })}
Expand Down
2 changes: 1 addition & 1 deletion src/components/video-editor/projectPersistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ describe("projectPersistence media compatibility", () => {
expect(normalizeProjectEditor({ webcamMaskShape: "rounded" }).webcamMaskShape).toBe("rounded");
expect(
normalizeProjectEditor({ webcamMaskShape: "not-a-real-shape" as never }).webcamMaskShape,
).toBe("rectangle");
).toBe("square");
});

it("normalizes webcam mirroring safely", () => {
Expand Down
2 changes: 1 addition & 1 deletion src/components/video-editor/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const DEFAULT_WEBCAM_LAYOUT_PRESET: WebcamLayoutPreset = "picture-in-pict

export type WebcamMaskShape = "rectangle" | "circle" | "square" | "rounded";

export const DEFAULT_WEBCAM_MASK_SHAPE: WebcamMaskShape = "rectangle";
export const DEFAULT_WEBCAM_MASK_SHAPE: WebcamMaskShape = "square";

export const DEFAULT_WEBCAM_MIRRORED = false;

Expand Down
4 changes: 2 additions & 2 deletions src/i18n/locales/ar/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -286,8 +286,8 @@
"webcamFraming": "تأطير كاميرا الويب",
"webcamFramingDrag": "اسحب الإطار لتحريكه، واسحب زاويةً للتكبير.",
"shapes": {
"rectangle": "مستطيل",
"square": "مربع"
"square": "مربع",
"original": "الأصلي"
},
"webcamPosition": "الموضع",
"anchors": {
Expand Down
4 changes: 2 additions & 2 deletions src/i18n/locales/cs/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -286,8 +286,8 @@
"webcamFraming": "Oříznutí kamery",
"webcamFramingDrag": "Přetažením rámečku jej posunete, přetažením rohu přiblížíte.",
"shapes": {
"rectangle": "Obdélník",
"square": "Čtverec"
"square": "Čtverec",
"original": "Původní"
},
"webcamPosition": "Poloha",
"anchors": {
Expand Down
4 changes: 2 additions & 2 deletions src/i18n/locales/de/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -286,8 +286,8 @@
"webcamFraming": "Webcam-Ausschnitt",
"webcamFramingDrag": "Ziehe den Rahmen, um ihn zu verschieben, oder eine Ecke, um zu zoomen.",
"shapes": {
"rectangle": "Rechteck",
"square": "Quadrat"
"square": "Quadrat",
"original": "Original"
},
"webcamPosition": "Position",
"anchors": {
Expand Down
4 changes: 2 additions & 2 deletions src/i18n/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -286,8 +286,8 @@
"webcamFraming": "Webcam crop",
"webcamFramingDrag": "Drag the frame to move it, or a corner to zoom.",
"shapes": {
"rectangle": "Rectangle",
"square": "Square"
"square": "Square",
"original": "Original"
},
"webcamPosition": "Position",
"anchors": {
Expand Down
4 changes: 2 additions & 2 deletions src/i18n/locales/es/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -286,8 +286,8 @@
"webcamFraming": "Encuadre de cámara",
"webcamFramingDrag": "Arrastra el marco para moverlo, o una esquina para hacer zoom.",
"shapes": {
"rectangle": "Rectángulo",
"square": "Cuadrado"
"square": "Cuadrado",
"original": "Original"
},
"webcamPosition": "Posición",
"anchors": {
Expand Down
4 changes: 2 additions & 2 deletions src/i18n/locales/fr/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -286,8 +286,8 @@
"webcamFraming": "Cadrage de la webcam",
"webcamFramingDrag": "Faites glisser le cadre pour le déplacer, ou un coin pour zoomer.",
"shapes": {
"rectangle": "Rectangle",
"square": "Carré"
"square": "Carré",
"original": "Original"
},
"webcamPosition": "Position",
"anchors": {
Expand Down
4 changes: 2 additions & 2 deletions src/i18n/locales/it/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -286,8 +286,8 @@
"webcamFraming": "Inquadratura webcam",
"webcamFramingDrag": "Trascina il riquadro per spostarlo, o un angolo per lo zoom.",
"shapes": {
"rectangle": "Rettangolo",
"square": "Quadrato"
"square": "Quadrato",
"original": "Originale"
},
"webcamPosition": "Posizione",
"anchors": {
Expand Down
4 changes: 2 additions & 2 deletions src/i18n/locales/ja-JP/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -286,8 +286,8 @@
"webcamFraming": "ウェブカメラの構図",
"webcamFramingDrag": "枠をドラッグして移動、角をドラッグしてズームします。",
"shapes": {
"rectangle": "長方形",
"square": "正方形"
"square": "正方形",
"original": "オリジナル"
},
"webcamPosition": "位置",
"anchors": {
Expand Down
4 changes: 2 additions & 2 deletions src/i18n/locales/ko-KR/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -286,8 +286,8 @@
"webcamFraming": "웹캠 구도",
"webcamFramingDrag": "프레임을 드래그해 옮기고, 모서리를 드래그해 확대/축소하세요.",
"shapes": {
"rectangle": "직사각형",
"square": "정사각형"
"square": "정사각형",
"original": "원본"
},
"webcamPosition": "위치",
"anchors": {
Expand Down
4 changes: 2 additions & 2 deletions src/i18n/locales/pt-BR/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -286,8 +286,8 @@
"webcamFraming": "Enquadramento da webcam",
"webcamFramingDrag": "Arraste o quadro para movê-lo, ou um canto para dar zoom.",
"shapes": {
"rectangle": "Retângulo",
"square": "Quadrado"
"square": "Quadrado",
"original": "Original"
},
"webcamPosition": "Posição",
"anchors": {
Expand Down
4 changes: 2 additions & 2 deletions src/i18n/locales/ru/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -286,8 +286,8 @@
"webcamFraming": "Кадрирование веб-камеры",
"webcamFramingDrag": "Перетащите рамку, чтобы сдвинуть её, или угол, чтобы изменить масштаб.",
"shapes": {
"rectangle": "Прямоугольник",
"square": "Квадрат"
"square": "Квадрат",
"original": "Исходная"
},
"webcamPosition": "Положение",
"anchors": {
Expand Down
4 changes: 2 additions & 2 deletions src/i18n/locales/tr/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -286,8 +286,8 @@
"webcamFraming": "Webcam kadrajı",
"webcamFramingDrag": "Taşımak için çerçeveyi, yakınlaştırmak için bir köşesini sürükleyin.",
"shapes": {
"rectangle": "Dikdörtgen",
"square": "Kare"
"square": "Kare",
"original": "Orijinal"
},
"webcamPosition": "Konum",
"anchors": {
Expand Down
4 changes: 2 additions & 2 deletions src/i18n/locales/vi/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -286,8 +286,8 @@
"webcamFraming": "Khung hình webcam",
"webcamFramingDrag": "Kéo khung để di chuyển, hoặc kéo một góc để thu phóng.",
"shapes": {
"rectangle": "Chữ nhật",
"square": "Vuông"
"square": "Vuông",
"original": "Gốc"
},
"webcamPosition": "Vị trí",
"anchors": {
Expand Down
4 changes: 2 additions & 2 deletions src/i18n/locales/zh-CN/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -286,8 +286,8 @@
"webcamFraming": "摄像头构图",
"webcamFramingDrag": "拖动取景框可移动,拖动一角可缩放。",
"shapes": {
"rectangle": "矩形",
"square": "正方形"
"square": "正方形",
"original": "原始"
},
"webcamPosition": "位置",
"anchors": {
Expand Down
4 changes: 2 additions & 2 deletions src/i18n/locales/zh-TW/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -286,8 +286,8 @@
"webcamFraming": "攝影機構圖",
"webcamFramingDrag": "拖曳取景框可移動,拖曳一角可縮放。",
"shapes": {
"rectangle": "矩形",
"square": "正方形"
"square": "正方形",
"original": "原始"
},
"webcamPosition": "位置",
"anchors": {
Expand Down
5 changes: 4 additions & 1 deletion src/lib/ai-edition/store/editorSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,10 @@ describe("patchEditorSettings", () => {
expect(read({ webcamMaskShape: "square" })).toEqual(["square", 0.3]);
// A stored roundness wins over the one the shape implied, and stays in 0..1.
expect(read({ webcamMaskShape: "circle", webcamRoundness: 0.2 })).toEqual(["square", 0.2]);
expect(read({ webcamRoundness: 4 })).toEqual(["rectangle", 1]);
expect(read({ webcamRoundness: 4 })).toEqual(["square", 1]);
// A stored rectangle keeps the rounding it drew; nothing stored is the factory square.
expect(read({ webcamMaskShape: "rectangle" })).toEqual(["rectangle", 0.3]);
expect(read({})).toEqual(["square", 0.7]);
});

it("reads a camera size outside the slider's 15–50% into it", () => {
Expand Down
4 changes: 3 additions & 1 deletion src/lib/compositeLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,9 @@ export function computeCompositeLayout(params: {
webcamSizePreset = 25,
webcamAnchor = "bottom-right",
webcamMaskShape = "rectangle",
webcamRoundness = DEFAULT_WEBCAM_ROUNDNESS,
webcamRoundness = DEFAULT_WEBCAM_ROUNDNESS[
webcamMaskShape === "square" || webcamMaskShape === "circle" ? "square" : "rectangle"
],
frame = "none",
} = params;
const { width: canvasWidth, height: canvasHeight } = canvasSize;
Expand Down
Loading
Loading