From a8bb0e88d56a9170b6dbe1238e64febc16ec4bd2 Mon Sep 17 00:00:00 2001 From: Siddharth Date: Sat, 21 Mar 2026 23:15:46 -0700 Subject: [PATCH] improved vertical split gated behind 9:16 --- src/components/video-editor/SettingsPanel.tsx | 19 +++-- src/components/video-editor/VideoEditor.tsx | 16 ++++- .../video-editor/videoPlayback/layoutUtils.ts | 41 ++++++----- src/lib/compositeLayout.ts | 69 ++++++++----------- src/lib/exporter/frameRenderer.ts | 43 +++++++----- src/utils/aspectRatioUtils.ts | 4 ++ 6 files changed, 111 insertions(+), 81 deletions(-) diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 422e946..f5afe35 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -39,7 +39,7 @@ import { WEBCAM_LAYOUT_PRESETS } from "@/lib/compositeLayout"; import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter"; import { GIF_FRAME_RATES, GIF_SIZE_PRESETS } from "@/lib/exporter"; import { cn } from "@/lib/utils"; -import { type AspectRatio } from "@/utils/aspectRatioUtils"; +import { type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils"; import { getTestId } from "@/utils/getTestId"; import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel"; import { CropControl } from "./CropControl"; @@ -609,7 +609,11 @@ export function SettingsPanel({ - {WEBCAM_LAYOUT_PRESETS.map((preset) => ( + {WEBCAM_LAYOUT_PRESETS.filter( + (preset) => + preset.value === "picture-in-picture" || + isPortraitAspectRatio(aspectRatio), + ).map((preset) => ( {preset.value === "picture-in-picture" ? t("layout.pictureInPicture") @@ -700,20 +704,25 @@ export function SettingsPanel({ className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" /> -
+
{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 27968df..bb12e30 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 b6e438b..13d4631 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 8ff75a5..5feca50 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, - webcamRect: { - x: left, - y: top + resolvedScreenHeight + resolvedGap, - width: resolvedScreenWidth, - height: resolvedWebcamHeight, - borderRadius: Math.min( - preset.borderRadius.max, - Math.max( - preset.borderRadius.min, - Math.round( - Math.min(resolvedScreenWidth, resolvedWebcamHeight) * preset.borderRadius.fraction, - ), - ), - ), + screenRect: { + x: 0, + y: 0, + width: canvasWidth, + height: Math.max(0, screenRectHeight), }, + webcamRect: { + x: 0, + y: Math.max(0, screenRectHeight), + width: resolvedWebcamWidth, + height: resolvedWebcamHeight, + borderRadius: 0, + }, + screenCover: true, }; } diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index 21628d6..4a9b2bd 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 2ad7e44..887b543 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(":", "/");