Allow advanced padding to position video vertically

This commit is contained in:
surim0n
2026-06-15 12:40:03 -04:00
parent 8c7d23d828
commit a51e05fcd4
6 changed files with 145 additions and 32 deletions
@@ -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}%`}
@@ -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,
});
});
});
@@ -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<ProjectEditorState>): 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,
+2
View File
@@ -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;
@@ -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);
});
});
});
@@ -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;