From 7aca8b8bc16a0eeea5f299f4a187d6811c189635 Mon Sep 17 00:00:00 2001 From: Siddharth Date: Sat, 21 Mar 2026 20:07:09 -0700 Subject: [PATCH 1/3] move project settings to top --- src/components/launch/LaunchWindow.tsx | 8 +- src/components/video-editor/SettingsPanel.tsx | 27 -- src/components/video-editor/VideoEditor.tsx | 415 +++++++++--------- 3 files changed, 220 insertions(+), 230 deletions(-) diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 32f3f102..252f4d76 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -19,6 +19,7 @@ import { RxDragHandleDots2 } from "react-icons/rx"; import { useI18n, useScopedT } from "@/contexts/I18nContext"; import { type Locale, SUPPORTED_LOCALES } from "@/i18n/config"; import { getLocaleName } from "@/i18n/loader"; +import { isMac as getIsMac } from "@/utils/platformUtils"; import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter"; import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices"; import { useScreenRecorder } from "../../hooks/useScreenRecorder"; @@ -67,6 +68,11 @@ const windowBtnClasses = export function LaunchWindow() { const t = useScopedT("launch"); const { locale, setLocale } = useI18n(); + const [isMac, setIsMac] = useState(false); + + useEffect(() => { + getIsMac().then(setIsMac); + }, []); const { recording, @@ -196,7 +202,7 @@ export function LaunchWindow() {
{/* Language switcher — top-left, beside traffic lights */}
+ +
{/* Left Column - Video & Timeline */} - - - - {/* Top section: video preview and controls */} - -
- {/* Video preview */} -
-
- 0} - shadowIntensity={shadowIntensity} - showBlur={showBlur} - motionBlurAmount={motionBlurAmount} - borderRadius={borderRadius} - padding={padding} - cropRegion={cropRegion} - trimRegions={trimRegions} - speedRegions={speedRegions} - annotationRegions={annotationRegions} - selectedAnnotationId={selectedAnnotationId} - onSelectAnnotation={handleSelectAnnotation} - onAnnotationPositionChange={handleAnnotationPositionChange} - onAnnotationSizeChange={handleAnnotationSizeChange} - /> -
-
- {/* Playback controls */} -
-
- -
+
+ + {/* Top section: video preview and controls */} + +
+ {/* Video preview */} +
+
+ 0} + shadowIntensity={shadowIntensity} + showBlur={showBlur} + motionBlurAmount={motionBlurAmount} + borderRadius={borderRadius} + padding={padding} + cropRegion={cropRegion} + trimRegions={trimRegions} + speedRegions={speedRegions} + annotationRegions={annotationRegions} + selectedAnnotationId={selectedAnnotationId} + onSelectAnnotation={handleSelectAnnotation} + onAnnotationPositionChange={handleAnnotationPositionChange} + onAnnotationSizeChange={handleAnnotationSizeChange} + />
- - - -
-
- - {/* Timeline section */} - -
- pushState({ aspectRatio: ar })} - /> + {/* Playback controls */} +
+
+ +
- - - - - -
-
- - {/* Right section: settings panel */} - - pushState({ wallpaper: w })} - selectedZoomDepth={ - selectedZoomId ? zoomRegions.find((z) => z.id === selectedZoomId)?.depth : null - } - onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)} - selectedZoomId={selectedZoomId} - onZoomDelete={handleZoomDelete} - selectedTrimId={selectedTrimId} - onTrimDelete={handleTrimDelete} - shadowIntensity={shadowIntensity} - onShadowChange={(v) => updateState({ shadowIntensity: v })} - onShadowCommit={commitState} - showBlur={showBlur} - onBlurChange={(v) => pushState({ showBlur: v })} - motionBlurAmount={motionBlurAmount} - onMotionBlurChange={(v) => updateState({ motionBlurAmount: v })} - onMotionBlurCommit={commitState} - borderRadius={borderRadius} - onBorderRadiusChange={(v) => updateState({ borderRadius: v })} - onBorderRadiusCommit={commitState} - padding={padding} - onPaddingChange={(v) => updateState({ padding: v })} - onPaddingCommit={commitState} - cropRegion={cropRegion} - onCropChange={(r) => pushState({ cropRegion: r })} - aspectRatio={aspectRatio} - hasWebcam={Boolean(webcamVideoPath)} - webcamLayoutPreset={webcamLayoutPreset} - onWebcamLayoutPresetChange={(preset) => pushState({ webcamLayoutPreset: preset })} - videoElement={videoPlaybackRef.current?.video || null} - exportQuality={exportQuality} - onExportQualityChange={setExportQuality} - exportFormat={exportFormat} - onExportFormatChange={setExportFormat} - gifFrameRate={gifFrameRate} - onGifFrameRateChange={setGifFrameRate} - gifLoop={gifLoop} - onGifLoopChange={setGifLoop} - gifSizePreset={gifSizePreset} - onGifSizePresetChange={setGifSizePreset} - gifOutputDimensions={calculateOutputDimensions( - videoPlaybackRef.current?.video?.videoWidth || 1920, - videoPlaybackRef.current?.video?.videoHeight || 1080, - gifSizePreset, - GIF_SIZE_PRESETS, - aspectRatio === "native" - ? getNativeAspectRatioValue( - videoPlaybackRef.current?.video?.videoWidth || 1920, - videoPlaybackRef.current?.video?.videoHeight || 1080, - cropRegion, - ) - : getAspectRatioValue(aspectRatio), - )} - onExport={handleOpenExportDialog} - selectedAnnotationId={selectedAnnotationId} - annotationRegions={annotationRegions} - onAnnotationContentChange={handleAnnotationContentChange} - onAnnotationTypeChange={handleAnnotationTypeChange} - onAnnotationStyleChange={handleAnnotationStyleChange} - onAnnotationFigureDataChange={handleAnnotationFigureDataChange} - onAnnotationDelete={handleAnnotationDelete} - onSaveProject={handleSaveProject} - onLoadProject={handleLoadProject} - selectedSpeedId={selectedSpeedId} - selectedSpeedValue={ - selectedSpeedId - ? (speedRegions.find((r) => r.id === selectedSpeedId)?.speed ?? null) - : null - } - onSpeedChange={handleSpeedChange} - onSpeedDelete={handleSpeedDelete} - unsavedExport={unsavedExport} - onSaveUnsavedExport={handleSaveUnsavedExport} - /> - - +
+
+ + +
+
+ + {/* Timeline section */} + +
+ pushState({ aspectRatio: ar })} + /> +
+
+ +
+ + {/* Right section: settings panel */} +
+ pushState({ wallpaper: w })} + selectedZoomDepth={ + selectedZoomId ? zoomRegions.find((z) => z.id === selectedZoomId)?.depth : null + } + onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)} + selectedZoomId={selectedZoomId} + onZoomDelete={handleZoomDelete} + selectedTrimId={selectedTrimId} + onTrimDelete={handleTrimDelete} + shadowIntensity={shadowIntensity} + onShadowChange={(v) => updateState({ shadowIntensity: v })} + onShadowCommit={commitState} + showBlur={showBlur} + onBlurChange={(v) => pushState({ showBlur: v })} + motionBlurAmount={motionBlurAmount} + onMotionBlurChange={(v) => updateState({ motionBlurAmount: v })} + onMotionBlurCommit={commitState} + borderRadius={borderRadius} + onBorderRadiusChange={(v) => updateState({ borderRadius: v })} + onBorderRadiusCommit={commitState} + padding={padding} + onPaddingChange={(v) => updateState({ padding: v })} + onPaddingCommit={commitState} + cropRegion={cropRegion} + onCropChange={(r) => pushState({ cropRegion: r })} + aspectRatio={aspectRatio} + hasWebcam={Boolean(webcamVideoPath)} + webcamLayoutPreset={webcamLayoutPreset} + onWebcamLayoutPresetChange={(preset) => pushState({ webcamLayoutPreset: preset })} + videoElement={videoPlaybackRef.current?.video || null} + exportQuality={exportQuality} + onExportQualityChange={setExportQuality} + exportFormat={exportFormat} + onExportFormatChange={setExportFormat} + gifFrameRate={gifFrameRate} + onGifFrameRateChange={setGifFrameRate} + gifLoop={gifLoop} + onGifLoopChange={setGifLoop} + gifSizePreset={gifSizePreset} + onGifSizePresetChange={setGifSizePreset} + gifOutputDimensions={calculateOutputDimensions( + videoPlaybackRef.current?.video?.videoWidth || 1920, + videoPlaybackRef.current?.video?.videoHeight || 1080, + gifSizePreset, + GIF_SIZE_PRESETS, + aspectRatio === "native" + ? getNativeAspectRatioValue( + videoPlaybackRef.current?.video?.videoWidth || 1920, + videoPlaybackRef.current?.video?.videoHeight || 1080, + cropRegion, + ) + : getAspectRatioValue(aspectRatio), + )} + onExport={handleOpenExportDialog} + selectedAnnotationId={selectedAnnotationId} + annotationRegions={annotationRegions} + onAnnotationContentChange={handleAnnotationContentChange} + onAnnotationTypeChange={handleAnnotationTypeChange} + onAnnotationStyleChange={handleAnnotationStyleChange} + onAnnotationFigureDataChange={handleAnnotationFigureDataChange} + onAnnotationDelete={handleAnnotationDelete} + selectedSpeedId={selectedSpeedId} + selectedSpeedValue={ + selectedSpeedId + ? (speedRegions.find((r) => r.id === selectedSpeedId)?.speed ?? null) + : null + } + onSpeedChange={handleSpeedChange} + onSpeedDelete={handleSpeedDelete} + unsavedExport={unsavedExport} + onSaveUnsavedExport={handleSaveUnsavedExport} + /> +
Date: Sat, 21 Mar 2026 22:04:10 -0700 Subject: [PATCH 2/3] movable camera pip --- src/components/video-editor/VideoEditor.tsx | 19 +++++- src/components/video-editor/VideoPlayback.tsx | 63 ++++++++++++++++++- .../video-editor/projectPersistence.ts | 13 ++++ src/components/video-editor/types.ts | 7 +++ .../video-editor/videoPlayback/layoutUtils.ts | 3 + src/hooks/useEditorHistory.ts | 9 ++- src/lib/compositeLayout.ts | 22 ++++++- src/lib/exporter/frameRenderer.ts | 2 + src/lib/exporter/gifExporter.ts | 2 + src/lib/exporter/videoExporter.ts | 2 + 10 files changed, 137 insertions(+), 5 deletions(-) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 2b526894..27968df2 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -80,6 +80,7 @@ export default function VideoEditor() { padding, aspectRatio, webcamLayoutPreset, + webcamPosition, } = editorState; // ── Non-undoable state @@ -187,6 +188,7 @@ export default function VideoEditor() { annotationRegions: normalizedEditor.annotationRegions, aspectRatio: normalizedEditor.aspectRatio, webcamLayoutPreset: normalizedEditor.webcamLayoutPreset, + webcamPosition: normalizedEditor.webcamPosition, }); setExportQuality(normalizedEditor.exportQuality); setExportFormat(normalizedEditor.exportFormat); @@ -255,6 +257,7 @@ export default function VideoEditor() { annotationRegions, aspectRatio, webcamLayoutPreset, + webcamPosition, exportQuality, exportFormat, gifFrameRate, @@ -277,6 +280,7 @@ export default function VideoEditor() { annotationRegions, aspectRatio, webcamLayoutPreset, + webcamPosition, exportQuality, exportFormat, gifFrameRate, @@ -369,6 +373,7 @@ export default function VideoEditor() { annotationRegions, aspectRatio, webcamLayoutPreset, + webcamPosition, exportQuality, exportFormat, gifFrameRate, @@ -422,6 +427,7 @@ export default function VideoEditor() { annotationRegions, aspectRatio, webcamLayoutPreset, + webcamPosition, exportQuality, exportFormat, gifFrameRate, @@ -1062,6 +1068,7 @@ export default function VideoEditor() { cropRegion, annotationRegions, webcamLayoutPreset, + webcamPosition, previewWidth, previewHeight, onProgress: (progress: ExportProgress) => { @@ -1192,6 +1199,7 @@ export default function VideoEditor() { cropRegion, annotationRegions, webcamLayoutPreset, + webcamPosition, previewWidth, previewHeight, onProgress: (progress: ExportProgress) => { @@ -1259,6 +1267,7 @@ export default function VideoEditor() { isPlaying, aspectRatio, webcamLayoutPreset, + webcamPosition, exportQuality, handleExportSaved, ], @@ -1435,6 +1444,9 @@ export default function VideoEditor() { videoPath={videoPath || ""} webcamVideoPath={webcamVideoPath || undefined} webcamLayoutPreset={webcamLayoutPreset} + webcamPosition={webcamPosition} + onWebcamPositionChange={(pos) => updateState({ webcamPosition: pos })} + onWebcamPositionDragEnd={commitState} onDurationChange={setDuration} onTimeUpdate={setCurrentTime} currentTime={currentTime} @@ -1556,7 +1568,12 @@ export default function VideoEditor() { aspectRatio={aspectRatio} hasWebcam={Boolean(webcamVideoPath)} webcamLayoutPreset={webcamLayoutPreset} - onWebcamLayoutPresetChange={(preset) => pushState({ webcamLayoutPreset: preset })} + onWebcamLayoutPresetChange={(preset) => + pushState({ + webcamLayoutPreset: preset, + webcamPosition: preset === "vertical-stack" ? null : webcamPosition, + }) + } 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 24cd1870..71030bb8 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -63,6 +63,9 @@ interface VideoPlaybackProps { videoPath: string; webcamVideoPath?: string; webcamLayoutPreset: WebcamLayoutPreset; + webcamPosition?: { cx: number; cy: number } | null; + onWebcamPositionChange?: (position: { cx: number; cy: number }) => void; + onWebcamPositionDragEnd?: () => void; onDurationChange: (duration: number) => void; onTimeUpdate: (time: number) => void; currentTime: number; @@ -108,6 +111,9 @@ const VideoPlayback = forwardRef( videoPath, webcamVideoPath, webcamLayoutPreset, + webcamPosition, + onWebcamPositionChange, + onWebcamPositionDragEnd, onDurationChange, onTimeUpdate, currentTime, @@ -167,6 +173,8 @@ const VideoPlayback = forwardRef( const blurFilterRef = useRef(null); const motionBlurFilterRef = useRef(null); const isDraggingFocusRef = useRef(false); + const isDraggingWebcamRef = useRef(false); + const webcamDragOffsetRef = useRef({ dx: 0, dy: 0 }); const stageSizeRef = useRef({ width: 0, height: 0 }); const videoSizeRef = useRef({ width: 0, height: 0 }); const baseScaleRef = useRef(1); @@ -263,6 +271,7 @@ const VideoPlayback = forwardRef( padding, webcamDimensions, webcamLayoutPreset, + webcamPosition, }); if (result) { @@ -292,6 +301,7 @@ const VideoPlayback = forwardRef( padding, webcamDimensions, webcamLayoutPreset, + webcamPosition, ]); useEffect(() => { @@ -401,6 +411,53 @@ const VideoPlayback = forwardRef( endFocusDrag(event); }; + // ── Webcam PiP drag handlers ── + + const handleWebcamPointerDown = (event: React.PointerEvent) => { + if (isPlayingRef.current) return; + if (webcamLayoutPreset !== "picture-in-picture") return; + event.preventDefault(); + event.stopPropagation(); + isDraggingWebcamRef.current = true; + event.currentTarget.setPointerCapture(event.pointerId); + + const webcamEl = event.currentTarget; + const webcamRect = webcamEl.getBoundingClientRect(); + webcamDragOffsetRef.current = { + dx: event.clientX - (webcamRect.left + webcamRect.width / 2), + dy: event.clientY - (webcamRect.top + webcamRect.height / 2), + }; + }; + + const handleWebcamPointerMove = (event: React.PointerEvent) => { + if (!isDraggingWebcamRef.current) return; + event.preventDefault(); + event.stopPropagation(); + + const containerEl = containerRef.current; + if (!containerEl || !onWebcamPositionChange) return; + + const containerRect = containerEl.getBoundingClientRect(); + const cx = clamp01( + (event.clientX - webcamDragOffsetRef.current.dx - containerRect.left) / containerRect.width, + ); + const cy = clamp01( + (event.clientY - webcamDragOffsetRef.current.dy - containerRect.top) / containerRect.height, + ); + onWebcamPositionChange({ cx, cy }); + }; + + const handleWebcamPointerUp = (event: React.PointerEvent) => { + if (!isDraggingWebcamRef.current) return; + isDraggingWebcamRef.current = false; + try { + event.currentTarget.releasePointerCapture(event.pointerId); + } catch { + // Pointer may already be released. + } + onWebcamPositionDragEnd?.(); + }; + useEffect(() => { zoomRegionsRef.current = zoomRegions; }, [zoomRegions]); @@ -1101,7 +1158,7 @@ const VideoPlayback = forwardRef(
-
+
{t("effects.padding")}
- {padding}% + + {webcamLayoutPreset === "vertical-stack" ? "—" : `${padding}%`} +
onPaddingChange?.(values[0])} onValueCommit={() => onPaddingCommit?.()} min={0} max={100} step={1} + disabled={webcamLayoutPreset === "vertical-stack"} className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" />
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 27968df2..bb12e30e 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -22,7 +22,11 @@ import { } from "@/lib/exporter"; import type { ProjectMedia } from "@/lib/recordingSession"; import { matchesShortcut } from "@/lib/shortcuts"; -import { getAspectRatioValue, getNativeAspectRatioValue } from "@/utils/aspectRatioUtils"; +import { + getAspectRatioValue, + getNativeAspectRatioValue, + isPortraitAspectRatio, +} from "@/utils/aspectRatioUtils"; import { ExportDialog } from "./ExportDialog"; import PlaybackControls from "./PlaybackControls"; import { @@ -1529,7 +1533,15 @@ export default function VideoEditor() { selectedAnnotationId={selectedAnnotationId} onSelectAnnotation={handleSelectAnnotation} aspectRatio={aspectRatio} - onAspectRatioChange={(ar) => pushState({ aspectRatio: ar })} + onAspectRatioChange={(ar) => + pushState({ + aspectRatio: ar, + webcamLayoutPreset: + !isPortraitAspectRatio(ar) && webcamLayoutPreset === "vertical-stack" + ? "picture-in-picture" + : webcamLayoutPreset, + }) + } />
diff --git a/src/components/video-editor/videoPlayback/layoutUtils.ts b/src/components/video-editor/videoPlayback/layoutUtils.ts index b6e438b7..13d46318 100644 --- a/src/components/video-editor/videoPlayback/layoutUtils.ts +++ b/src/components/video-editor/videoPlayback/layoutUtils.ts @@ -81,7 +81,9 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null { // Calculate scale to fit the cropped area in the viewport // Padding is a percentage (0-100), where 50 matches the original VIEWPORT_SCALE of 0.8 - const paddingScale = 1.0 - (padding / 100) * 0.4; + // Vertical stack ignores padding — it's full-bleed + const effectivePadding = webcamLayoutPreset === "vertical-stack" ? 0 : padding; + const paddingScale = 1.0 - (effectivePadding / 100) * 0.4; const maxDisplayWidth = width * paddingScale; const maxDisplayHeight = height * paddingScale; @@ -98,7 +100,15 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null { return null; } - const scale = compositeLayout.screenRect.width / croppedVideoWidth; + const screenRect = compositeLayout.screenRect; + + // Cover mode: scale to fill the rect (may crop), otherwise fit-to-width + let scale: number; + if (compositeLayout.screenCover) { + scale = Math.max(screenRect.width / croppedVideoWidth, screenRect.height / croppedVideoHeight); + } else { + scale = screenRect.width / croppedVideoWidth; + } videoSprite.scale.set(scale); @@ -106,25 +116,24 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null { const fullVideoDisplayWidth = videoWidth * scale; const fullVideoDisplayHeight = videoHeight * scale; - // Calculate display size of just the cropped region - // Position the full video sprite so that when we apply the mask, - // the cropped region appears centered - // The crop starts at (crop.x * videoWidth, crop.y * videoHeight) in video coordinates - // In display coordinates, that's (crop.x * fullVideoDisplayWidth, crop.y * fullVideoDisplayHeight) - // We want that point to be at screenRect.x, screenRect.y - const spriteX = compositeLayout.screenRect.x - crop.x * fullVideoDisplayWidth; - const spriteY = compositeLayout.screenRect.y - crop.y * fullVideoDisplayHeight; + // Position the video so the cropped region is centered within the screenRect + const croppedDisplayWidth = croppedVideoWidth * scale; + const croppedDisplayHeight = croppedVideoHeight * scale; + const offsetX = screenRect.x + (screenRect.width - croppedDisplayWidth) / 2; + const offsetY = screenRect.y + (screenRect.height - croppedDisplayHeight) / 2; + const spriteX = offsetX - crop.x * fullVideoDisplayWidth; + const spriteY = offsetY - crop.y * fullVideoDisplayHeight; videoSprite.position.set(spriteX, spriteY); - // Apply border radius + // Apply border radius — mask clips the video to the screenRect maskGraphics.clear(); maskGraphics.roundRect( - compositeLayout.screenRect.x, - compositeLayout.screenRect.y, - compositeLayout.screenRect.width, - compositeLayout.screenRect.height, - borderRadius, + screenRect.x, + screenRect.y, + screenRect.width, + screenRect.height, + compositeLayout.screenCover ? 0 : borderRadius, ); maskGraphics.fill({ color: 0xffffff }); diff --git a/src/lib/compositeLayout.ts b/src/lib/compositeLayout.ts index 8ff75a5a..5feca50e 100644 --- a/src/lib/compositeLayout.ts +++ b/src/lib/compositeLayout.ts @@ -52,11 +52,12 @@ export interface WebcamLayoutPresetDefinition { export interface WebcamCompositeLayout { screenRect: RenderRect; webcamRect: StyledRenderRect | null; + /** When true, the video should be scaled to cover screenRect (cropping overflow). */ + screenCover?: boolean; } const MAX_STAGE_FRACTION = 0.18; const MARGIN_FRACTION = 0.02; -const MIN_SIZE = 96; const MAX_BORDER_RADIUS = 24; const WEBCAM_LAYOUT_PRESET_MAP: Record = { "picture-in-picture": { @@ -65,8 +66,8 @@ const WEBCAM_LAYOUT_PRESET_MAP: Record 0 ? scale : 1; - const resolvedScreenHeight = Math.round(screenHeight * clampedScale); - const resolvedScreenWidth = Math.round(screenWidth * clampedScale); - const resolvedWebcamHeight = Math.round(normalizedWebcamHeight * clampedScale); - const resolvedGap = Math.round(gap * clampedScale); - const totalHeight = resolvedScreenHeight + resolvedGap + resolvedWebcamHeight; - const top = Math.max(0, Math.floor((canvasHeight - totalHeight) / 2)); - const left = Math.max(0, Math.floor((canvasWidth - resolvedScreenWidth) / 2)); - const screenRect = { - x: left, - y: top, - width: resolvedScreenWidth, - height: resolvedScreenHeight, - }; + // Webcam: full width at the bottom, maintaining its aspect ratio + const webcamAspect = webcamWidth / webcamHeight; + const resolvedWebcamWidth = canvasWidth; + const resolvedWebcamHeight = Math.round(canvasWidth / webcamAspect); + + // Screen: fills remaining space at the top (cover mode — may crop sides) + const screenRectHeight = canvasHeight - resolvedWebcamHeight; return { - screenRect, + screenRect: { + x: 0, + y: 0, + width: canvasWidth, + height: Math.max(0, screenRectHeight), + }, webcamRect: { - x: left, - y: top + resolvedScreenHeight + resolvedGap, - width: resolvedScreenWidth, + x: 0, + y: Math.max(0, screenRectHeight), + width: resolvedWebcamWidth, height: resolvedWebcamHeight, - borderRadius: Math.min( - preset.borderRadius.max, - Math.max( - preset.borderRadius.min, - Math.round( - Math.min(resolvedScreenWidth, resolvedWebcamHeight) * preset.borderRadius.fraction, - ), - ), - ), + borderRadius: 0, }, + screenCover: true, }; } diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index 21628d6c..4a9b2bdc 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -429,7 +429,9 @@ export class FrameRenderer { // Calculate scale to fit in viewport // Padding is a percentage (0-100), where 50% ~ 0.8 scale - const paddingScale = 1.0 - (padding / 100) * 0.4; + // Vertical stack ignores padding — it's full-bleed + const effectivePadding = this.config.webcamLayoutPreset === "vertical-stack" ? 0 : padding; + const paddingScale = 1.0 - (effectivePadding / 100) * 0.4; const viewportWidth = width * paddingScale; const viewportHeight = height * paddingScale; const compositeLayout = computeCompositeLayout({ @@ -442,37 +444,46 @@ export class FrameRenderer { }); if (!compositeLayout) return; - const scale = compositeLayout.screenRect.width / croppedVideoWidth; + const screenRect = compositeLayout.screenRect; + + // Cover mode: scale to fill the rect (may crop), otherwise fit-to-width + let scale: number; + if (compositeLayout.screenCover) { + scale = Math.max( + screenRect.width / croppedVideoWidth, + screenRect.height / croppedVideoHeight, + ); + } else { + scale = screenRect.width / croppedVideoWidth; + } // Position video sprite this.videoSprite.width = videoWidth * scale; this.videoSprite.height = videoHeight * scale; + // Center the cropped region within the screenRect + const croppedDisplayWidth = croppedVideoWidth * scale; + const croppedDisplayHeight = croppedVideoHeight * scale; + const coverOffsetX = (screenRect.width - croppedDisplayWidth) / 2; + const coverOffsetY = (screenRect.height - croppedDisplayHeight) / 2; + const cropPixelX = cropStartX * videoWidth * scale; const cropPixelY = cropStartY * videoHeight * scale; - this.videoSprite.x = -cropPixelX; - this.videoSprite.y = -cropPixelY; + this.videoSprite.x = -cropPixelX + coverOffsetX; + this.videoSprite.y = -cropPixelY + coverOffsetY; // Position video container - const croppedDisplayWidth = compositeLayout.screenRect.width; - const croppedDisplayHeight = compositeLayout.screenRect.height; - this.videoContainer.x = compositeLayout.screenRect.x; - this.videoContainer.y = compositeLayout.screenRect.y; + this.videoContainer.x = screenRect.x; + this.videoContainer.y = screenRect.y; // scale border radius by export/preview canvas ratio const previewWidth = this.config.previewWidth || 1920; const previewHeight = this.config.previewHeight || 1080; const canvasScaleFactor = Math.min(width / previewWidth, height / previewHeight); - const scaledBorderRadius = borderRadius * canvasScaleFactor; + const scaledBorderRadius = compositeLayout.screenCover ? 0 : borderRadius * canvasScaleFactor; this.maskGraphics.clear(); - this.maskGraphics.roundRect( - 0, - 0, - croppedDisplayWidth, - croppedDisplayHeight, - scaledBorderRadius, - ); + this.maskGraphics.roundRect(0, 0, screenRect.width, screenRect.height, scaledBorderRadius); this.maskGraphics.fill({ color: 0xffffff }); // Cache layout info diff --git a/src/utils/aspectRatioUtils.ts b/src/utils/aspectRatioUtils.ts index 2ad7e442..887b543f 100644 --- a/src/utils/aspectRatioUtils.ts +++ b/src/utils/aspectRatioUtils.ts @@ -67,6 +67,10 @@ export function getAspectRatioLabel(aspectRatio: AspectRatio): string { return aspectRatio; } +export function isPortraitAspectRatio(aspectRatio: AspectRatio): boolean { + return getAspectRatioValue(aspectRatio) < 1; +} + export function formatAspectRatioForCSS(aspectRatio: AspectRatio, nativeRatio?: number): string { if (aspectRatio === "native") return String(nativeRatio ?? 16 / 9); return aspectRatio.replace(":", "/");