diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index d4d5d7ff..66ca23b5 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, @@ -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(null); const builtInWallpaperPaths = useMemo( @@ -915,7 +920,7 @@ export function SettingsPanel({ const [gradient, setGradient] = useState( 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([]); @@ -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$/, ""))} /> - 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 af6d4685..3cee42fb 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 d02531de..5438ecac 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; @@ -758,7 +760,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: (() => { + 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/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..1b2aef7b 100644 --- a/src/components/video-editor/videoPlayback/layoutUtils.ts +++ b/src/components/video-editor/videoPlayback/layoutUtils.ts @@ -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, + }, }; } diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index c384c0d7..03d1cde0 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -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": { diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index 392978c9..f3609e1a 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, @@ -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, }, }; 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 26215922..bdf4df30 100644 --- a/src/lib/exporter/modernFrameRenderer.ts +++ b/src/lib/exporter/modernFrameRenderer.ts @@ -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; } diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index 87f7bcc3..41797ad2 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, @@ -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; diff --git a/src/lib/extensions/extensionHost.ts b/src/lib/extensions/extensionHost.ts index 9361c22d..1a9147ab 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,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 }, }; }, 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; /**