diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 35778ec0..daf5f424 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -52,10 +52,11 @@ import type { PlaybackSpeed, WebcamLayoutPreset, WebcamMaskShape, + WebcamSizePreset, ZoomDepth, ZoomFocusMode, } from "./types"; -import { MAX_PLAYBACK_SPEED, SPEED_OPTIONS } from "./types"; +import { DEFAULT_WEBCAM_SIZE_PRESET, MAX_PLAYBACK_SPEED, SPEED_OPTIONS } from "./types"; function CustomSpeedInput({ value, @@ -195,7 +196,11 @@ interface SettingsPanelProps { onGifSizePresetChange?: (preset: GifSizePreset) => void; gifOutputDimensions?: { width: number; height: number }; onExport?: () => void; - unsavedExport?: { arrayBuffer: ArrayBuffer; fileName: string; format: string } | null; + unsavedExport?: { + arrayBuffer: ArrayBuffer; + fileName: string; + format: string; + } | null; onSaveUnsavedExport?: () => void; selectedAnnotationId?: string | null; annotationRegions?: AnnotationRegion[]; @@ -213,6 +218,9 @@ interface SettingsPanelProps { onWebcamLayoutPresetChange?: (preset: WebcamLayoutPreset) => void; webcamMaskShape?: import("./types").WebcamMaskShape; onWebcamMaskShapeChange?: (shape: import("./types").WebcamMaskShape) => void; + webcamSizePreset?: WebcamSizePreset; + onWebcamSizePresetChange?: (size: WebcamSizePreset) => void; + onWebcamSizePresetCommit?: () => void; } export default SettingsPanel; @@ -286,6 +294,9 @@ export function SettingsPanel({ onWebcamLayoutPresetChange, webcamMaskShape = "rectangle", onWebcamMaskShapeChange, + webcamSizePreset = DEFAULT_WEBCAM_SIZE_PRESET, + onWebcamSizePresetChange, + onWebcamSizePresetCommit, }: SettingsPanelProps) { const t = useScopedT("settings"); const [wallpaperPaths, setWallpaperPaths] = useState([]); @@ -837,6 +848,27 @@ export function SettingsPanel({ )} + {webcamLayoutPreset === "picture-in-picture" && ( +
+
+
+ {t("layout.webcamSize")} +
+
+ {webcamSizePreset}% +
+
+ onWebcamSizePresetChange?.(values[0])} + onValueCommit={() => onWebcamSizePresetCommit?.()} + min={10} + max={50} + step={1} + className="w-full" + /> +
+ )} )} @@ -1102,7 +1134,9 @@ export function SettingsPanel({ : "border-white/10 hover:border-[#34B27B]/40 opacity-80 hover:opacity-100 bg-white/5", )} style={{ background: g }} - aria-label={t("background.gradientLabel", { index: idx + 1 })} + aria-label={t("background.gradientLabel", { + index: idx + 1, + })} onClick={() => { setGradient(g); onWallpaperChange(g); diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 58dd3609..88c3aae0 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -98,6 +98,7 @@ export default function VideoEditor() { aspectRatio, webcamLayoutPreset, webcamMaskShape, + webcamSizePreset, webcamPosition, } = editorState; @@ -215,6 +216,7 @@ export default function VideoEditor() { aspectRatio: normalizedEditor.aspectRatio, webcamLayoutPreset: normalizedEditor.webcamLayoutPreset, webcamMaskShape: normalizedEditor.webcamMaskShape, + webcamSizePreset: normalizedEditor.webcamSizePreset, webcamPosition: normalizedEditor.webcamPosition, }); setExportQuality(normalizedEditor.exportQuality); @@ -305,6 +307,7 @@ export default function VideoEditor() { aspectRatio, webcamLayoutPreset, webcamMaskShape, + webcamSizePreset, webcamPosition, exportQuality, exportFormat, @@ -425,6 +428,7 @@ export default function VideoEditor() { aspectRatio, webcamLayoutPreset, webcamMaskShape, + webcamSizePreset, webcamPosition, exportQuality, exportFormat, @@ -480,6 +484,7 @@ export default function VideoEditor() { aspectRatio, webcamLayoutPreset, webcamMaskShape, + webcamSizePreset, webcamPosition, exportQuality, exportFormat, @@ -697,7 +702,11 @@ export default function VideoEditor() { pushState((prev) => ({ zoomRegions: prev.zoomRegions.map((region) => region.id === id - ? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end) } + ? { + ...region, + startMs: Math.round(span.start), + endMs: Math.round(span.end), + } : region, ), })); @@ -710,7 +719,11 @@ export default function VideoEditor() { pushState((prev) => ({ trimRegions: prev.trimRegions.map((region) => region.id === id - ? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end) } + ? { + ...region, + startMs: Math.round(span.start), + endMs: Math.round(span.end), + } : region, ), })); @@ -736,7 +749,11 @@ export default function VideoEditor() { pushState((prev) => ({ zoomRegions: prev.zoomRegions.map((region) => region.id === selectedZoomId - ? { ...region, depth, focus: clampFocusToDepth(region.focus, depth) } + ? { + ...region, + depth, + focus: clampFocusToDepth(region.focus, depth), + } : region, ), })); @@ -758,7 +775,9 @@ export default function VideoEditor() { const handleZoomDelete = useCallback( (id: string) => { - pushState((prev) => ({ zoomRegions: prev.zoomRegions.filter((r) => r.id !== id) })); + pushState((prev) => ({ + zoomRegions: prev.zoomRegions.filter((r) => r.id !== id), + })); if (selectedZoomId === id) { setSelectedZoomId(null); } @@ -768,7 +787,9 @@ export default function VideoEditor() { const handleTrimDelete = useCallback( (id: string) => { - pushState((prev) => ({ trimRegions: prev.trimRegions.filter((r) => r.id !== id) })); + pushState((prev) => ({ + trimRegions: prev.trimRegions.filter((r) => r.id !== id), + })); if (selectedTrimId === id) { setSelectedTrimId(null); } @@ -794,7 +815,9 @@ export default function VideoEditor() { endMs: Math.round(span.end), speed: DEFAULT_PLAYBACK_SPEED, }; - pushState((prev) => ({ speedRegions: [...prev.speedRegions, newRegion] })); + pushState((prev) => ({ + speedRegions: [...prev.speedRegions, newRegion], + })); setSelectedSpeedId(id); setSelectedZoomId(null); setSelectedTrimId(null); @@ -859,7 +882,9 @@ export default function VideoEditor() { style: { ...DEFAULT_ANNOTATION_STYLE }, zIndex, }; - pushState((prev) => ({ annotationRegions: [...prev.annotationRegions, newRegion] })); + pushState((prev) => ({ + annotationRegions: [...prev.annotationRegions, newRegion], + })); setSelectedAnnotationId(id); setSelectedZoomId(null); setSelectedTrimId(null); @@ -872,7 +897,11 @@ export default function VideoEditor() { pushState((prev) => ({ annotationRegions: prev.annotationRegions.map((region) => region.id === id - ? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end) } + ? { + ...region, + startMs: Math.round(span.start), + endMs: Math.round(span.end), + } : region, ), })); @@ -1193,6 +1222,7 @@ export default function VideoEditor() { annotationRegions, webcamLayoutPreset, webcamMaskShape, + webcamSizePreset, webcamPosition, previewWidth, previewHeight, @@ -1326,6 +1356,7 @@ export default function VideoEditor() { annotationRegions, webcamLayoutPreset, webcamMaskShape, + webcamSizePreset, webcamPosition, previewWidth, previewHeight, @@ -1396,6 +1427,7 @@ export default function VideoEditor() { aspectRatio, webcamLayoutPreset, webcamMaskShape, + webcamSizePreset, webcamPosition, exportQuality, handleExportSaved, @@ -1618,6 +1650,7 @@ export default function VideoEditor() { webcamVideoPath={webcamVideoPath || undefined} webcamLayoutPreset={webcamLayoutPreset} webcamMaskShape={webcamMaskShape} + webcamSizePreset={webcamSizePreset} webcamPosition={webcamPosition} onWebcamPositionChange={(pos) => updateState({ webcamPosition: pos })} onWebcamPositionDragEnd={commitState} @@ -1768,6 +1801,9 @@ export default function VideoEditor() { } webcamMaskShape={webcamMaskShape} onWebcamMaskShapeChange={(shape) => pushState({ webcamMaskShape: shape })} + webcamSizePreset={webcamSizePreset} + onWebcamSizePresetChange={(v) => updateState({ webcamSizePreset: v })} + onWebcamSizePresetCommit={commitState} videoElement={videoPlaybackRef.current?.video || null} exportQuality={exportQuality} onExportQualityChange={setExportQuality} diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index d659afe6..08c1c253 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -24,6 +24,7 @@ import { type Size, type StyledRenderRect, type WebcamLayoutPreset, + type WebcamSizePreset, } from "@/lib/compositeLayout"; import { getCssClipPath } from "@/lib/webcamMaskShapes"; import { @@ -69,6 +70,7 @@ interface VideoPlaybackProps { webcamVideoPath?: string; webcamLayoutPreset: WebcamLayoutPreset; webcamMaskShape?: import("./types").WebcamMaskShape; + webcamSizePreset?: WebcamSizePreset; webcamPosition?: { cx: number; cy: number } | null; onWebcamPositionChange?: (position: { cx: number; cy: number }) => void; onWebcamPositionDragEnd?: () => void; @@ -119,6 +121,7 @@ const VideoPlayback = forwardRef( webcamVideoPath, webcamLayoutPreset, webcamMaskShape, + webcamSizePreset, webcamPosition, onWebcamPositionChange, onWebcamPositionDragEnd, @@ -195,7 +198,10 @@ const VideoPlayback = forwardRef( const isPlayingRef = useRef(isPlaying); const isSeekingRef = useRef(false); const allowPlaybackRef = useRef(false); - const lockedVideoDimensionsRef = useRef<{ width: number; height: number } | null>(null); + const lockedVideoDimensionsRef = useRef<{ + width: number; + height: number; + } | null>(null); const layoutVideoContentRef = useRef<(() => void) | null>(null); const trimRegionsRef = useRef([]); const speedRegionsRef = useRef([]); @@ -283,6 +289,7 @@ const VideoPlayback = forwardRef( padding, webcamDimensions, webcamLayoutPreset, + webcamSizePreset, webcamPosition, webcamMaskShape, }); @@ -314,6 +321,7 @@ const VideoPlayback = forwardRef( padding, webcamDimensions, webcamLayoutPreset, + webcamSizePreset, webcamPosition, webcamMaskShape, ]); @@ -648,7 +656,11 @@ const VideoPlayback = forwardRef( app.ticker.maxFPS = 60; if (!mounted) { - app.destroy(true, { children: true, texture: true, textureSource: true }); + app.destroy(true, { + children: true, + texture: true, + textureSource: true, + }); return; } @@ -672,7 +684,11 @@ const VideoPlayback = forwardRef( mounted = false; setPixiReady(false); if (app && app.renderer) { - app.destroy(true, { children: true, texture: true, textureSource: true }); + app.destroy(true, { + children: true, + texture: true, + textureSource: true, + }); } appRef.current = null; cameraContainerRef.current = null; @@ -853,12 +869,19 @@ const VideoPlayback = forwardRef( const ss = stageSizeRef.current; const viewportRatio = bm.width > 0 && bm.height > 0 - ? { widthRatio: ss.width / bm.width, heightRatio: ss.height / bm.height } + ? { + widthRatio: ss.width / bm.width, + heightRatio: ss.height / bm.height, + } : undefined; const { region, strength, blendedScale, transition } = findDominantRegion( zoomRegionsRef.current, currentTimeRef.current, - { connectZooms: true, cursorTelemetry: cursorTelemetryRef.current, viewportRatio }, + { + connectZooms: true, + cursorTelemetry: cursorTelemetryRef.current, + viewportRatio, + }, ); const defaultFocus = DEFAULT_FOCUS; diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index fad8fa37..45513d4c 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -15,6 +15,7 @@ import { DEFAULT_WEBCAM_LAYOUT_PRESET, DEFAULT_WEBCAM_MASK_SHAPE, DEFAULT_WEBCAM_POSITION, + DEFAULT_WEBCAM_SIZE_PRESET, DEFAULT_ZOOM_DEPTH, MAX_PLAYBACK_SPEED, MIN_PLAYBACK_SPEED, @@ -23,6 +24,7 @@ import { type WebcamLayoutPreset, type WebcamMaskShape, type WebcamPosition, + type WebcamSizePreset, type ZoomRegion, } from "./types"; @@ -50,6 +52,7 @@ export interface ProjectEditorState { aspectRatio: AspectRatio; webcamLayoutPreset: WebcamLayoutPreset; webcamMaskShape: WebcamMaskShape; + webcamSizePreset: WebcamSizePreset; webcamPosition: WebcamPosition | null; exportQuality: ExportQuality; exportFormat: ExportFormat; @@ -362,6 +365,10 @@ export function normalizeProjectEditor(editor: Partial): Pro editor.webcamMaskShape === "rounded" ? editor.webcamMaskShape : DEFAULT_WEBCAM_MASK_SHAPE, + webcamSizePreset: + typeof editor.webcamSizePreset === "number" && isFiniteNumber(editor.webcamSizePreset) + ? Math.max(10, Math.min(50, editor.webcamSizePreset)) + : DEFAULT_WEBCAM_SIZE_PRESET, webcamPosition: editor.webcamPosition && typeof editor.webcamPosition === "object" && diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index 6107a8b4..e8e649fa 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -3,6 +3,10 @@ import type { WebcamLayoutPreset } from "@/lib/compositeLayout"; export type ZoomDepth = 1 | 2 | 3 | 4 | 5 | 6; export type ZoomFocusMode = "manual" | "auto"; export type { WebcamLayoutPreset }; +/** Webcam size as a percentage of the canvas reference dimension (10–50). */ +export type WebcamSizePreset = number; + +export const DEFAULT_WEBCAM_SIZE_PRESET: WebcamSizePreset = 25; export const DEFAULT_WEBCAM_LAYOUT_PRESET: WebcamLayoutPreset = "picture-in-picture"; diff --git a/src/components/video-editor/videoPlayback/layoutUtils.ts b/src/components/video-editor/videoPlayback/layoutUtils.ts index 2444c39b..5059ccb4 100644 --- a/src/components/video-editor/videoPlayback/layoutUtils.ts +++ b/src/components/video-editor/videoPlayback/layoutUtils.ts @@ -5,6 +5,7 @@ import { type Size, type StyledRenderRect, type WebcamLayoutPreset, + type WebcamSizePreset, } from "@/lib/compositeLayout"; import type { CropRegion, WebcamMaskShape } from "../types"; @@ -20,6 +21,7 @@ interface LayoutParams { padding?: number; webcamDimensions?: Size | null; webcamLayoutPreset?: WebcamLayoutPreset; + webcamSizePreset?: WebcamSizePreset; webcamPosition?: { cx: number; cy: number } | null; webcamMaskShape?: WebcamMaskShape; } @@ -47,6 +49,7 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null { padding = 0, webcamDimensions, webcamLayoutPreset, + webcamSizePreset, webcamPosition, webcamMaskShape, } = params; @@ -95,6 +98,7 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null { screenSize: { width: croppedVideoWidth, height: croppedVideoHeight }, webcamSize: webcamDimensions, layoutPreset: webcamLayoutPreset, + webcamSizePreset, webcamPosition, webcamMaskShape, }); diff --git a/src/hooks/useEditorHistory.ts b/src/hooks/useEditorHistory.ts index d261c1f8..cc19222d 100644 --- a/src/hooks/useEditorHistory.ts +++ b/src/hooks/useEditorHistory.ts @@ -7,6 +7,7 @@ import type { WebcamLayoutPreset, WebcamMaskShape, WebcamPosition, + WebcamSizePreset, ZoomRegion, } from "@/components/video-editor/types"; import { @@ -14,6 +15,7 @@ import { DEFAULT_WEBCAM_LAYOUT_PRESET, DEFAULT_WEBCAM_MASK_SHAPE, DEFAULT_WEBCAM_POSITION, + DEFAULT_WEBCAM_SIZE_PRESET, } from "@/components/video-editor/types"; import type { AspectRatio } from "@/utils/aspectRatioUtils"; @@ -34,6 +36,7 @@ export interface EditorState { aspectRatio: AspectRatio; webcamLayoutPreset: WebcamLayoutPreset; webcamMaskShape: WebcamMaskShape; + webcamSizePreset: WebcamSizePreset; webcamPosition: WebcamPosition | null; } @@ -52,6 +55,7 @@ export const INITIAL_EDITOR_STATE: EditorState = { aspectRatio: "16:9", webcamLayoutPreset: DEFAULT_WEBCAM_LAYOUT_PRESET, webcamMaskShape: DEFAULT_WEBCAM_MASK_SHAPE, + webcamSizePreset: DEFAULT_WEBCAM_SIZE_PRESET, webcamPosition: DEFAULT_WEBCAM_POSITION, }; diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 05b7df78..16ede59e 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -26,7 +26,8 @@ "selectPreset": "Select preset", "pictureInPicture": "Picture in Picture", "verticalStack": "Vertical Stack", - "webcamShape": "Camera Shape" + "webcamShape": "Camera Shape", + "webcamSize": "Webcam Size" }, "effects": { "title": "Video Effects", diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index 97b27ba7..b7a1bde5 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -26,7 +26,8 @@ "selectPreset": "Seleccionar predefinido", "pictureInPicture": "Imagen en imagen", "verticalStack": "Apilado vertical", - "webcamShape": "Forma de cámara" + "webcamShape": "Forma de cámara", + "webcamSize": "Tamaño de cámara" }, "effects": { "title": "Efectos de video", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 970d2d42..da5f4dcf 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -26,7 +26,8 @@ "selectPreset": "选择预设", "pictureInPicture": "画中画", "verticalStack": "垂直堆叠", - "webcamShape": "摄像头形状" + "webcamShape": "摄像头形状", + "webcamSize": "摄像头大小" }, "effects": { "title": "视频效果", diff --git a/src/lib/compositeLayout.test.ts b/src/lib/compositeLayout.test.ts index 93ce2c5e..90883b1b 100644 --- a/src/lib/compositeLayout.test.ts +++ b/src/lib/compositeLayout.test.ts @@ -24,16 +24,111 @@ describe("computeCompositeLayout", () => { webcamSize: { width: 1920, height: 1080 }, }); + const refDim = Math.sqrt(1280 * 720); + const defaultFraction = 25 / 100; // DEFAULT_WEBCAM_SIZE_PRESET = 25 expect(layout).not.toBeNull(); expect(layout!.webcamRect).not.toBeNull(); - expect(layout!.webcamRect!.width).toBeLessThanOrEqual(Math.round(1280 * 0.18) + 1); - expect(layout!.webcamRect!.height).toBeLessThanOrEqual(Math.round(720 * 0.18) + 1); + expect(layout!.webcamRect!.width).toBeLessThanOrEqual(Math.round(refDim * defaultFraction) + 1); + expect(layout!.webcamRect!.height).toBeLessThanOrEqual( + Math.round(refDim * defaultFraction) + 1, + ); expect( Math.abs(layout!.webcamRect!.width * 1080 - layout!.webcamRect!.height * 1920), ).toBeLessThanOrEqual(1920); }); - it("uses cover-style full-width stacking in vertical stack mode", () => { + it("produces consistent webcam size across landscape and portrait aspect ratios", () => { + const webcamSize = { width: 1280, height: 720 }; + const landscape = computeCompositeLayout({ + canvasSize: { width: 1920, height: 1080 }, + screenSize: { width: 1920, height: 1080 }, + webcamSize, + webcamSizePreset: 50, + }); + const portrait = computeCompositeLayout({ + canvasSize: { width: 1080, height: 1920 }, + screenSize: { width: 1080, height: 1920 }, + webcamSize, + webcamSizePreset: 50, + }); + + expect(landscape).not.toBeNull(); + expect(portrait).not.toBeNull(); + // Same total pixel count — webcam area should be comparable + const landscapeArea = landscape!.webcamRect!.width * landscape!.webcamRect!.height; + const portraitArea = portrait!.webcamRect!.width * portrait!.webcamRect!.height; + expect(landscapeArea).toBe(portraitArea); + }); + + it("scales the webcam proportionally as webcamSizePreset increases", () => { + const canvasSize = { width: 1920, height: 1080 }; + const screenSize = { width: 1920, height: 1080 }; + const webcamSize = { width: 1280, height: 720 }; + + const small = computeCompositeLayout({ + canvasSize, + screenSize, + webcamSize, + webcamSizePreset: 10, + }); + const medium = computeCompositeLayout({ + canvasSize, + screenSize, + webcamSize, + webcamSizePreset: 25, + }); + const large = computeCompositeLayout({ + canvasSize, + screenSize, + webcamSize, + webcamSizePreset: 50, + }); + + expect(small!.webcamRect!.width).toBeLessThan(medium!.webcamRect!.width); + expect(medium!.webcamRect!.width).toBeLessThan(large!.webcamRect!.width); + expect(small!.webcamRect!.height).toBeLessThan(medium!.webcamRect!.height); + expect(medium!.webcamRect!.height).toBeLessThan(large!.webcamRect!.height); + }); + + it("clamps webcamSizePreset to the valid range (10–50)", () => { + const canvasSize = { width: 1920, height: 1080 }; + const screenSize = { width: 1920, height: 1080 }; + const webcamSize = { width: 1280, height: 720 }; + + const atMin = computeCompositeLayout({ + canvasSize, + screenSize, + webcamSize, + webcamSizePreset: 10, + }); + const belowMin = computeCompositeLayout({ + canvasSize, + screenSize, + webcamSize, + webcamSizePreset: 1, + }); + const atMax = computeCompositeLayout({ + canvasSize, + screenSize, + webcamSize, + webcamSizePreset: 50, + }); + const aboveMax = computeCompositeLayout({ + canvasSize, + screenSize, + webcamSize, + webcamSizePreset: 100, + }); + + // Values below 10 should clamp to 10 + expect(belowMin!.webcamRect!.width).toBe(atMin!.webcamRect!.width); + expect(belowMin!.webcamRect!.height).toBe(atMin!.webcamRect!.height); + // Values above 50 should clamp to 50 + expect(aboveMax!.webcamRect!.width).toBe(atMax!.webcamRect!.width); + expect(aboveMax!.webcamRect!.height).toBe(atMax!.webcamRect!.height); + }); + + it("centers the combined screen and webcam stack in vertical stack mode", () => { const layout = computeCompositeLayout({ canvasSize: { width: 1920, height: 1080 }, maxContentSize: { width: 1536, height: 864 }, @@ -43,23 +138,19 @@ describe("computeCompositeLayout", () => { }); expect(layout).not.toBeNull(); - expect(layout?.screenRect).toEqual({ - x: 0, - y: 0, - width: 1920, - height: 0, - }); - expect(layout?.webcamRect).toEqual({ - x: 0, - y: 0, - width: 1920, - height: 1080, - borderRadius: 0, - }); - expect(layout?.screenCover).toBe(true); + // Webcam is full-width at the bottom + expect(layout!.webcamRect).not.toBeNull(); + expect(layout!.webcamRect!.x).toBe(0); + expect(layout!.webcamRect!.width).toBe(1920); + expect(layout!.webcamRect!.borderRadius).toBe(0); + // Screen fills remaining space at the top (cover mode) + expect(layout!.screenRect.x).toBe(0); + expect(layout!.screenRect.y).toBe(0); + expect(layout!.screenRect.width).toBe(1920); + expect(layout!.screenCover).toBe(true); }); - it("fills the canvas with the screen when vertical stack has no webcam", () => { + it("keeps the screen full-canvas and omits the webcam when dimensions are unavailable in stack mode", () => { const layout = computeCompositeLayout({ canvasSize: { width: 1920, height: 1080 }, maxContentSize: { width: 1536, height: 864 }, diff --git a/src/lib/compositeLayout.ts b/src/lib/compositeLayout.ts index a3c84e87..48583a37 100644 --- a/src/lib/compositeLayout.ts +++ b/src/lib/compositeLayout.ts @@ -16,6 +16,8 @@ export interface Size { } export type WebcamLayoutPreset = "picture-in-picture" | "vertical-stack"; +/** Webcam size as a percentage of the canvas reference dimension (10–50). */ +export type WebcamSizePreset = number; export interface WebcamLayoutShadow { color: string; @@ -32,7 +34,6 @@ interface BorderRadiusRule { interface OverlayTransform { type: "overlay"; - maxStageFraction: number; marginFraction: number; minMargin: number; minSize: number; @@ -57,7 +58,13 @@ export interface WebcamCompositeLayout { screenCover?: boolean; } -const MAX_STAGE_FRACTION = 0.18; +/** Convert a webcam size percentage (10–50) to a fraction of the reference dimension. */ +function webcamSizeToFraction(percent: number): number { + const safe = Number.isFinite(percent) ? percent : 25; + const clamped = Math.max(10, Math.min(50, safe)); + return clamped / 100; +} + const MARGIN_FRACTION = 0.02; const MAX_BORDER_RADIUS = 24; const WEBCAM_LAYOUT_PRESET_MAP: Record = { @@ -65,7 +72,6 @@ const WEBCAM_LAYOUT_PRESET_MAP: Record