diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 1fa52661..a74ec409 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -73,6 +73,7 @@ import type { ZoomTransitionEasing, } from "./types"; import { + ADVANCED_VERTICAL_PADDING_MAX, DEFAULT_AUTO_CAPTION_SETTINGS, DEFAULT_CROP_REGION, DEFAULT_CURSOR_CLICK_BOUNCE, @@ -2413,7 +2414,7 @@ export function SettingsPanel({ value={padding.top} defaultValue={DEFAULT_PADDING.top} min={0} - max={100} + max={ADVANCED_VERTICAL_PADDING_MAX} step={1} onChange={(v) => handlePaddingSideChange("top", v)} formatValue={(v) => `${v}%`} @@ -2424,7 +2425,7 @@ export function SettingsPanel({ value={padding.bottom} defaultValue={DEFAULT_PADDING.bottom} min={0} - max={100} + max={ADVANCED_VERTICAL_PADDING_MAX} step={1} onChange={(v) => handlePaddingSideChange("bottom", v)} formatValue={(v) => `${v}%`} diff --git a/src/components/video-editor/projectPersistence.test.ts b/src/components/video-editor/projectPersistence.test.ts new file mode 100644 index 00000000..575c3b67 --- /dev/null +++ b/src/components/video-editor/projectPersistence.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; + +import { normalizeProjectEditor } from "./projectPersistence"; +import { ADVANCED_VERTICAL_PADDING_MAX } from "./types"; + +describe("normalizeProjectEditor", () => { + it("preserves the extended advanced vertical padding range", () => { + const editor = normalizeProjectEditor({ + padding: { + top: 240, + bottom: ADVANCED_VERTICAL_PADDING_MAX, + left: 22, + right: 22, + linked: false, + }, + }); + + expect(editor.padding).toMatchObject({ + top: 240, + bottom: ADVANCED_VERTICAL_PADDING_MAX, + left: 22, + right: 22, + linked: false, + }); + }); + + it("keeps linked padding clamped to the original range", () => { + const editor = normalizeProjectEditor({ + padding: { + top: ADVANCED_VERTICAL_PADDING_MAX, + bottom: ADVANCED_VERTICAL_PADDING_MAX, + left: ADVANCED_VERTICAL_PADDING_MAX, + right: ADVANCED_VERTICAL_PADDING_MAX, + linked: true, + }, + }); + + expect(editor.padding).toMatchObject({ + top: 100, + bottom: 100, + left: 100, + right: 100, + linked: true, + }); + }); +}); diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 704371a6..1d4be3d8 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -22,6 +22,7 @@ import { DEFAULT_WALLPAPER_PATH } from "@/lib/wallpapers"; import { ASPECT_RATIOS, type AspectRatio, isCustomAspectRatio } from "@/utils/aspectRatioUtils"; import { CURSOR_MOTION_PRESETS, resolveCursorMotionPresetId } from "./cursorMotionPresets"; import { + ADVANCED_VERTICAL_PADDING_MAX, type AnnotationRegion, type AudioRegion, type AutoCaptionAnimation, @@ -950,14 +951,17 @@ export function normalizeProjectEditor(editor: Partial): Pro 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; + const verticalMax = linked ? 100 : ADVANCED_VERTICAL_PADDING_MAX; + const top = isFiniteNumber(p.top) + ? clamp(p.top, 0, verticalMax) + : 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) + ? clamp(p.bottom, 0, verticalMax) : 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, diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index 9bc401d5..91d67624 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -497,6 +497,8 @@ export const DEFAULT_CROP_REGION: CropRegion = { height: 1, }; +export const ADVANCED_VERTICAL_PADDING_MAX = 250; + export interface Padding { top: number; bottom: number; diff --git a/src/components/video-editor/videoPlayback/layoutUtils.test.ts b/src/components/video-editor/videoPlayback/layoutUtils.test.ts index 39d21f03..a93db03f 100644 --- a/src/components/video-editor/videoPlayback/layoutUtils.test.ts +++ b/src/components/video-editor/videoPlayback/layoutUtils.test.ts @@ -1,5 +1,59 @@ import { describe, expect, it } from "vitest"; -import { scalePreviewBorderRadius } from "./layoutUtils"; + +import { ADVANCED_VERTICAL_PADDING_MAX } from "../types"; +import { computePaddedLayout, scalePreviewBorderRadius } from "./layoutUtils"; + +const BASE_LAYOUT_PARAMS = { + width: 1000, + height: 1000, + cropRegion: { x: 0, y: 0, width: 1, height: 1 }, + videoWidth: 1000, + videoHeight: 1000, +}; + +describe("computePaddedLayout", () => { + it("allows advanced bottom padding to pin the video to the top edge", () => { + const layout = computePaddedLayout({ + ...BASE_LAYOUT_PARAMS, + padding: { + top: 0, + bottom: ADVANCED_VERTICAL_PADDING_MAX, + left: 0, + right: 0, + linked: false, + }, + }); + + expect(layout.centerOffsetY).toBeCloseTo(0); + }); + + it("allows advanced top padding to pin the video to the bottom edge", () => { + const layout = computePaddedLayout({ + ...BASE_LAYOUT_PARAMS, + padding: { + top: ADVANCED_VERTICAL_PADDING_MAX, + bottom: 0, + left: 0, + right: 0, + linked: false, + }, + }); + + expect(layout.centerOffsetY + layout.croppedDisplayHeight).toBeCloseTo( + BASE_LAYOUT_PARAMS.height, + ); + }); + + it("preserves linked padding centering behavior", () => { + const layout = computePaddedLayout({ + ...BASE_LAYOUT_PARAMS, + padding: { top: 20, bottom: 20, left: 20, right: 20, linked: true }, + }); + + expect(layout.centerOffsetY).toBeCloseTo(40); + expect(layout.centerOffsetY + layout.croppedDisplayHeight).toBeCloseTo(960); + }); +}); describe("scalePreviewBorderRadius", () => { it("matches export scaling against the logical preview size", () => { @@ -13,4 +67,4 @@ describe("scalePreviewBorderRadius", () => { expect(scalePreviewBorderRadius(960, 0, 16)).toBe(0); expect(scalePreviewBorderRadius(960, 540, -8)).toBe(0); }); -}); \ No newline at end of file +}); diff --git a/src/components/video-editor/videoPlayback/layoutUtils.ts b/src/components/video-editor/videoPlayback/layoutUtils.ts index 3cce0cd3..b93ef19d 100644 --- a/src/components/video-editor/videoPlayback/layoutUtils.ts +++ b/src/components/video-editor/videoPlayback/layoutUtils.ts @@ -1,16 +1,12 @@ import { Application, Graphics, Sprite } from "pixi.js"; import { drawSquircleOnGraphics } from "@/lib/geometry/squircle"; -import type { CropRegion, Padding } from "../types"; +import { ADVANCED_VERTICAL_PADDING_MAX, type CropRegion, type Padding } from "../types"; export const PADDING_SCALE_FACTOR = 0.2; export const BASE_PREVIEW_WIDTH = 1920; export const BASE_PREVIEW_HEIGHT = 1080; -export function scalePreviewBorderRadius( - width: number, - height: number, - borderRadius = 0, -): number { +export function scalePreviewBorderRadius(width: number, height: number, borderRadius = 0): number { if (width <= 0 || height <= 0) { return 0; } @@ -23,12 +19,7 @@ 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 - ); + return padding.top === 0 && padding.bottom === 0 && padding.left === 0 && padding.right === 0; } export interface PaddedLayoutResult { @@ -64,13 +55,21 @@ export function computePaddedLayout(params: { ? { 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; + // Padding is a percentage. Linked padding keeps the original 0-100 scaling + // behavior; advanced vertical padding gets extra range for positioning. + const isAdvancedPadding = typeof padding !== "number" && padding.linked === false; + const clampPercent = (v: number, max = 100) => Math.min(max, Math.max(0, v)); + const leftPercent = clampPercent(p.left); + const rightPercent = clampPercent(p.right); + const topPercent = clampPercent(p.top, isAdvancedPadding ? ADVANCED_VERTICAL_PADDING_MAX : 100); + const bottomPercent = clampPercent( + p.bottom, + isAdvancedPadding ? ADVANCED_VERTICAL_PADDING_MAX : 100, + ); + const leftPadFrac = (leftPercent / 100) * PADDING_SCALE_FACTOR; + const rightPadFrac = (rightPercent / 100) * PADDING_SCALE_FACTOR; + const topPadFrac = (Math.min(topPercent, 100) / 100) * PADDING_SCALE_FACTOR; + const bottomPadFrac = (Math.min(bottomPercent, 100) / 100) * PADDING_SCALE_FACTOR; const availableFracW = Math.max(0, 1.0 - leftPadFrac - rightPadFrac); const availableFracH = Math.max(0, 1.0 - topPadFrac - bottomPadFrac); @@ -103,17 +102,24 @@ export function computePaddedLayout(params: { const fullFrameDisplayH = fullFrameVideoH * scale; const availableCenterX = leftPadFrac * width + maxDisplayWidth / 2; - const availableCenterY = topPadFrac * height + maxDisplayHeight / 2; + const availableCenterY = isAdvancedPadding + ? (() => { + const verticalTravel = Math.max(0, height - fullFrameDisplayH); + const centeredOffsetY = verticalTravel / 2; + const directionalOffsetY = + centeredOffsetY + + ((topPercent - bottomPercent) / ADVANCED_VERTICAL_PADDING_MAX) * + centeredOffsetY; + const frameOffsetY = Math.min(verticalTravel, Math.max(0, directionalOffsetY)); + return frameOffsetY + fullFrameDisplayH / 2; + })() + : 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 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;