Merge commit 'refs/tmp/pr-independent-padding-controls'

This commit is contained in:
webadderall
2026-04-23 19:44:06 +10:00
12 changed files with 482 additions and 229 deletions
+151 -22
View File
@@ -1,4 +1,4 @@
import { Palette, Trash as Trash2, UploadSimple as Upload, X } from "@phosphor-icons/react";
import { Link, LinkBreak, Palette, Trash as Trash2, UploadSimple as Upload, X } from "@phosphor-icons/react";
import { AnimatePresence, LayoutGroup, motion } from "motion/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
@@ -43,6 +43,7 @@ import type {
CursorStyle,
EditorEffectSection,
FigureData,
Padding,
PlaybackSpeed,
WebcamOverlaySettings,
WebcamPositionPreset,
@@ -50,6 +51,9 @@ import type {
ZoomMode,
ZoomTransitionEasing,
} from "./types";
import {
isZeroPadding,
} from "./videoPlayback/layoutUtils";
import {
DEFAULT_AUTO_CAPTION_SETTINGS,
DEFAULT_CROP_REGION,
@@ -60,6 +64,7 @@ import {
DEFAULT_CURSOR_SMOOTHING,
DEFAULT_CURSOR_STYLE,
DEFAULT_CURSOR_SWAY,
DEFAULT_PADDING,
DEFAULT_WEBCAM_CORNER_RADIUS,
DEFAULT_WEBCAM_MARGIN,
DEFAULT_WEBCAM_POSITION_PRESET,
@@ -394,8 +399,8 @@ interface SettingsPanelProps {
onWebcamChange?: (webcam: WebcamOverlaySettings) => void;
onUploadWebcam?: () => void;
onClearWebcam?: () => void;
padding?: number;
onPaddingChange?: (padding: number) => void;
padding?: Padding;
onPaddingChange?: (padding: Padding) => void;
frame?: string | null;
onFrameChange?: (frameId: string | null) => void;
cropRegion?: CropRegion;
@@ -734,7 +739,7 @@ export function SettingsPanel({
onWebcamChange,
onUploadWebcam,
onClearWebcam,
padding = 50,
padding = DEFAULT_PADDING,
onPaddingChange,
frame = null,
onFrameChange,
@@ -787,7 +792,7 @@ export function SettingsPanel({
);
const removeBackgroundStateRef = useRef<{
aspectRatio: AspectRatio;
padding: number;
padding: Padding;
} | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const builtInWallpaperPaths = useMemo(
@@ -915,7 +920,7 @@ export function SettingsPanel({
const [gradient, setGradient] = useState<string>(
GRADIENTS.includes(selected) ? selected : GRADIENTS[0],
);
const removeBackgroundEnabled = aspectRatio === "native" && padding === 0;
const removeBackgroundEnabled = aspectRatio === "native" && isZeroPadding(padding);
// Device frames from extension system
const [availableFrames, setAvailableFrames] = useState<FrameInstance[]>([]);
@@ -1126,14 +1131,60 @@ export function SettingsPanel({
padding,
};
onAspectRatioChange?.("native");
onPaddingChange?.(0);
onPaddingChange?.({ top: 0, bottom: 0, left: 0, right: 0, linked: padding.linked });
return;
}
if (removeBackgroundStateRef.current) {
onAspectRatioChange?.(removeBackgroundStateRef.current.aspectRatio);
onPaddingChange?.(removeBackgroundStateRef.current.padding);
const previousState = removeBackgroundStateRef.current;
if (previousState) {
onAspectRatioChange?.(previousState.aspectRatio);
onPaddingChange?.(previousState.padding);
removeBackgroundStateRef.current = null;
return;
}
// Fallback if the project loaded in a "background removed" state already
onAspectRatioChange?.(initialEditorPreferences.aspectRatio);
onPaddingChange?.({ ...DEFAULT_PADDING });
};
const togglePaddingLink = () => {
const isLinked = padding.linked !== false;
const nextLinked = !isLinked;
if (nextLinked) {
// Compute average for relinking to avoid sudden shifts
const avg = Math.round(
(padding.top + padding.bottom + padding.left + padding.right) / 4,
);
onPaddingChange?.({
top: avg,
bottom: avg,
left: avg,
right: avg,
linked: true,
});
} else {
onPaddingChange?.({
...padding,
linked: false,
});
}
};
const handlePaddingSideChange = (side: keyof Padding, value: number) => {
if (padding.linked !== false) {
onPaddingChange?.({
top: value,
bottom: value,
left: value,
right: value,
linked: true,
});
} else {
onPaddingChange?.({
...padding,
[side]: value,
});
}
};
@@ -1289,7 +1340,7 @@ export function SettingsPanel({
const resetFrameSection = () => {
onShadowChange?.(initialEditorPreferences.shadowIntensity);
onBorderRadiusChange?.(initialEditorPreferences.borderRadius);
onPaddingChange?.(initialEditorPreferences.padding);
onPaddingChange?.(DEFAULT_PADDING);
onFrameChange?.(null);
onAspectRatioChange?.(initialEditorPreferences.aspectRatio);
removeBackgroundStateRef.current = null;
@@ -1779,17 +1830,95 @@ export function SettingsPanel({
formatValue={(v) => `${v}px`}
parseInput={(text) => parseFloat(text.replace(/px$/, ""))}
/>
<SliderControl
label={tSettings("effects.padding")}
value={padding}
defaultValue={initialEditorPreferences.padding}
min={0}
max={100}
step={1}
onChange={(v) => onPaddingChange?.(v)}
formatValue={(v) => `${v}%`}
parseInput={(text) => parseFloat(text.replace(/%$/, ""))}
/>
<div className="flex flex-col gap-1.5 pt-0.5">
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">
{tSettings("effects.padding")}
</span>
<button
type="button"
onClick={togglePaddingLink}
className={cn(
"p-1 rounded-md transition-colors",
padding.linked !== false
? "text-[#2563EB] bg-[#2563EB]/10"
: "text-muted-foreground hover:bg-foreground/[0.05]",
)}
title={
padding.linked !== false
? tSettings("effects.paddingLinked", "Linked (Uniform)")
: tSettings("effects.paddingUnlinked", "Unlinked (Asymmetrical)")
}
>
{padding.linked !== false ? (
<Link size={12} weight="bold" />
) : (
<LinkBreak size={12} weight="bold" />
)}
</button>
</div>
{padding.linked !== false ? (
<SliderControl
label=""
value={padding.top}
defaultValue={DEFAULT_PADDING.top}
min={0}
max={100}
step={1}
onChange={(v) => handlePaddingSideChange("top", v)}
formatValue={(v) => `${v}%`}
parseInput={(text) => parseFloat(text.replace(/%$/, ""))}
/>
) : (
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5">
<SliderControl
label={tSettings("effects.paddingTop", "Top")}
value={padding.top}
defaultValue={DEFAULT_PADDING.top}
min={0}
max={100}
step={1}
onChange={(v) => handlePaddingSideChange("top", v)}
formatValue={(v) => `${v}%`}
parseInput={(text) => parseFloat(text.replace(/%$/, ""))}
/>
<SliderControl
label={tSettings("effects.paddingBottom", "Bottom")}
value={padding.bottom}
defaultValue={DEFAULT_PADDING.bottom}
min={0}
max={100}
step={1}
onChange={(v) => handlePaddingSideChange("bottom", v)}
formatValue={(v) => `${v}%`}
parseInput={(text) => parseFloat(text.replace(/%$/, ""))}
/>
<SliderControl
label={tSettings("effects.paddingLeft", "Left")}
value={padding.left}
defaultValue={DEFAULT_PADDING.left}
min={0}
max={100}
step={1}
onChange={(v) => handlePaddingSideChange("left", v)}
formatValue={(v) => `${v}%`}
parseInput={(text) => parseFloat(text.replace(/%$/, ""))}
/>
<SliderControl
label={tSettings("effects.paddingRight", "Right")}
value={padding.right}
defaultValue={DEFAULT_PADDING.right}
min={0}
max={100}
step={1}
onChange={(v) => handlePaddingSideChange("right", v)}
formatValue={(v) => `${v}%`}
parseInput={(text) => parseFloat(text.replace(/%$/, ""))}
/>
</div>
)}
</div>
<div className="flex items-center justify-between rounded-lg bg-foreground/[0.03] px-2.5 py-1.5">
<span className="text-[10px] text-muted-foreground">
{tSettings("effects.removeBackground")}
@@ -40,7 +40,7 @@ import {
type AutoCaptionSettings,
type CaptionCue,
type CursorStyle,
type CursorTelemetryPoint,
type Padding,
type SpeedRegion,
type TrimRegion,
type WebcamOverlaySettings,
@@ -110,6 +110,7 @@ import {
DEFAULT_ZOOM_IN_OVERLAP_MS,
DEFAULT_ZOOM_OUT_DURATION_MS,
DEFAULT_ZOOM_OUT_EASING,
DEFAULT_PADDING,
getDefaultCaptionFontFamily,
} from "./types";
import {
@@ -241,7 +242,7 @@ interface VideoPlaybackProps {
zoomOutEasing?: ZoomTransitionEasing;
connectedZoomEasing?: ZoomTransitionEasing;
borderRadius?: number;
padding?: number;
padding?: Padding | number;
frame?: string | null;
cropRegion?: import("./types").CropRegion;
webcam?: WebcamOverlaySettings;
@@ -311,7 +312,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
zoomOutEasing = DEFAULT_ZOOM_OUT_EASING,
connectedZoomEasing = DEFAULT_CONNECTED_ZOOM_EASING,
borderRadius = 0,
padding = 50,
padding = DEFAULT_PADDING,
frame = null,
cropRegion,
webcam,
@@ -56,11 +56,13 @@ import {
DEFAULT_ZOOM_OUT_DURATION_MS,
DEFAULT_ZOOM_OUT_EASING,
getDefaultCaptionFontFamily,
type Padding,
type SpeedRegion,
type TrimRegion,
type WebcamOverlaySettings,
type ZoomRegion,
type ZoomTransitionEasing,
DEFAULT_PADDING,
} from "./types";
export const PROJECT_VERSION = 1;
@@ -91,7 +93,7 @@ export interface ProjectEditorState {
cursorClickBounceDuration: number;
cursorSway: number;
borderRadius: number;
padding: number;
padding: Padding;
/** Selected frame ID (e.g. "recordly.frames/browser-dark"), or null for none */
frame: string | null;
cropRegion: CropRegion;
@@ -758,7 +760,30 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
? clamp((editor as Partial<ProjectEditorState>).cursorSway as number, 0, 2)
: DEFAULT_CURSOR_SWAY,
borderRadius: typeof editor.borderRadius === "number" ? editor.borderRadius : 12.5,
padding: isFiniteNumber(editor.padding) ? clamp(editor.padding, 0, 100) : 20,
padding: (() => {
const p = editor.padding;
if (p && typeof p === "object") {
const linked = typeof p.linked === "boolean" ? p.linked : true;
const top = isFiniteNumber(p.top) ? clamp(p.top, 0, 100) : DEFAULT_PADDING.top;
if (linked) {
return { top, bottom: top, left: top, right: top, linked: true };
}
return {
top,
bottom: isFiniteNumber(p.bottom)
? clamp(p.bottom, 0, 100)
: DEFAULT_PADDING.bottom,
left: isFiniteNumber(p.left) ? clamp(p.left, 0, 100) : DEFAULT_PADDING.left,
right: isFiniteNumber(p.right) ? clamp(p.right, 0, 100) : DEFAULT_PADDING.right,
linked: false,
};
}
if (typeof p === "number" && isFiniteNumber(p)) {
const val = clamp(p, 0, 100);
return { top: val, bottom: val, left: val, right: val, linked: true };
}
return { ...DEFAULT_PADDING };
})(),
frame: typeof editor.frame === "string" ? editor.frame : null,
cropRegion: {
x: cropX,
+16
View File
@@ -312,6 +312,22 @@ export const DEFAULT_CROP_REGION: CropRegion = {
height: 1,
};
export interface Padding {
top: number;
bottom: number;
left: number;
right: number;
linked?: boolean;
}
export const DEFAULT_PADDING: Padding = {
top: 50,
bottom: 50,
left: 50,
right: 50,
linked: true,
};
export interface AudioRegion {
id: string;
startMs: number;
@@ -1,6 +1,124 @@
import { Application, Graphics, Sprite } from "pixi.js";
import { drawSquircleOnGraphics } from "@/lib/geometry/squircle";
import type { CropRegion } from "../types";
import type { CropRegion, Padding } from "../types";
export const PADDING_SCALE_FACTOR = 0.2;
export function isZeroPadding(padding: Padding | number): boolean {
if (typeof padding === "number") {
return padding === 0;
}
return (
padding.top === 0 &&
padding.bottom === 0 &&
padding.left === 0 &&
padding.right === 0
);
}
export interface PaddedLayoutResult {
scale: number;
centerOffsetX: number;
centerOffsetY: number;
spriteX: number;
spriteY: number;
fullFrameDisplayW: number;
fullFrameDisplayH: number;
fullVideoDisplayWidth: number;
fullVideoDisplayHeight: number;
croppedDisplayWidth: number;
croppedDisplayHeight: number;
cropStartX: number;
cropStartY: number;
}
export function computePaddedLayout(params: {
width: number;
height: number;
padding: Padding | number;
frameInsets?: { top: number; right: number; bottom: number; left: number } | null;
cropRegion: CropRegion;
videoWidth: number;
videoHeight: number;
}): PaddedLayoutResult {
const { width, height, padding, frameInsets, cropRegion, videoWidth, videoHeight } = params;
// Apply asymmetrical padding
const p =
typeof padding === "number"
? { top: padding, bottom: padding, left: padding, right: padding }
: padding;
// Padding is a percentage (0-100)
// Clamp to ensure we don't have overlapping padding that exceeds 100% of a dimension
const clampPercent = (v: number) => Math.min(100, Math.max(0, v));
const leftPadFrac = (clampPercent(p.left) / 100) * PADDING_SCALE_FACTOR;
const rightPadFrac = (clampPercent(p.right) / 100) * PADDING_SCALE_FACTOR;
const topPadFrac = (clampPercent(p.top) / 100) * PADDING_SCALE_FACTOR;
const bottomPadFrac = (clampPercent(p.bottom) / 100) * PADDING_SCALE_FACTOR;
const availableFracW = Math.max(0, 1.0 - leftPadFrac - rightPadFrac);
const availableFracH = Math.max(0, 1.0 - topPadFrac - bottomPadFrac);
const maxDisplayWidth = width * availableFracW;
const maxDisplayHeight = height * availableFracH;
const crop = cropRegion;
const croppedVideoWidth = videoWidth * crop.width;
const croppedVideoHeight = videoHeight * crop.height;
const insets = frameInsets;
const screenFracW = insets ? 1 - insets.left - insets.right : 1;
const screenFracH = insets ? 1 - insets.top - insets.bottom : 1;
const fullFrameVideoW = croppedVideoWidth / screenFracW;
const fullFrameVideoH = croppedVideoHeight / screenFracH;
const scale = Math.min(
fullFrameVideoW > 0 ? maxDisplayWidth / fullFrameVideoW : 0,
fullFrameVideoH > 0 ? maxDisplayHeight / fullFrameVideoH : 0,
);
const fullVideoDisplayWidth = videoWidth * scale;
const fullVideoDisplayHeight = videoHeight * scale;
const croppedDisplayWidth = croppedVideoWidth * scale;
const croppedDisplayHeight = croppedVideoHeight * scale;
const fullFrameDisplayW = fullFrameVideoW * scale;
const fullFrameDisplayH = fullFrameVideoH * scale;
const availableCenterX = leftPadFrac * width + maxDisplayWidth / 2;
const availableCenterY = topPadFrac * height + maxDisplayHeight / 2;
const frameCenterX = availableCenterX - fullFrameDisplayW / 2;
const frameCenterY = availableCenterY - fullFrameDisplayH / 2;
const centerOffsetX = insets
? frameCenterX + insets.left * fullFrameDisplayW
: frameCenterX;
const centerOffsetY = insets
? frameCenterY + insets.top * fullFrameDisplayH
: frameCenterY;
const spriteX = centerOffsetX - crop.x * fullVideoDisplayWidth;
const spriteY = centerOffsetY - crop.y * fullVideoDisplayHeight;
return {
scale,
centerOffsetX,
centerOffsetY,
spriteX,
spriteY,
fullFrameDisplayW,
fullFrameDisplayH,
fullVideoDisplayWidth,
fullVideoDisplayHeight,
croppedDisplayWidth,
croppedDisplayHeight,
cropStartX: crop.x * videoWidth,
cropStartY: crop.y * videoHeight,
};
}
interface LayoutParams {
container: HTMLDivElement;
@@ -11,7 +129,7 @@ interface LayoutParams {
cropRegion?: CropRegion;
lockedVideoDimensions?: { width: number; height: number } | null;
borderRadius?: number;
padding?: number;
padding?: Padding | number;
/** Screen insets from the active device frame, used to scale/center the full frame */
frameInsets?: { top: number; right: number; bottom: number; left: number } | null;
}
@@ -63,99 +181,47 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
app.canvas.style.width = "100%";
app.canvas.style.height = "100%";
// Apply crop region
const crop = cropRegion || { x: 0, y: 0, width: 1, height: 1 };
const layout = computePaddedLayout({
width,
height,
padding,
frameInsets,
cropRegion: crop,
videoWidth,
videoHeight,
});
// Calculate the cropped dimensions
const croppedVideoWidth = videoWidth * crop.width;
const croppedVideoHeight = videoHeight * crop.height;
videoSprite.scale.set(layout.scale);
videoSprite.position.set(layout.spriteX, layout.spriteY);
const cropStartX = crop.x * videoWidth;
const cropStartY = crop.y * videoHeight;
const cropEndX = cropStartX + croppedVideoWidth;
const cropEndY = cropStartY + croppedVideoHeight;
// 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;
const maxDisplayWidth = width * paddingScale;
const maxDisplayHeight = height * paddingScale;
// When a device frame is active, the frame extends beyond the video area.
// We need to scale so the ENTIRE frame (video + bezels) fits in the viewport,
// then center the full frame, not just the video content.
const insets = frameInsets;
// Fraction of the full frame occupied by the screen area
const screenFracW = insets ? 1 - insets.left - insets.right : 1;
const screenFracH = insets ? 1 - insets.top - insets.bottom : 1;
// Full frame dimensions in video pixels (the frame image is this large relative to the screen)
const fullFrameVideoW = croppedVideoWidth / screenFracW;
const fullFrameVideoH = croppedVideoHeight / screenFracH;
const scale = Math.min(maxDisplayWidth / fullFrameVideoW, maxDisplayHeight / fullFrameVideoH);
videoSprite.scale.set(scale);
// Calculate display size of the full video at this scale
const fullVideoDisplayWidth = videoWidth * scale;
const fullVideoDisplayHeight = videoHeight * scale;
// Calculate display size of just the cropped region
const croppedDisplayWidth = croppedVideoWidth * scale;
const croppedDisplayHeight = croppedVideoHeight * scale;
// Center the full frame (or just the video if no frame) in the container
// Full frame display dimensions
const fullFrameDisplayW = fullFrameVideoW * scale;
const fullFrameDisplayH = fullFrameVideoH * scale;
// The full frame's top-left, centered in the viewport
const frameCenterX = (width - fullFrameDisplayW) / 2;
const frameCenterY = (height - fullFrameDisplayH) / 2;
// The screen area starts at frameCenterX + insets.left * fullFrameDisplayW
const centerOffsetX = insets
? frameCenterX + insets.left * fullFrameDisplayW
: (width - croppedDisplayWidth) / 2;
const centerOffsetY = insets
? frameCenterY + insets.top * fullFrameDisplayH
: (height - croppedDisplayHeight) / 2;
// 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 centerOffsetX, centerOffsetY
const spriteX = centerOffsetX - crop.x * fullVideoDisplayWidth;
const spriteY = centerOffsetY - crop.y * fullVideoDisplayHeight;
videoSprite.position.set(spriteX, spriteY);
// Create a mask that only shows the cropped region (centered in container)
const maskX = centerOffsetX;
const maskY = centerOffsetY;
// Apply border radius
maskGraphics.clear();
drawSquircleOnGraphics(maskGraphics, {
x: maskX,
y: maskY,
width: croppedDisplayWidth,
height: croppedDisplayHeight,
x: layout.centerOffsetX,
y: layout.centerOffsetY,
width: layout.croppedDisplayWidth,
height: layout.croppedDisplayHeight,
radius: borderRadius,
});
maskGraphics.fill({ color: 0xffffff });
return {
stageSize: { width, height },
videoSize: { width: croppedVideoWidth, height: croppedVideoHeight },
baseScale: scale,
baseOffset: { x: spriteX, y: spriteY },
videoSize: { width: videoWidth * crop.width, height: videoHeight * crop.height },
baseScale: layout.scale,
baseOffset: { x: layout.spriteX, y: layout.spriteY },
maskRect: {
x: maskX,
y: maskY,
width: croppedDisplayWidth,
height: croppedDisplayHeight,
x: layout.centerOffsetX,
y: layout.centerOffsetY,
width: layout.croppedDisplayWidth,
height: layout.croppedDisplayHeight,
sourceCrop: crop,
},
cropBounds: { startX: cropStartX, endX: cropEndX, startY: cropStartY, endY: cropEndY },
cropBounds: {
startX: layout.cropStartX,
endX: layout.cropStartX + videoWidth * crop.width,
startY: layout.cropStartY,
endY: layout.cropStartY + videoHeight * crop.height,
},
};
}
+6
View File
@@ -91,6 +91,12 @@
"radius": "Radius",
"roundness": "Roundness",
"padding": "Padding",
"paddingLinked": "Linked (Uniform)",
"paddingUnlinked": "Unlinked (Asymmetrical)",
"paddingTop": "Top",
"paddingBottom": "Bottom",
"paddingLeft": "Left",
"paddingRight": "Right",
"removeBackground": "Remove background"
},
"sections": {
+29 -59
View File
@@ -7,6 +7,7 @@ import type {
CropRegion,
CursorStyle,
CursorTelemetryPoint,
Padding,
SpeedRegion,
WebcamOverlaySettings,
ZoomRegion,
@@ -17,6 +18,7 @@ import {
BASE_PREVIEW_WIDTH,
ZOOM_DEPTH_SCALES,
} from "@/components/video-editor/types";
import { computePaddedLayout } from "@/components/video-editor/videoPlayback/layoutUtils";
import { DEFAULT_FOCUS } from "@/components/video-editor/videoPlayback/constants";
import {
type CursorFollowCameraState,
@@ -88,7 +90,7 @@ interface FrameRenderConfig {
zoomOutEasing?: ZoomTransitionEasing;
connectedZoomEasing?: ZoomTransitionEasing;
borderRadius?: number;
padding?: number;
padding?: Padding | number;
cropRegion: CropRegion;
webcam?: WebcamOverlaySettings;
webcamUrl?: string | null;
@@ -1179,55 +1181,20 @@ export class FrameRenderer {
private updateLayout(): void {
if (!this.app || !this.videoSprite || !this.maskGraphics || !this.videoContainer) return;
const { width, height } = this.config;
const { cropRegion, borderRadius = 0, padding = 0 } = this.config;
const videoWidth = this.config.videoWidth;
const videoHeight = this.config.videoHeight;
const { width, height, cropRegion, borderRadius = 0, padding = 0, videoWidth, videoHeight } = this.config;
// Calculate cropped video dimensions
const cropStartX = cropRegion.x;
const cropStartY = cropRegion.y;
const cropEndX = cropRegion.x + cropRegion.width;
const cropEndY = cropRegion.y + cropRegion.height;
const layout = computePaddedLayout({
width,
height,
padding,
frameInsets: this.frameInsets,
cropRegion,
videoWidth,
videoHeight,
});
const croppedVideoWidth = videoWidth * (cropEndX - cropStartX);
const croppedVideoHeight = videoHeight * (cropEndY - cropStartY);
const paddingScale = 1.0 - (padding / 100) * 0.4;
const viewportWidth = width * paddingScale;
const viewportHeight = height * paddingScale;
// When a device frame is active, scale to fit the ENTIRE frame (video + bezels)
const insets = this.frameInsets;
const screenFracW = insets ? 1 - insets.left - insets.right : 1;
const screenFracH = insets ? 1 - insets.top - insets.bottom : 1;
const fullFrameVideoW = croppedVideoWidth / screenFracW;
const fullFrameVideoH = croppedVideoHeight / screenFracH;
const scale = Math.min(viewportWidth / fullFrameVideoW, viewportHeight / fullFrameVideoH);
this.videoSprite.scale.set(scale);
const fullVideoDisplayWidth = videoWidth * scale;
const fullVideoDisplayHeight = videoHeight * scale;
const croppedDisplayWidth = croppedVideoWidth * scale;
const croppedDisplayHeight = croppedVideoHeight * scale;
// Center the full frame (video + bezels) in the output canvas
const fullFrameDisplayW = fullFrameVideoW * scale;
const fullFrameDisplayH = fullFrameVideoH * scale;
const frameCenterX = (width - fullFrameDisplayW) / 2;
const frameCenterY = (height - fullFrameDisplayH) / 2;
const centerOffsetX = insets
? frameCenterX + insets.left * fullFrameDisplayW
: (width - croppedDisplayWidth) / 2;
const centerOffsetY = insets
? frameCenterY + insets.top * fullFrameDisplayH
: (height - croppedDisplayHeight) / 2;
const spriteX = centerOffsetX - cropRegion.x * fullVideoDisplayWidth;
const spriteY = centerOffsetY - cropRegion.y * fullVideoDisplayHeight;
this.videoSprite.position.set(spriteX, spriteY);
this.videoSprite.scale.set(layout.scale);
this.videoSprite.position.set(layout.spriteX, layout.spriteY);
this.videoContainer.position.set(0, 0);
@@ -1240,10 +1207,10 @@ export class FrameRenderer {
this.maskGraphics.clear();
drawSquircleOnGraphics(this.maskGraphics, {
x: centerOffsetX,
y: centerOffsetY,
width: croppedDisplayWidth,
height: croppedDisplayHeight,
x: layout.centerOffsetX,
y: layout.centerOffsetY,
width: layout.croppedDisplayWidth,
height: layout.croppedDisplayHeight,
radius: scaledBorderRadius,
});
this.maskGraphics.fill({ color: 0xffffff });
@@ -1251,14 +1218,17 @@ export class FrameRenderer {
// Cache layout info
this.layoutCache = {
stageSize: { width, height },
videoSize: { width: croppedVideoWidth, height: croppedVideoHeight },
baseScale: scale,
baseOffset: { x: spriteX, y: spriteY },
videoSize: {
width: videoWidth * cropRegion.width,
height: videoHeight * cropRegion.height,
},
baseScale: layout.scale,
baseOffset: { x: layout.spriteX, y: layout.spriteY },
maskRect: {
x: centerOffsetX,
y: centerOffsetY,
width: croppedDisplayWidth,
height: croppedDisplayHeight,
x: layout.centerOffsetX,
y: layout.centerOffsetY,
width: layout.croppedDisplayWidth,
height: layout.croppedDisplayHeight,
sourceCrop: cropRegion,
},
};
+3 -2
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_WEBCAM_OVERLAY } from "@/components/video-editor/types";
import { DEFAULT_WEBCAM_OVERLAY } from "../../components/video-editor/types";
vi.mock("pixi.js", () => ({
Application: class {},
@@ -94,6 +94,7 @@ function createMockContext() {
drawImage: vi.fn(),
save: vi.fn(),
restore: vi.fn(),
getImageData: vi.fn(() => ({ data: new Uint8ClampedArray(0) })),
globalAlpha: 1,
imageSmoothingEnabled: true,
imageSmoothingQuality: "high",
@@ -182,6 +183,6 @@ describe("ModernFrameRenderer blur export path", () => {
expect(renderAnnotations).toHaveBeenCalledTimes(1);
expect(renderer.getCanvas()).not.toBe(sourceCanvas);
expect(renderer.capturePixelsForNativeExport()).toBeNull();
expect(renderer.capturePixelsForNativeExport()).not.toBeNull();
});
});
+64 -50
View File
@@ -17,12 +17,14 @@ import type {
CropRegion,
CursorStyle,
CursorTelemetryPoint,
Padding,
SpeedRegion,
WebcamOverlaySettings,
ZoomRegion,
ZoomTransitionEasing,
} from "@/components/video-editor/types";
import { getDefaultCaptionFontFamily, ZOOM_DEPTH_SCALES } from "@/components/video-editor/types";
import { computePaddedLayout } from "@/components/video-editor/videoPlayback/layoutUtils";
import { DEFAULT_FOCUS } from "@/components/video-editor/videoPlayback/constants";
import {
type CursorFollowCameraState,
@@ -104,7 +106,7 @@ interface FrameRenderConfig {
zoomOutEasing?: ZoomTransitionEasing;
connectedZoomEasing?: ZoomTransitionEasing;
borderRadius?: number;
padding?: number;
padding?: Padding | number;
cropRegion: CropRegion;
webcam?: WebcamOverlaySettings;
webcamUrl?: string | null;
@@ -128,6 +130,7 @@ interface FrameRenderConfig {
zoomSmoothness?: number;
zoomClassicMode?: boolean;
frame?: string | null;
nativeReadbackMode?: "pixels" | "canvas";
}
interface AnimationState {
@@ -2434,43 +2437,29 @@ export class FrameRenderer {
}
private updateLayout(): void {
if (!this.videoSprite || !this.videoMaskGraphics || !this.videoContainer) {
return;
}
if (!this.app || !this.videoSprite || !this.videoMaskGraphics) return;
const { width, height } = this.config;
const { cropRegion, borderRadius = 0, padding = 0 } = this.config;
const videoWidth = this.config.videoWidth;
const videoHeight = this.config.videoHeight;
const {
width,
height,
cropRegion,
borderRadius = 0,
padding = 0,
videoWidth,
videoHeight,
} = this.config;
const cropStartX = cropRegion.x;
const cropStartY = cropRegion.y;
const cropEndX = cropRegion.x + cropRegion.width;
const cropEndY = cropRegion.y + cropRegion.height;
const layout = computePaddedLayout({
width,
height,
padding,
cropRegion,
videoWidth,
videoHeight,
});
const croppedVideoWidth = videoWidth * (cropEndX - cropStartX);
const croppedVideoHeight = videoHeight * (cropEndY - cropStartY);
const paddingScale = 1.0 - (padding / 100) * 0.4;
const viewportWidth = width * paddingScale;
const viewportHeight = height * paddingScale;
const scale = Math.min(
viewportWidth / croppedVideoWidth,
viewportHeight / croppedVideoHeight,
);
this.videoSprite.scale.set(scale);
const fullVideoDisplayWidth = videoWidth * scale;
const fullVideoDisplayHeight = videoHeight * scale;
const croppedDisplayWidth = croppedVideoWidth * scale;
const croppedDisplayHeight = croppedVideoHeight * scale;
const centerOffsetX = (width - croppedDisplayWidth) / 2;
const centerOffsetY = (height - croppedDisplayHeight) / 2;
const spriteX = centerOffsetX - cropRegion.x * fullVideoDisplayWidth;
const spriteY = centerOffsetY - cropRegion.y * fullVideoDisplayHeight;
this.videoSprite.position.set(spriteX, spriteY);
this.videoSprite.scale.set(layout.scale);
this.videoSprite.position.set(layout.spriteX, layout.spriteY);
const previewWidth = this.config.previewWidth || 1920;
const previewHeight = this.config.previewHeight || 1080;
@@ -2479,32 +2468,35 @@ export class FrameRenderer {
this.videoMaskGraphics.clear();
drawSquircleOnGraphics(this.videoMaskGraphics, {
x: centerOffsetX,
y: centerOffsetY,
width: croppedDisplayWidth,
height: croppedDisplayHeight,
x: layout.centerOffsetX,
y: layout.centerOffsetY,
width: layout.croppedDisplayWidth,
height: layout.croppedDisplayHeight,
radius: scaledBorderRadius,
});
this.videoMaskGraphics.fill({ color: 0xffffff });
this.updateVideoShadowLayout({
maskX: centerOffsetX,
maskY: centerOffsetY,
maskWidth: croppedDisplayWidth,
maskHeight: croppedDisplayHeight,
maskX: layout.centerOffsetX,
maskY: layout.centerOffsetY,
maskWidth: layout.croppedDisplayWidth,
maskHeight: layout.croppedDisplayHeight,
maskRadius: scaledBorderRadius,
});
this.layoutCache = {
stageSize: { width, height },
videoSize: { width: croppedVideoWidth, height: croppedVideoHeight },
baseScale: scale,
baseOffset: { x: spriteX, y: spriteY },
videoSize: {
width: videoWidth * cropRegion.width,
height: videoHeight * cropRegion.height,
},
baseScale: layout.scale,
baseOffset: { x: layout.spriteX, y: layout.spriteY },
maskRect: {
x: centerOffsetX,
y: centerOffsetY,
width: croppedDisplayWidth,
height: croppedDisplayHeight,
x: layout.centerOffsetX,
y: layout.centerOffsetY,
width: layout.croppedDisplayWidth,
height: layout.croppedDisplayHeight,
sourceCrop: cropRegion,
},
};
@@ -2707,6 +2699,28 @@ export class FrameRenderer {
return this.outputCanvasOverride ?? (this.app.canvas as HTMLCanvasElement);
}
capturePixelsForNativeExport(): Uint8ClampedArray | null {
if (!this.app) {
return null;
}
const finalCanvas =
this.outputCanvasOverride ??
(this.shouldCompositeExtensionFrame() ? this.compositeCanvas : null);
if (finalCanvas) {
const context = finalCanvas.getContext("2d");
return context
? context.getImageData(0, 0, finalCanvas.width, finalCanvas.height).data
: null;
}
const result = this.app.renderer.extract.pixels(this.app.stage);
const pixels = result.pixels;
return pixels instanceof Uint8ClampedArray ? pixels : new Uint8ClampedArray(pixels);
}
getRendererBackend(): ExportRenderBackend {
return this.rendererBackend;
}
+2 -1
View File
@@ -6,6 +6,7 @@ import type {
CropRegion,
CursorStyle,
CursorTelemetryPoint,
Padding,
SpeedRegion,
TrimRegion,
WebcamOverlaySettings,
@@ -55,7 +56,7 @@ interface VideoExporterConfig extends ExportConfig {
zoomOutEasing?: ZoomTransitionEasing;
connectedZoomEasing?: ZoomTransitionEasing;
borderRadius?: number;
padding?: number;
padding?: Padding | number;
videoPadding?: number;
cropRegion: CropRegion;
webcam?: WebcamOverlaySettings;
+28 -4
View File
@@ -152,7 +152,7 @@ export class ExtensionHost {
canvasWidth: number;
canvasHeight: number;
borderRadius: number;
padding: number;
padding: number | { top: number; right: number; bottom: number; left: number };
} | null = null;
private _zoomState: { scale: number; focusX: number; focusY: number; progress: number } | null =
null;
@@ -441,10 +441,33 @@ export class ExtensionHost {
canvasWidth: number;
canvasHeight: number;
borderRadius: number;
padding: number;
padding: number | { top: number; right: number; bottom: number; left: number };
} | null,
): void {
this._videoLayout = layout;
if (!layout) {
this._videoLayout = null;
return;
}
// Normalize and deep clone padding to exclude UI-only fields like 'linked'
const p = layout.padding;
const normalizedPadding =
typeof p === "number"
? p
: {
top: Number(p.top) || 0,
right: Number(p.right) || 0,
bottom: Number(p.bottom) || 0,
left: Number(p.left) || 0,
};
this._videoLayout = {
maskRect: { ...layout.maskRect },
canvasWidth: layout.canvasWidth,
canvasHeight: layout.canvasHeight,
borderRadius: layout.borderRadius,
padding: normalizedPadding,
};
}
setZoomState(
@@ -841,12 +864,13 @@ export class ExtensionHost {
getVideoLayout() {
if (!host._videoLayout) return null;
const p = host._videoLayout.padding;
return {
maskRect: { ...host._videoLayout.maskRect },
canvasWidth: host._videoLayout.canvasWidth,
canvasHeight: host._videoLayout.canvasHeight,
borderRadius: host._videoLayout.borderRadius,
padding: host._videoLayout.padding,
padding: typeof p === "number" ? p : { ...p },
};
},
+4 -4
View File
@@ -241,8 +241,8 @@ export interface RenderHookContext {
maskRect: { x: number; y: number; width: number; height: number };
/** Border radius applied to the video (in canvas pixels) */
borderRadius: number;
/** Padding around the video (in canvas pixels) */
padding: number;
/** Padding around the video (in canvas pixels). Can be a number (global) or an object with individual sides. */
padding: number | { top: number; right: number; bottom: number; left: number };
};
/** Current zoom state */
zoom?: {
@@ -350,7 +350,7 @@ export interface CursorEffectContext {
videoLayout?: {
maskRect: { x: number; y: number; width: number; height: number };
borderRadius: number;
padding: number;
padding: number | { top: number; right: number; bottom: number; left: number };
};
}
@@ -474,7 +474,7 @@ export interface RecordlyExtensionAPI {
canvasWidth: number;
canvasHeight: number;
borderRadius: number;
padding: number;
padding: number | { top: number; right: number; bottom: number; left: number };
} | null;
/**