From 020469b1a8db455404f218fabe236166bed7fbf6 Mon Sep 17 00:00:00 2001 From: SehajveerSingh2005 Date: Wed, 22 Apr 2026 22:59:33 +0530 Subject: [PATCH 1/4] feat: implement independent padding controls #158 - Replaced global padding slider with asymmetrical controls (Top, Bottom, Left, Right). - Added 'Link' toggle to maintain uniform padding when preferred. - Updated layout logic to support individual side offsets and fixed a centering bug. - Ensured consistent rendering across preview and export pipelines. - Implemented state normalization for backward compatibility with legacy projects. - Propagated padding state to the extension system for custom render hooks. --- src/components/video-editor/SettingsPanel.tsx | 154 +++++++++++++++--- src/components/video-editor/VideoPlayback.tsx | 7 +- .../video-editor/projectPersistence.ts | 29 +++- src/components/video-editor/types.ts | 16 ++ .../video-editor/videoPlayback/layoutUtils.ts | 42 +++-- src/lib/exporter/frameRenderer.ts | 38 ++++- src/lib/exporter/modernFrameRenderer.ts | 47 ++++-- src/lib/exporter/videoExporter.ts | 3 +- src/lib/extensions/extensionHost.ts | 4 +- src/lib/extensions/types.ts | 8 +- 10 files changed, 286 insertions(+), 62 deletions(-) diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 1de3ebc1..fc0f7415 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -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, @@ -60,6 +61,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 +396,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; @@ -733,7 +735,7 @@ export function SettingsPanel({ onWebcamChange, onUploadWebcam, onClearWebcam, - padding = 50, + padding = DEFAULT_PADDING, onPaddingChange, frame = null, onFrameChange, @@ -786,7 +788,7 @@ export function SettingsPanel({ ); const removeBackgroundStateRef = useRef<{ aspectRatio: AspectRatio; - padding: number; + padding: Padding; } | null>(null); const fileInputRef = useRef(null); const builtInWallpaperPaths = useMemo( @@ -914,7 +916,7 @@ export function SettingsPanel({ const [gradient, setGradient] = useState( GRADIENTS.includes(selected) ? selected : GRADIENTS[0], ); - const removeBackgroundEnabled = aspectRatio === "native" && padding === 0; + const removeBackgroundEnabled = aspectRatio === "native" && padding.top === 0 && padding.bottom === 0 && padding.left === 0 && padding.right === 0; // Device frames from extension system const [availableFrames, setAvailableFrames] = useState([]); @@ -1125,7 +1127,7 @@ export function SettingsPanel({ padding, }; onAspectRatioChange?.("native"); - onPaddingChange?.(0); + onPaddingChange?.({ top: 0, bottom: 0, left: 0, right: 0, linked: padding.linked }); return; } @@ -1136,6 +1138,42 @@ export function SettingsPanel({ } }; + const togglePaddingLink = () => { + const isLinked = padding.linked !== false; + const nextLinked = !isLinked; + if (nextLinked) { + onPaddingChange?.({ + top: padding.top, + bottom: padding.top, + left: padding.top, + right: padding.top, + 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, + }); + } + }; + const webcamFileName = webcam?.sourcePath?.split(/[\\/]/).pop() ?? null; const visibleColorPalette = colorPalette.slice(0, 15); const webcamPositionPreset = webcam?.positionPreset ?? DEFAULT_WEBCAM_POSITION_PRESET; @@ -1288,7 +1326,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; @@ -1778,17 +1816,95 @@ export function SettingsPanel({ formatValue={(v) => `${v}px`} parseInput={(text) => parseFloat(text.replace(/px$/, ""))} /> - onPaddingChange?.(v)} - formatValue={(v) => `${v}%`} - parseInput={(text) => parseFloat(text.replace(/%$/, ""))} - /> +
+
+ + {tSettings("effects.padding")} + + +
+ + {padding.linked !== false ? ( + handlePaddingSideChange("top", v)} + formatValue={(v) => `${v}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, ""))} + /> + ) : ( +
+ handlePaddingSideChange("top", v)} + formatValue={(v) => `${v}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, ""))} + /> + handlePaddingSideChange("bottom", v)} + formatValue={(v) => `${v}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, ""))} + /> + handlePaddingSideChange("left", v)} + formatValue={(v) => `${v}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, ""))} + /> + handlePaddingSideChange("right", v)} + formatValue={(v) => `${v}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, ""))} + /> +
+ )} +
{tSettings("effects.removeBackground")} diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 045d9304..1ac6d402 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -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( zoomOutEasing = DEFAULT_ZOOM_OUT_EASING, connectedZoomEasing = DEFAULT_CONNECTED_ZOOM_EASING, borderRadius = 0, - padding = 50, + padding = DEFAULT_PADDING, frame = null, cropRegion, webcam, diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 81c59744..dabc43ab 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -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; @@ -748,7 +750,30 @@ export function normalizeProjectEditor(editor: Partial): Pro ? clamp((editor as Partial).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: + editor.padding && typeof editor.padding === "object" + ? { + top: isFiniteNumber(editor.padding.top) ? clamp(editor.padding.top, 0, 100) : DEFAULT_PADDING.top, + bottom: isFiniteNumber(editor.padding.bottom) + ? clamp(editor.padding.bottom, 0, 100) + : DEFAULT_PADDING.bottom, + left: isFiniteNumber(editor.padding.left) + ? clamp(editor.padding.left, 0, 100) + : DEFAULT_PADDING.left, + right: isFiniteNumber(editor.padding.right) + ? clamp(editor.padding.right, 0, 100) + : DEFAULT_PADDING.right, + linked: typeof editor.padding.linked === "boolean" ? editor.padding.linked : true, + } + : typeof editor.padding === "number" && isFiniteNumber(editor.padding) + ? { + top: clamp(editor.padding, 0, 100), + bottom: clamp(editor.padding, 0, 100), + left: clamp(editor.padding, 0, 100), + right: clamp(editor.padding, 0, 100), + linked: true, + } + : DEFAULT_PADDING, frame: typeof editor.frame === "string" ? editor.frame : null, cropRegion: { x: cropX, diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index 4115a30c..7fbddb7b 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -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; diff --git a/src/components/video-editor/videoPlayback/layoutUtils.ts b/src/components/video-editor/videoPlayback/layoutUtils.ts index 8c028e36..5dc838fc 100644 --- a/src/components/video-editor/videoPlayback/layoutUtils.ts +++ b/src/components/video-editor/videoPlayback/layoutUtils.ts @@ -1,6 +1,6 @@ import { Application, Graphics, Sprite } from "pixi.js"; import { drawSquircleOnGraphics } from "@/lib/geometry/squircle"; -import type { CropRegion } from "../types"; +import type { CropRegion, Padding } from "../types"; interface LayoutParams { container: HTMLDivElement; @@ -11,7 +11,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; } @@ -75,11 +75,24 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null { const cropEndX = cropStartX + croppedVideoWidth; const cropEndY = cropStartY + croppedVideoHeight; - // Calculate scale to fit the cropped area in the viewport + // Apply asymmetrical padding + const p = + typeof padding === "number" + ? { top: padding, bottom: padding, left: padding, right: padding } + : padding; + // 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; + // We use 0.2 as a multiplier for each side so that uniform 100% padding results in 0.6 scale (1.0 - 0.4) + const leftPadFrac = (p.left / 100) * 0.2; + const rightPadFrac = (p.right / 100) * 0.2; + const topPadFrac = (p.top / 100) * 0.2; + const bottomPadFrac = (p.bottom / 100) * 0.2; + + const availableFracW = 1.0 - leftPadFrac - rightPadFrac; + const availableFracH = 1.0 - topPadFrac - bottomPadFrac; + + const maxDisplayWidth = width * availableFracW; + const maxDisplayHeight = height * availableFracH; // 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, @@ -104,20 +117,25 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null { const croppedDisplayWidth = croppedVideoWidth * scale; const croppedDisplayHeight = croppedVideoHeight * scale; - // Center the full frame (or just the video if no frame) in the container + // Center the full frame (or just the video if no frame) in the available area // 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; + + // Center point of the available area relative to the container + const availableCenterX = leftPadFrac * width + maxDisplayWidth / 2; + const availableCenterY = topPadFrac * height + maxDisplayHeight / 2; + + // The full frame's top-left, centered in the available area + const frameCenterX = availableCenterX - fullFrameDisplayW / 2; + const frameCenterY = availableCenterY - fullFrameDisplayH / 2; // The screen area starts at frameCenterX + insets.left * fullFrameDisplayW const centerOffsetX = insets ? frameCenterX + insets.left * fullFrameDisplayW - : (width - croppedDisplayWidth) / 2; + : frameCenterX; const centerOffsetY = insets ? frameCenterY + insets.top * fullFrameDisplayH - : (height - croppedDisplayHeight) / 2; + : frameCenterY; // Position the full video sprite so that when we apply the mask, // the cropped region appears centered diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index 392978c9..99dcbcee 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -7,6 +7,7 @@ import type { CropRegion, CursorStyle, CursorTelemetryPoint, + Padding, SpeedRegion, WebcamOverlaySettings, ZoomRegion, @@ -88,7 +89,7 @@ interface FrameRenderConfig { zoomOutEasing?: ZoomTransitionEasing; connectedZoomEasing?: ZoomTransitionEasing; borderRadius?: number; - padding?: number; + padding?: Padding | number; cropRegion: CropRegion; webcam?: WebcamOverlaySettings; webcamUrl?: string | null; @@ -1193,9 +1194,24 @@ export class FrameRenderer { 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; + // Apply asymmetrical padding + const p = + typeof padding === "number" + ? { top: padding, bottom: padding, left: padding, right: padding } + : padding; + + // Padding is a percentage (0-100), where 50 matches the original VIEWPORT_SCALE of 0.8 + // We use 0.2 as a multiplier for each side so that uniform 100% padding results in 0.6 scale (1.0 - 0.4) + const leftPadFrac = (p.left / 100) * 0.2; + const rightPadFrac = (p.right / 100) * 0.2; + const topPadFrac = (p.top / 100) * 0.2; + const bottomPadFrac = (p.bottom / 100) * 0.2; + + const availableFracW = 1.0 - leftPadFrac - rightPadFrac; + const availableFracH = 1.0 - topPadFrac - bottomPadFrac; + + const viewportWidth = width * availableFracW; + const viewportHeight = height * availableFracH; // When a device frame is active, scale to fit the ENTIRE frame (video + bezels) const insets = this.frameInsets; @@ -1213,17 +1229,21 @@ export class FrameRenderer { const croppedDisplayWidth = croppedVideoWidth * scale; const croppedDisplayHeight = croppedVideoHeight * scale; - // Center the full frame (video + bezels) in the output canvas + // Center the full frame (video + bezels) in the available area const fullFrameDisplayW = fullFrameVideoW * scale; const fullFrameDisplayH = fullFrameVideoH * scale; - const frameCenterX = (width - fullFrameDisplayW) / 2; - const frameCenterY = (height - fullFrameDisplayH) / 2; + + const availableCenterX = leftPadFrac * width + viewportWidth / 2; + const availableCenterY = topPadFrac * height + viewportHeight / 2; + + const frameCenterX = availableCenterX - fullFrameDisplayW / 2; + const frameCenterY = availableCenterY - fullFrameDisplayH / 2; const centerOffsetX = insets ? frameCenterX + insets.left * fullFrameDisplayW - : (width - croppedDisplayWidth) / 2; + : frameCenterX; const centerOffsetY = insets ? frameCenterY + insets.top * fullFrameDisplayH - : (height - croppedDisplayHeight) / 2; + : frameCenterY; const spriteX = centerOffsetX - cropRegion.x * fullVideoDisplayWidth; const spriteY = centerOffsetY - cropRegion.y * fullVideoDisplayHeight; diff --git a/src/lib/exporter/modernFrameRenderer.ts b/src/lib/exporter/modernFrameRenderer.ts index 26215922..69101843 100644 --- a/src/lib/exporter/modernFrameRenderer.ts +++ b/src/lib/exporter/modernFrameRenderer.ts @@ -17,6 +17,7 @@ import type { CropRegion, CursorStyle, CursorTelemetryPoint, + Padding, SpeedRegion, WebcamOverlaySettings, ZoomRegion, @@ -104,7 +105,7 @@ interface FrameRenderConfig { zoomOutEasing?: ZoomTransitionEasing; connectedZoomEasing?: ZoomTransitionEasing; borderRadius?: number; - padding?: number; + padding?: Padding | number; cropRegion: CropRegion; webcam?: WebcamOverlaySettings; webcamUrl?: string | null; @@ -2451,13 +2452,26 @@ export class FrameRenderer { 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, - ); + // Apply asymmetrical padding + const p = + typeof padding === "number" + ? { top: padding, bottom: padding, left: padding, right: padding } + : padding; + + // Padding is a percentage (0-100), where 50 matches the original VIEWPORT_SCALE of 0.8 + // We use 0.2 as a multiplier for each side so that uniform 100% padding results in 0.6 scale (1.0 - 0.4) + const leftPadFrac = (p.left / 100) * 0.2; + const rightPadFrac = (p.right / 100) * 0.2; + const topPadFrac = (p.top / 100) * 0.2; + const bottomPadFrac = (p.bottom / 100) * 0.2; + + const availableFracW = 1.0 - leftPadFrac - rightPadFrac; + const availableFracH = 1.0 - topPadFrac - bottomPadFrac; + + const viewportWidth = width * availableFracW; + const viewportHeight = height * availableFracH; + + const scale = Math.min(viewportWidth / croppedVideoWidth, viewportHeight / croppedVideoHeight); this.videoSprite.scale.set(scale); @@ -2465,8 +2479,13 @@ export class FrameRenderer { const fullVideoDisplayHeight = videoHeight * scale; const croppedDisplayWidth = croppedVideoWidth * scale; const croppedDisplayHeight = croppedVideoHeight * scale; - const centerOffsetX = (width - croppedDisplayWidth) / 2; - const centerOffsetY = (height - croppedDisplayHeight) / 2; + + // Center the video in the available area + const availableCenterX = leftPadFrac * width + viewportWidth / 2; + const availableCenterY = topPadFrac * height + viewportHeight / 2; + + const centerOffsetX = availableCenterX - croppedDisplayWidth / 2; + const centerOffsetY = availableCenterY - croppedDisplayHeight / 2; const spriteX = centerOffsetX - cropRegion.x * fullVideoDisplayWidth; const spriteY = centerOffsetY - cropRegion.y * fullVideoDisplayHeight; @@ -2707,6 +2726,14 @@ export class FrameRenderer { return this.outputCanvasOverride ?? (this.app.canvas as HTMLCanvasElement); } + capturePixelsForNativeExport(): Uint8ClampedArray | null { + if (!this.app || this.outputCanvasOverride) { + return null; + } + + return (this.app.renderer.extract.pixels(this.app.stage) as unknown) as Uint8ClampedArray; + } + getRendererBackend(): ExportRenderBackend { return this.rendererBackend; } diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index 43d345c3..cff12de8 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -6,6 +6,7 @@ import type { CropRegion, CursorStyle, CursorTelemetryPoint, + Padding, SpeedRegion, TrimRegion, WebcamOverlaySettings, @@ -42,7 +43,7 @@ interface VideoExporterConfig extends ExportConfig { zoomOutEasing?: ZoomTransitionEasing; connectedZoomEasing?: ZoomTransitionEasing; borderRadius?: number; - padding?: number; + padding?: Padding | number; videoPadding?: number; cropRegion: CropRegion; webcam?: WebcamOverlaySettings; diff --git a/src/lib/extensions/extensionHost.ts b/src/lib/extensions/extensionHost.ts index 9361c22d..19dd53f4 100644 --- a/src/lib/extensions/extensionHost.ts +++ b/src/lib/extensions/extensionHost.ts @@ -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,7 +441,7 @@ 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; diff --git a/src/lib/extensions/types.ts b/src/lib/extensions/types.ts index 0e6f8ef5..a77e58a6 100644 --- a/src/lib/extensions/types.ts +++ b/src/lib/extensions/types.ts @@ -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; /** From 26f3db34db0483d03c4bb1af74450121a53b137b Mon Sep 17 00:00:00 2001 From: SehajveerSingh2005 Date: Thu, 23 Apr 2026 00:02:53 +0530 Subject: [PATCH 2/4] refactor: address code review findings and clean up padding implementation --- src/components/video-editor/SettingsPanel.tsx | 29 ++- .../video-editor/projectPersistence.ts | 48 ++-- .../video-editor/videoPlayback/layoutUtils.ts | 244 +++++++++++------- src/lib/exporter/frameRenderer.ts | 104 ++------ src/lib/exporter/modernFrameRenderer.test.ts | 5 +- src/lib/exporter/modernFrameRenderer.ts | 125 ++++----- src/lib/extensions/extensionHost.ts | 28 +- 7 files changed, 300 insertions(+), 283 deletions(-) diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index fc0f7415..282318af 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -51,6 +51,9 @@ import type { ZoomMode, ZoomTransitionEasing, } from "./types"; +import { + isZeroPadding, +} from "./videoPlayback/layoutUtils"; import { DEFAULT_AUTO_CAPTION_SETTINGS, DEFAULT_CROP_REGION, @@ -916,7 +919,7 @@ export function SettingsPanel({ const [gradient, setGradient] = useState( GRADIENTS.includes(selected) ? selected : GRADIENTS[0], ); - const removeBackgroundEnabled = aspectRatio === "native" && padding.top === 0 && padding.bottom === 0 && padding.left === 0 && padding.right === 0; + const removeBackgroundEnabled = aspectRatio === "native" && isZeroPadding(padding); // Device frames from extension system const [availableFrames, setAvailableFrames] = useState([]); @@ -1142,11 +1145,15 @@ export function SettingsPanel({ 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: padding.top, - bottom: padding.top, - left: padding.top, - right: padding.top, + top: avg, + bottom: avg, + left: avg, + right: avg, linked: true, }); } else { @@ -1832,8 +1839,8 @@ export function SettingsPanel({ )} title={ padding.linked !== false - ? "Linked (Uniform)" - : "Unlinked (Asymmetrical)" + ? tSettings("effects.padding.linked", "Linked (Uniform)") + : tSettings("effects.padding.unlinked", "Unlinked (Asymmetrical)") } > {padding.linked !== false ? ( @@ -1859,7 +1866,7 @@ export function SettingsPanel({ ) : (
parseFloat(text.replace(/%$/, ""))} /> parseFloat(text.replace(/%$/, ""))} /> parseFloat(text.replace(/%$/, ""))} /> ): Pro ? clamp((editor as Partial).cursorSway as number, 0, 2) : DEFAULT_CURSOR_SWAY, borderRadius: typeof editor.borderRadius === "number" ? editor.borderRadius : 12.5, - padding: - editor.padding && typeof editor.padding === "object" - ? { - top: isFiniteNumber(editor.padding.top) ? clamp(editor.padding.top, 0, 100) : DEFAULT_PADDING.top, - bottom: isFiniteNumber(editor.padding.bottom) - ? clamp(editor.padding.bottom, 0, 100) - : DEFAULT_PADDING.bottom, - left: isFiniteNumber(editor.padding.left) - ? clamp(editor.padding.left, 0, 100) - : DEFAULT_PADDING.left, - right: isFiniteNumber(editor.padding.right) - ? clamp(editor.padding.right, 0, 100) - : DEFAULT_PADDING.right, - linked: typeof editor.padding.linked === "boolean" ? editor.padding.linked : true, - } - : typeof editor.padding === "number" && isFiniteNumber(editor.padding) - ? { - top: clamp(editor.padding, 0, 100), - bottom: clamp(editor.padding, 0, 100), - left: clamp(editor.padding, 0, 100), - right: clamp(editor.padding, 0, 100), - linked: true, - } - : DEFAULT_PADDING, + 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, diff --git a/src/components/video-editor/videoPlayback/layoutUtils.ts b/src/components/video-editor/videoPlayback/layoutUtils.ts index 5dc838fc..1b2aef7b 100644 --- a/src/components/video-editor/videoPlayback/layoutUtils.ts +++ b/src/components/video-editor/videoPlayback/layoutUtils.ts @@ -2,6 +2,124 @@ import { Application, Graphics, Sprite } from "pixi.js"; import { drawSquircleOnGraphics } from "@/lib/geometry/squircle"; 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; app: Application; @@ -63,117 +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; - - // Apply asymmetrical padding - const p = - typeof padding === "number" - ? { top: padding, bottom: padding, left: padding, right: padding } - : padding; - - // Padding is a percentage (0-100), where 50 matches the original VIEWPORT_SCALE of 0.8 - // We use 0.2 as a multiplier for each side so that uniform 100% padding results in 0.6 scale (1.0 - 0.4) - const leftPadFrac = (p.left / 100) * 0.2; - const rightPadFrac = (p.right / 100) * 0.2; - const topPadFrac = (p.top / 100) * 0.2; - const bottomPadFrac = (p.bottom / 100) * 0.2; - - const availableFracW = 1.0 - leftPadFrac - rightPadFrac; - const availableFracH = 1.0 - topPadFrac - bottomPadFrac; - - const maxDisplayWidth = width * availableFracW; - const maxDisplayHeight = height * availableFracH; - - // 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 available area - // Full frame display dimensions - const fullFrameDisplayW = fullFrameVideoW * scale; - const fullFrameDisplayH = fullFrameVideoH * scale; - - // Center point of the available area relative to the container - const availableCenterX = leftPadFrac * width + maxDisplayWidth / 2; - const availableCenterY = topPadFrac * height + maxDisplayHeight / 2; - - // The full frame's top-left, centered in the available area - const frameCenterX = availableCenterX - fullFrameDisplayW / 2; - const frameCenterY = availableCenterY - fullFrameDisplayH / 2; - // The screen area starts at frameCenterX + insets.left * fullFrameDisplayW - const centerOffsetX = insets - ? frameCenterX + insets.left * fullFrameDisplayW - : frameCenterX; - const centerOffsetY = insets - ? frameCenterY + insets.top * fullFrameDisplayH - : frameCenterY; - - // 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, + }, }; } diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index 99dcbcee..f3609e1a 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -18,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, @@ -1180,74 +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); - - // Apply asymmetrical padding - const p = - typeof padding === "number" - ? { top: padding, bottom: padding, left: padding, right: padding } - : padding; - - // Padding is a percentage (0-100), where 50 matches the original VIEWPORT_SCALE of 0.8 - // We use 0.2 as a multiplier for each side so that uniform 100% padding results in 0.6 scale (1.0 - 0.4) - const leftPadFrac = (p.left / 100) * 0.2; - const rightPadFrac = (p.right / 100) * 0.2; - const topPadFrac = (p.top / 100) * 0.2; - const bottomPadFrac = (p.bottom / 100) * 0.2; - - const availableFracW = 1.0 - leftPadFrac - rightPadFrac; - const availableFracH = 1.0 - topPadFrac - bottomPadFrac; - - const viewportWidth = width * availableFracW; - const viewportHeight = height * availableFracH; - - // 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 available area - const fullFrameDisplayW = fullFrameVideoW * scale; - const fullFrameDisplayH = fullFrameVideoH * scale; - - const availableCenterX = leftPadFrac * width + viewportWidth / 2; - const availableCenterY = topPadFrac * height + viewportHeight / 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 - 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); @@ -1260,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 }); @@ -1271,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, }, }; diff --git a/src/lib/exporter/modernFrameRenderer.test.ts b/src/lib/exporter/modernFrameRenderer.test.ts index cc6e6ab7..2ccdec77 100644 --- a/src/lib/exporter/modernFrameRenderer.test.ts +++ b/src/lib/exporter/modernFrameRenderer.test.ts @@ -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(); }); }); diff --git a/src/lib/exporter/modernFrameRenderer.ts b/src/lib/exporter/modernFrameRenderer.ts index 69101843..bdf4df30 100644 --- a/src/lib/exporter/modernFrameRenderer.ts +++ b/src/lib/exporter/modernFrameRenderer.ts @@ -24,6 +24,7 @@ import type { 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, @@ -129,6 +130,7 @@ interface FrameRenderConfig { zoomSmoothness?: number; zoomClassicMode?: boolean; frame?: string | null; + nativeReadbackMode?: "pixels" | "canvas"; } interface AnimationState { @@ -2435,61 +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); - - // Apply asymmetrical padding - const p = - typeof padding === "number" - ? { top: padding, bottom: padding, left: padding, right: padding } - : padding; - - // Padding is a percentage (0-100), where 50 matches the original VIEWPORT_SCALE of 0.8 - // We use 0.2 as a multiplier for each side so that uniform 100% padding results in 0.6 scale (1.0 - 0.4) - const leftPadFrac = (p.left / 100) * 0.2; - const rightPadFrac = (p.right / 100) * 0.2; - const topPadFrac = (p.top / 100) * 0.2; - const bottomPadFrac = (p.bottom / 100) * 0.2; - - const availableFracW = 1.0 - leftPadFrac - rightPadFrac; - const availableFracH = 1.0 - topPadFrac - bottomPadFrac; - - const viewportWidth = width * availableFracW; - const viewportHeight = height * availableFracH; - - 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; - - // Center the video in the available area - const availableCenterX = leftPadFrac * width + viewportWidth / 2; - const availableCenterY = topPadFrac * height + viewportHeight / 2; - - const centerOffsetX = availableCenterX - croppedDisplayWidth / 2; - const centerOffsetY = availableCenterY - 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; @@ -2498,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, }, }; @@ -2727,11 +2700,25 @@ export class FrameRenderer { } capturePixelsForNativeExport(): Uint8ClampedArray | null { - if (!this.app || this.outputCanvasOverride) { + if (!this.app) { return null; } - return (this.app.renderer.extract.pixels(this.app.stage) as unknown) as Uint8ClampedArray; + 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 { diff --git a/src/lib/extensions/extensionHost.ts b/src/lib/extensions/extensionHost.ts index 19dd53f4..1a9147ab 100644 --- a/src/lib/extensions/extensionHost.ts +++ b/src/lib/extensions/extensionHost.ts @@ -444,7 +444,30 @@ export class ExtensionHost { 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 }, }; }, From 5488e880248b1f761771201be204d9b6278c1847 Mon Sep 17 00:00:00 2001 From: SehajveerSingh2005 Date: Thu, 23 Apr 2026 00:15:04 +0530 Subject: [PATCH 3/4] fix: add fallback for background removal toggle when state is missing --- src/components/video-editor/SettingsPanel.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 282318af..f5afda88 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -1134,11 +1134,17 @@ export function SettingsPanel({ 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 = () => { From 4046c16dba61f6041007499739636eeaa02b6aec Mon Sep 17 00:00:00 2001 From: SehajveerSingh2005 Date: Thu, 23 Apr 2026 00:28:55 +0530 Subject: [PATCH 4/4] fix(ui): resolve i18n key collision for padding labels --- src/components/video-editor/SettingsPanel.tsx | 12 ++++++------ src/i18n/locales/en/settings.json | 6 ++++++ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index f5afda88..7d0a68e3 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -1845,8 +1845,8 @@ export function SettingsPanel({ )} title={ padding.linked !== false - ? tSettings("effects.padding.linked", "Linked (Uniform)") - : tSettings("effects.padding.unlinked", "Unlinked (Asymmetrical)") + ? tSettings("effects.paddingLinked", "Linked (Uniform)") + : tSettings("effects.paddingUnlinked", "Unlinked (Asymmetrical)") } > {padding.linked !== false ? ( @@ -1872,7 +1872,7 @@ export function SettingsPanel({ ) : (
parseFloat(text.replace(/%$/, ""))} /> parseFloat(text.replace(/%$/, ""))} /> parseFloat(text.replace(/%$/, ""))} />