Skip to content

Commit a8bb0e8

Browse files
improved vertical split gated behind 9:16
1 parent cbbe2d7 commit a8bb0e8

6 files changed

Lines changed: 109 additions & 79 deletions

File tree

src/components/video-editor/SettingsPanel.tsx

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ import { WEBCAM_LAYOUT_PRESETS } from "@/lib/compositeLayout";
3939
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
4040
import { GIF_FRAME_RATES, GIF_SIZE_PRESETS } from "@/lib/exporter";
4141
import { cn } from "@/lib/utils";
42-
import { type AspectRatio } from "@/utils/aspectRatioUtils";
42+
import { type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils";
4343
import { getTestId } from "@/utils/getTestId";
4444
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
4545
import { CropControl } from "./CropControl";
@@ -609,7 +609,11 @@ export function SettingsPanel({
609609
<SelectValue placeholder={t("layout.selectPreset")} />
610610
</SelectTrigger>
611611
<SelectContent>
612-
{WEBCAM_LAYOUT_PRESETS.map((preset) => (
612+
{WEBCAM_LAYOUT_PRESETS.filter(
613+
(preset) =>
614+
preset.value === "picture-in-picture" ||
615+
isPortraitAspectRatio(aspectRatio),
616+
).map((preset) => (
613617
<SelectItem key={preset.value} value={preset.value} className="text-xs">
614618
{preset.value === "picture-in-picture"
615619
? t("layout.pictureInPicture")
@@ -700,20 +704,25 @@ export function SettingsPanel({
700704
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
701705
/>
702706
</div>
703-
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
707+
<div
708+
className={`p-2 rounded-lg bg-white/5 border border-white/5 ${webcamLayoutPreset === "vertical-stack" ? "opacity-40 pointer-events-none" : ""}`}
709+
>
704710
<div className="flex items-center justify-between mb-1">
705711
<div className="text-[10px] font-medium text-slate-300">
706712
{t("effects.padding")}
707713
</div>
708-
<span className="text-[10px] text-slate-500 font-mono">{padding}%</span>
714+
<span className="text-[10px] text-slate-500 font-mono">
715+
{webcamLayoutPreset === "vertical-stack" ? "—" : `${padding}%`}
716+
</span>
709717
</div>
710718
<Slider
711-
value={[padding]}
719+
value={[webcamLayoutPreset === "vertical-stack" ? 0 : padding]}
712720
onValueChange={(values) => onPaddingChange?.(values[0])}
713721
onValueCommit={() => onPaddingCommit?.()}
714722
min={0}
715723
max={100}
716724
step={1}
725+
disabled={webcamLayoutPreset === "vertical-stack"}
717726
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
718727
/>
719728
</div>

src/components/video-editor/VideoEditor.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@ import {
2222
} from "@/lib/exporter";
2323
import type { ProjectMedia } from "@/lib/recordingSession";
2424
import { matchesShortcut } from "@/lib/shortcuts";
25-
import { getAspectRatioValue, getNativeAspectRatioValue } from "@/utils/aspectRatioUtils";
25+
import {
26+
getAspectRatioValue,
27+
getNativeAspectRatioValue,
28+
isPortraitAspectRatio,
29+
} from "@/utils/aspectRatioUtils";
2630
import { ExportDialog } from "./ExportDialog";
2731
import PlaybackControls from "./PlaybackControls";
2832
import {
@@ -1529,7 +1533,15 @@ export default function VideoEditor() {
15291533
selectedAnnotationId={selectedAnnotationId}
15301534
onSelectAnnotation={handleSelectAnnotation}
15311535
aspectRatio={aspectRatio}
1532-
onAspectRatioChange={(ar) => pushState({ aspectRatio: ar })}
1536+
onAspectRatioChange={(ar) =>
1537+
pushState({
1538+
aspectRatio: ar,
1539+
webcamLayoutPreset:
1540+
!isPortraitAspectRatio(ar) && webcamLayoutPreset === "vertical-stack"
1541+
? "picture-in-picture"
1542+
: webcamLayoutPreset,
1543+
})
1544+
}
15331545
/>
15341546
</div>
15351547
</Panel>

src/components/video-editor/videoPlayback/layoutUtils.ts

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,9 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
8181

8282
// Calculate scale to fit the cropped area in the viewport
8383
// Padding is a percentage (0-100), where 50 matches the original VIEWPORT_SCALE of 0.8
84-
const paddingScale = 1.0 - (padding / 100) * 0.4;
84+
// Vertical stack ignores padding — it's full-bleed
85+
const effectivePadding = webcamLayoutPreset === "vertical-stack" ? 0 : padding;
86+
const paddingScale = 1.0 - (effectivePadding / 100) * 0.4;
8587
const maxDisplayWidth = width * paddingScale;
8688
const maxDisplayHeight = height * paddingScale;
8789

@@ -98,33 +100,40 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
98100
return null;
99101
}
100102

101-
const scale = compositeLayout.screenRect.width / croppedVideoWidth;
103+
const screenRect = compositeLayout.screenRect;
104+
105+
// Cover mode: scale to fill the rect (may crop), otherwise fit-to-width
106+
let scale: number;
107+
if (compositeLayout.screenCover) {
108+
scale = Math.max(screenRect.width / croppedVideoWidth, screenRect.height / croppedVideoHeight);
109+
} else {
110+
scale = screenRect.width / croppedVideoWidth;
111+
}
102112

103113
videoSprite.scale.set(scale);
104114

105115
// Calculate display size of the full video at this scale
106116
const fullVideoDisplayWidth = videoWidth * scale;
107117
const fullVideoDisplayHeight = videoHeight * scale;
108118

109-
// Calculate display size of just the cropped region
110-
// Position the full video sprite so that when we apply the mask,
111-
// the cropped region appears centered
112-
// The crop starts at (crop.x * videoWidth, crop.y * videoHeight) in video coordinates
113-
// In display coordinates, that's (crop.x * fullVideoDisplayWidth, crop.y * fullVideoDisplayHeight)
114-
// We want that point to be at screenRect.x, screenRect.y
115-
const spriteX = compositeLayout.screenRect.x - crop.x * fullVideoDisplayWidth;
116-
const spriteY = compositeLayout.screenRect.y - crop.y * fullVideoDisplayHeight;
119+
// Position the video so the cropped region is centered within the screenRect
120+
const croppedDisplayWidth = croppedVideoWidth * scale;
121+
const croppedDisplayHeight = croppedVideoHeight * scale;
122+
const offsetX = screenRect.x + (screenRect.width - croppedDisplayWidth) / 2;
123+
const offsetY = screenRect.y + (screenRect.height - croppedDisplayHeight) / 2;
124+
const spriteX = offsetX - crop.x * fullVideoDisplayWidth;
125+
const spriteY = offsetY - crop.y * fullVideoDisplayHeight;
117126

118127
videoSprite.position.set(spriteX, spriteY);
119128

120-
// Apply border radius
129+
// Apply border radius — mask clips the video to the screenRect
121130
maskGraphics.clear();
122131
maskGraphics.roundRect(
123-
compositeLayout.screenRect.x,
124-
compositeLayout.screenRect.y,
125-
compositeLayout.screenRect.width,
126-
compositeLayout.screenRect.height,
127-
borderRadius,
132+
screenRect.x,
133+
screenRect.y,
134+
screenRect.width,
135+
screenRect.height,
136+
compositeLayout.screenCover ? 0 : borderRadius,
128137
);
129138
maskGraphics.fill({ color: 0xffffff });
130139

src/lib/compositeLayout.ts

Lines changed: 25 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,12 @@ export interface WebcamLayoutPresetDefinition {
5252
export interface WebcamCompositeLayout {
5353
screenRect: RenderRect;
5454
webcamRect: StyledRenderRect | null;
55+
/** When true, the video should be scaled to cover screenRect (cropping overflow). */
56+
screenCover?: boolean;
5557
}
5658

5759
const MAX_STAGE_FRACTION = 0.18;
5860
const MARGIN_FRACTION = 0.02;
59-
const MIN_SIZE = 96;
6061
const MAX_BORDER_RADIUS = 24;
6162
const WEBCAM_LAYOUT_PRESET_MAP: Record<WebcamLayoutPreset, WebcamLayoutPresetDefinition> = {
6263
"picture-in-picture": {
@@ -65,8 +66,8 @@ const WEBCAM_LAYOUT_PRESET_MAP: Record<WebcamLayoutPreset, WebcamLayoutPresetDef
6566
type: "overlay",
6667
maxStageFraction: MAX_STAGE_FRACTION,
6768
marginFraction: MARGIN_FRACTION,
68-
minMargin: 12,
69-
minSize: MIN_SIZE,
69+
minMargin: 0,
70+
minSize: 0,
7071
},
7172
borderRadius: {
7273
max: MAX_BORDER_RADIUS,
@@ -134,7 +135,6 @@ export function computeCompositeLayout(params: {
134135
webcamPosition,
135136
} = params;
136137
const { width: canvasWidth, height: canvasHeight } = canvasSize;
137-
const { width: maxContentWidth, height: maxContentHeight } = maxContentSize;
138138
const { width: screenWidth, height: screenHeight } = screenSize;
139139
const webcamWidth = webcamSize?.width;
140140
const webcamHeight = webcamSize?.height;
@@ -146,52 +146,37 @@ export function computeCompositeLayout(params: {
146146

147147
if (preset.transform.type === "stack") {
148148
if (!webcamWidth || !webcamHeight || webcamWidth <= 0 || webcamHeight <= 0) {
149+
// No webcam — screen fills the entire canvas (cover mode)
149150
return {
150-
screenRect: centerRect({
151-
canvasSize,
152-
size: screenSize,
153-
maxSize: maxContentSize,
154-
}),
151+
screenRect: { x: 0, y: 0, width: canvasWidth, height: canvasHeight },
155152
webcamRect: null,
153+
screenCover: true,
156154
};
157155
}
158156

159-
const gap = preset.transform.gap;
160-
const normalizedWebcamHeight = webcamHeight * (screenWidth / webcamWidth);
161-
const combinedHeight = screenHeight + gap + normalizedWebcamHeight;
162-
const scale = Math.min(maxContentWidth / screenWidth, maxContentHeight / combinedHeight, 1);
163-
const clampedScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
164-
const resolvedScreenHeight = Math.round(screenHeight * clampedScale);
165-
const resolvedScreenWidth = Math.round(screenWidth * clampedScale);
166-
const resolvedWebcamHeight = Math.round(normalizedWebcamHeight * clampedScale);
167-
const resolvedGap = Math.round(gap * clampedScale);
168-
const totalHeight = resolvedScreenHeight + resolvedGap + resolvedWebcamHeight;
169-
const top = Math.max(0, Math.floor((canvasHeight - totalHeight) / 2));
170-
const left = Math.max(0, Math.floor((canvasWidth - resolvedScreenWidth) / 2));
171-
const screenRect = {
172-
x: left,
173-
y: top,
174-
width: resolvedScreenWidth,
175-
height: resolvedScreenHeight,
176-
};
157+
// Webcam: full width at the bottom, maintaining its aspect ratio
158+
const webcamAspect = webcamWidth / webcamHeight;
159+
const resolvedWebcamWidth = canvasWidth;
160+
const resolvedWebcamHeight = Math.round(canvasWidth / webcamAspect);
161+
162+
// Screen: fills remaining space at the top (cover mode — may crop sides)
163+
const screenRectHeight = canvasHeight - resolvedWebcamHeight;
177164

178165
return {
179-
screenRect,
166+
screenRect: {
167+
x: 0,
168+
y: 0,
169+
width: canvasWidth,
170+
height: Math.max(0, screenRectHeight),
171+
},
180172
webcamRect: {
181-
x: left,
182-
y: top + resolvedScreenHeight + resolvedGap,
183-
width: resolvedScreenWidth,
173+
x: 0,
174+
y: Math.max(0, screenRectHeight),
175+
width: resolvedWebcamWidth,
184176
height: resolvedWebcamHeight,
185-
borderRadius: Math.min(
186-
preset.borderRadius.max,
187-
Math.max(
188-
preset.borderRadius.min,
189-
Math.round(
190-
Math.min(resolvedScreenWidth, resolvedWebcamHeight) * preset.borderRadius.fraction,
191-
),
192-
),
193-
),
177+
borderRadius: 0,
194178
},
179+
screenCover: true,
195180
};
196181
}
197182

src/lib/exporter/frameRenderer.ts

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -429,7 +429,9 @@ export class FrameRenderer {
429429

430430
// Calculate scale to fit in viewport
431431
// Padding is a percentage (0-100), where 50% ~ 0.8 scale
432-
const paddingScale = 1.0 - (padding / 100) * 0.4;
432+
// Vertical stack ignores padding — it's full-bleed
433+
const effectivePadding = this.config.webcamLayoutPreset === "vertical-stack" ? 0 : padding;
434+
const paddingScale = 1.0 - (effectivePadding / 100) * 0.4;
433435
const viewportWidth = width * paddingScale;
434436
const viewportHeight = height * paddingScale;
435437
const compositeLayout = computeCompositeLayout({
@@ -442,37 +444,46 @@ export class FrameRenderer {
442444
});
443445
if (!compositeLayout) return;
444446

445-
const scale = compositeLayout.screenRect.width / croppedVideoWidth;
447+
const screenRect = compositeLayout.screenRect;
448+
449+
// Cover mode: scale to fill the rect (may crop), otherwise fit-to-width
450+
let scale: number;
451+
if (compositeLayout.screenCover) {
452+
scale = Math.max(
453+
screenRect.width / croppedVideoWidth,
454+
screenRect.height / croppedVideoHeight,
455+
);
456+
} else {
457+
scale = screenRect.width / croppedVideoWidth;
458+
}
446459

447460
// Position video sprite
448461
this.videoSprite.width = videoWidth * scale;
449462
this.videoSprite.height = videoHeight * scale;
450463

464+
// Center the cropped region within the screenRect
465+
const croppedDisplayWidth = croppedVideoWidth * scale;
466+
const croppedDisplayHeight = croppedVideoHeight * scale;
467+
const coverOffsetX = (screenRect.width - croppedDisplayWidth) / 2;
468+
const coverOffsetY = (screenRect.height - croppedDisplayHeight) / 2;
469+
451470
const cropPixelX = cropStartX * videoWidth * scale;
452471
const cropPixelY = cropStartY * videoHeight * scale;
453-
this.videoSprite.x = -cropPixelX;
454-
this.videoSprite.y = -cropPixelY;
472+
this.videoSprite.x = -cropPixelX + coverOffsetX;
473+
this.videoSprite.y = -cropPixelY + coverOffsetY;
455474

456475
// Position video container
457-
const croppedDisplayWidth = compositeLayout.screenRect.width;
458-
const croppedDisplayHeight = compositeLayout.screenRect.height;
459-
this.videoContainer.x = compositeLayout.screenRect.x;
460-
this.videoContainer.y = compositeLayout.screenRect.y;
476+
this.videoContainer.x = screenRect.x;
477+
this.videoContainer.y = screenRect.y;
461478

462479
// scale border radius by export/preview canvas ratio
463480
const previewWidth = this.config.previewWidth || 1920;
464481
const previewHeight = this.config.previewHeight || 1080;
465482
const canvasScaleFactor = Math.min(width / previewWidth, height / previewHeight);
466-
const scaledBorderRadius = borderRadius * canvasScaleFactor;
483+
const scaledBorderRadius = compositeLayout.screenCover ? 0 : borderRadius * canvasScaleFactor;
467484

468485
this.maskGraphics.clear();
469-
this.maskGraphics.roundRect(
470-
0,
471-
0,
472-
croppedDisplayWidth,
473-
croppedDisplayHeight,
474-
scaledBorderRadius,
475-
);
486+
this.maskGraphics.roundRect(0, 0, screenRect.width, screenRect.height, scaledBorderRadius);
476487
this.maskGraphics.fill({ color: 0xffffff });
477488

478489
// Cache layout info

src/utils/aspectRatioUtils.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@ export function getAspectRatioLabel(aspectRatio: AspectRatio): string {
6767
return aspectRatio;
6868
}
6969

70+
export function isPortraitAspectRatio(aspectRatio: AspectRatio): boolean {
71+
return getAspectRatioValue(aspectRatio) < 1;
72+
}
73+
7074
export function formatAspectRatioForCSS(aspectRatio: AspectRatio, nativeRatio?: number): string {
7175
if (aspectRatio === "native") return String(nativeRatio ?? 16 / 9);
7276
return aspectRatio.replace(":", "/");

0 commit comments

Comments
 (0)