improved vertical split gated behind 9:16

This commit is contained in:
Siddharth
2026-03-21 23:15:46 -07:00
parent cbbe2d7fbf
commit a8bb0e88d5
6 changed files with 111 additions and 81 deletions
+14 -5
View File
@@ -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({
<SelectValue placeholder={t("layout.selectPreset")} />
</SelectTrigger>
<SelectContent>
{WEBCAM_LAYOUT_PRESETS.map((preset) => (
{WEBCAM_LAYOUT_PRESETS.filter(
(preset) =>
preset.value === "picture-in-picture" ||
isPortraitAspectRatio(aspectRatio),
).map((preset) => (
<SelectItem key={preset.value} value={preset.value} className="text-xs">
{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"
/>
</div>
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
<div
className={`p-2 rounded-lg bg-white/5 border border-white/5 ${webcamLayoutPreset === "vertical-stack" ? "opacity-40 pointer-events-none" : ""}`}
>
<div className="flex items-center justify-between mb-1">
<div className="text-[10px] font-medium text-slate-300">
{t("effects.padding")}
</div>
<span className="text-[10px] text-slate-500 font-mono">{padding}%</span>
<span className="text-[10px] text-slate-500 font-mono">
{webcamLayoutPreset === "vertical-stack" ? "—" : `${padding}%`}
</span>
</div>
<Slider
value={[padding]}
value={[webcamLayoutPreset === "vertical-stack" ? 0 : padding]}
onValueChange={(values) => 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"
/>
</div>
+14 -2
View File
@@ -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,
})
}
/>
</div>
</Panel>
@@ -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 });
+27 -42
View File
@@ -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<WebcamLayoutPreset, WebcamLayoutPresetDefinition> = {
"picture-in-picture": {
@@ -65,8 +66,8 @@ const WEBCAM_LAYOUT_PRESET_MAP: Record<WebcamLayoutPreset, WebcamLayoutPresetDef
type: "overlay",
maxStageFraction: MAX_STAGE_FRACTION,
marginFraction: MARGIN_FRACTION,
minMargin: 12,
minSize: MIN_SIZE,
minMargin: 0,
minSize: 0,
},
borderRadius: {
max: MAX_BORDER_RADIUS,
@@ -134,7 +135,6 @@ export function computeCompositeLayout(params: {
webcamPosition,
} = params;
const { width: canvasWidth, height: canvasHeight } = canvasSize;
const { width: maxContentWidth, height: maxContentHeight } = maxContentSize;
const { width: screenWidth, height: screenHeight } = screenSize;
const webcamWidth = webcamSize?.width;
const webcamHeight = webcamSize?.height;
@@ -146,52 +146,37 @@ export function computeCompositeLayout(params: {
if (preset.transform.type === "stack") {
if (!webcamWidth || !webcamHeight || webcamWidth <= 0 || webcamHeight <= 0) {
// No webcam — screen fills the entire canvas (cover mode)
return {
screenRect: centerRect({
canvasSize,
size: screenSize,
maxSize: maxContentSize,
}),
screenRect: { x: 0, y: 0, width: canvasWidth, height: canvasHeight },
webcamRect: null,
screenCover: true,
};
}
const gap = preset.transform.gap;
const normalizedWebcamHeight = webcamHeight * (screenWidth / webcamWidth);
const combinedHeight = screenHeight + gap + normalizedWebcamHeight;
const scale = Math.min(maxContentWidth / screenWidth, maxContentHeight / combinedHeight, 1);
const clampedScale = Number.isFinite(scale) && scale > 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,
};
}
+27 -16
View File
@@ -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
+4
View File
@@ -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(":", "/");