Fix export click effects and preview radius parity

This commit is contained in:
webadderall
2026-05-29 19:46:20 +10:00
parent 1940b1b42b
commit f76912f2eb
9 changed files with 275 additions and 59 deletions
+1 -1
View File
@@ -5108,7 +5108,7 @@ export default function VideoEditor() {
zoomInEasing={zoomInEasing}
zoomOutEasing={zoomOutEasing}
connectedZoomEasing={connectedZoomEasing}
borderRadius={16}
borderRadius={borderRadius}
padding={padding}
frame={frame}
cropRegion={cropRegion}
+50 -1
View File
@@ -475,6 +475,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
ref,
) => {
const videoRef = useRef<HTMLVideoElement | null>(null);
const previewFrameRef = useRef<HTMLDivElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const appRef = useRef<Application | null>(null);
const videoSpriteRef = useRef<Sprite | null>(null);
@@ -1729,6 +1730,53 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
});
}, [pixiReady, videoReady, layoutVideoContent, cropRegion]);
useEffect(() => {
const previewFrame = previewFrameRef.current;
if (!previewFrame) {
return;
}
let frameId: number | null = null;
const applyPreviewFrameSquircle = () => {
const width = previewFrame.offsetWidth;
const height = previewFrame.offsetHeight;
if (width <= 0 || height <= 0) {
return;
}
const squirclePath = getSquircleSvgPath({
x: 0,
y: 0,
width,
height,
radius: 12,
});
previewFrame.style.clipPath = `path('${squirclePath}')`;
previewFrame.style.setProperty("-webkit-clip-path", `path('${squirclePath}')`);
};
applyPreviewFrameSquircle();
if (typeof ResizeObserver === "undefined") {
return;
}
const observer = new ResizeObserver(() => {
if (frameId !== null) {
cancelAnimationFrame(frameId);
}
frameId = requestAnimationFrame(applyPreviewFrameSquircle);
});
observer.observe(previewFrame);
return () => {
if (frameId !== null) {
cancelAnimationFrame(frameId);
}
observer.disconnect();
};
}, []);
useEffect(() => {
if (!pixiReady || !videoReady) return;
const container = containerRef.current;
@@ -2782,11 +2830,12 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
return (
<div
ref={previewFrameRef}
className="relative overflow-hidden"
style={{
width: "100%",
aspectRatio: formatAspectRatioForCSS(aspectRatio, nativeAspectRatio),
borderRadius: `${Math.max(0, borderRadius)}px`,
borderRadius: "12px",
}}
>
{/* Background layer */}
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { scalePreviewBorderRadius } from "./layoutUtils";
describe("scalePreviewBorderRadius", () => {
it("matches export scaling against the logical preview size", () => {
expect(scalePreviewBorderRadius(1920, 1080, 16)).toBeCloseTo(16, 6);
expect(scalePreviewBorderRadius(960, 540, 16)).toBeCloseTo(8, 6);
expect(scalePreviewBorderRadius(1440, 810, 16)).toBeCloseTo(12, 6);
});
it("clamps invalid or empty preview sizes to zero", () => {
expect(scalePreviewBorderRadius(0, 540, 16)).toBe(0);
expect(scalePreviewBorderRadius(960, 0, 16)).toBe(0);
expect(scalePreviewBorderRadius(960, 540, -8)).toBe(0);
});
});
@@ -6,6 +6,19 @@ 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 {
if (width <= 0 || height <= 0) {
return 0;
}
const canvasScaleFactor = Math.min(width / BASE_PREVIEW_WIDTH, height / BASE_PREVIEW_HEIGHT);
return Math.max(0, borderRadius * canvasScaleFactor);
}
export function isZeroPadding(padding: Padding | number): boolean {
if (typeof padding === "number") {
return padding === 0;
@@ -198,13 +211,12 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
videoSprite.position.set(layout.spriteX, layout.spriteY);
maskGraphics.clear();
const canvasScaleFactor = Math.min(width / BASE_PREVIEW_WIDTH, height / BASE_PREVIEW_HEIGHT);
drawSquircleOnGraphics(maskGraphics, {
x: layout.centerOffsetX,
y: layout.centerOffsetY,
width: layout.croppedDisplayWidth,
height: layout.croppedDisplayHeight,
radius: Math.max(0, borderRadius * canvasScaleFactor),
radius: scalePreviewBorderRadius(width, height, borderRadius),
});
maskGraphics.fill({ color: 0xffffff });
+40 -1
View File
@@ -1,6 +1,10 @@
import * as fc from "fast-check";
import { describe, expect, it } from "vitest";
import { calculateOutputDimensions, getGifRepeat } from "./gifExporter";
import {
buildGifFrameRendererConfig,
calculateOutputDimensions,
getGifRepeat,
} from "./gifExporter";
import { GIF_SIZE_PRESETS, GifSizePreset } from "./types";
/**
@@ -249,6 +253,41 @@ describe("Property 3: Size Preset Resolution Mapping", () => {
});
});
describe("GIF renderer config", () => {
it("forwards cursor click-effect settings into the frame renderer config", () => {
const config = buildGifFrameRendererConfig(
{
videoUrl: "file:///recording.mp4",
width: 1920,
height: 1080,
frameRate: 30,
loop: true,
sizePreset: "original",
wallpaper: "#101010",
zoomRegions: [],
showShadow: false,
shadowIntensity: 0,
backgroundBlur: 0,
cropRegion: { x: 0, y: 0, width: 1, height: 1 },
cursorClickEffect: "echo",
cursorClickEffectColor: "#22C55E",
cursorClickEffectScale: 1.4,
cursorClickEffectOpacity: 0.65,
cursorClickEffectDurationMs: 720,
} as never,
{ width: 1920, height: 1080 },
);
expect(config).toMatchObject({
cursorClickEffect: "echo",
cursorClickEffectColor: "#22C55E",
cursorClickEffectScale: 1.4,
cursorClickEffectOpacity: 0.65,
cursorClickEffectDurationMs: 720,
});
});
});
/**
* Property 6: Frame Count Consistency
*
+66 -54
View File
@@ -134,6 +134,71 @@ export function getGifRepeat(loop: boolean): 0 | 1 {
return loop ? 0 : 1;
}
export function buildGifFrameRendererConfig(
config: GifExporterConfig,
videoInfo: { width: number; height: number },
) {
return {
width: config.width,
height: config.height,
wallpaper: config.wallpaper,
zoomRegions: config.zoomRegions,
showShadow: config.showShadow,
shadowIntensity: config.shadowIntensity,
backgroundBlur: config.backgroundBlur,
zoomMotionBlur: config.zoomMotionBlur,
zoomMotionBlurTuning: config.zoomMotionBlurTuning,
zoomTemporalMotionBlur: config.zoomTemporalMotionBlur,
zoomMotionBlurSampleCount: config.zoomMotionBlurSampleCount,
zoomMotionBlurShutterFraction: config.zoomMotionBlurShutterFraction,
connectZooms: config.connectZooms,
zoomInDurationMs: config.zoomInDurationMs,
zoomInOverlapMs: config.zoomInOverlapMs,
zoomOutDurationMs: config.zoomOutDurationMs,
connectedZoomGapMs: config.connectedZoomGapMs,
connectedZoomDurationMs: config.connectedZoomDurationMs,
zoomInEasing: config.zoomInEasing,
zoomOutEasing: config.zoomOutEasing,
connectedZoomEasing: config.connectedZoomEasing,
borderRadius: config.borderRadius,
padding: config.padding,
cropRegion: config.cropRegion,
webcam: config.webcam,
webcamUrl: config.webcamUrl,
videoWidth: videoInfo.width,
videoHeight: videoInfo.height,
annotationRegions: config.annotationRegions,
autoCaptions: config.autoCaptions,
autoCaptionSettings: config.autoCaptionSettings,
speedRegions: config.speedRegions,
previewWidth: config.previewWidth,
previewHeight: config.previewHeight,
cursorTelemetry: config.cursorTelemetry,
showCursor: config.showCursor,
cursorStyle: config.cursorStyle,
cursorSize: config.cursorSize,
cursorSmoothing: config.cursorSmoothing,
cursorSpringStiffnessMultiplier: config.cursorSpringStiffnessMultiplier,
cursorSpringDampingMultiplier: config.cursorSpringDampingMultiplier,
cursorSpringMassMultiplier: config.cursorSpringMassMultiplier,
cameraSpringStiffnessMultiplier: config.cameraSpringStiffnessMultiplier,
cameraSpringDampingMultiplier: config.cameraSpringDampingMultiplier,
cameraSpringMassMultiplier: config.cameraSpringMassMultiplier,
zoomSmoothness: config.zoomSmoothness,
zoomClassicMode: config.zoomClassicMode,
cursorMotionBlur: config.cursorMotionBlur,
cursorClickEffect: config.cursorClickEffect,
cursorClickEffectColor: config.cursorClickEffectColor,
cursorClickEffectScale: config.cursorClickEffectScale,
cursorClickEffectOpacity: config.cursorClickEffectOpacity,
cursorClickEffectDurationMs: config.cursorClickEffectDurationMs,
cursorClickBounce: config.cursorClickBounce,
cursorClickBounceDuration: config.cursorClickBounceDuration,
cursorSway: config.cursorSway,
frame: config.frame,
};
}
export class GifExporter {
private config: GifExporterConfig;
private streamingDecoder: StreamingVideoDecoder | null = null;
@@ -166,60 +231,7 @@ export class GifExporter {
const videoInfo = await this.streamingDecoder.loadMetadata(this.config.videoUrl);
// Initialize frame renderer
this.renderer = new FrameRenderer({
width: this.config.width,
height: this.config.height,
wallpaper: this.config.wallpaper,
zoomRegions: this.config.zoomRegions,
showShadow: this.config.showShadow,
shadowIntensity: this.config.shadowIntensity,
backgroundBlur: this.config.backgroundBlur,
zoomMotionBlur: this.config.zoomMotionBlur,
zoomMotionBlurTuning: this.config.zoomMotionBlurTuning,
zoomTemporalMotionBlur: this.config.zoomTemporalMotionBlur,
zoomMotionBlurSampleCount: this.config.zoomMotionBlurSampleCount,
zoomMotionBlurShutterFraction: this.config.zoomMotionBlurShutterFraction,
connectZooms: this.config.connectZooms,
zoomInDurationMs: this.config.zoomInDurationMs,
zoomInOverlapMs: this.config.zoomInOverlapMs,
zoomOutDurationMs: this.config.zoomOutDurationMs,
connectedZoomGapMs: this.config.connectedZoomGapMs,
connectedZoomDurationMs: this.config.connectedZoomDurationMs,
zoomInEasing: this.config.zoomInEasing,
zoomOutEasing: this.config.zoomOutEasing,
connectedZoomEasing: this.config.connectedZoomEasing,
borderRadius: this.config.borderRadius,
padding: this.config.padding,
cropRegion: this.config.cropRegion,
webcam: this.config.webcam,
webcamUrl: this.config.webcamUrl,
videoWidth: videoInfo.width,
videoHeight: videoInfo.height,
annotationRegions: this.config.annotationRegions,
autoCaptions: this.config.autoCaptions,
autoCaptionSettings: this.config.autoCaptionSettings,
speedRegions: this.config.speedRegions,
previewWidth: this.config.previewWidth,
previewHeight: this.config.previewHeight,
cursorTelemetry: this.config.cursorTelemetry,
showCursor: this.config.showCursor,
cursorStyle: this.config.cursorStyle,
cursorSize: this.config.cursorSize,
cursorSmoothing: this.config.cursorSmoothing,
cursorSpringStiffnessMultiplier: this.config.cursorSpringStiffnessMultiplier,
cursorSpringDampingMultiplier: this.config.cursorSpringDampingMultiplier,
cursorSpringMassMultiplier: this.config.cursorSpringMassMultiplier,
cameraSpringStiffnessMultiplier: this.config.cameraSpringStiffnessMultiplier,
cameraSpringDampingMultiplier: this.config.cameraSpringDampingMultiplier,
cameraSpringMassMultiplier: this.config.cameraSpringMassMultiplier,
zoomSmoothness: this.config.zoomSmoothness,
zoomClassicMode: this.config.zoomClassicMode,
cursorMotionBlur: this.config.cursorMotionBlur,
cursorClickBounce: this.config.cursorClickBounce,
cursorClickBounceDuration: this.config.cursorClickBounceDuration,
cursorSway: this.config.cursorSway,
frame: this.config.frame,
});
this.renderer = new FrameRenderer(buildGifFrameRendererConfig(this.config, videoInfo));
await this.renderer.initialize();
// Initialize GIF encoder
@@ -286,4 +286,52 @@ describe("ModernVideoExporter native fallback routing", () => {
expect(mocks.streamingDecoderDecodeAll).toHaveBeenCalledTimes(2);
expect(mocks.muxerFinalize).toHaveBeenCalledTimes(1);
});
it("forwards cursor click-effect settings into the modern frame renderer", async () => {
const { ModernVideoExporter } = await import("./modernVideoExporter");
const { FrameRenderer } = await import("./modernFrameRenderer");
mocks.streamingDecoderGetEffectiveDuration.mockReturnValue(1);
const exporter = new ModernVideoExporter({
videoUrl: "file:///recording.mp4",
width: 1920,
height: 1080,
frameRate: 30,
bitrate: 8_000_000,
wallpaper: "#101010",
padding: 0,
borderRadius: 24,
backgroundBlur: 0,
shadowIntensity: 0,
showShadow: false,
cropRegion: { x: 0, y: 0, width: 1, height: 1 },
backendPreference: "webcodecs",
cursorClickEffect: "echo",
cursorClickEffectColor: "#22C55E",
cursorClickEffectScale: 1.4,
cursorClickEffectOpacity: 0.65,
cursorClickEffectDurationMs: 720,
} as never) as unknown as {
export: () => Promise<{ success: boolean; blob?: Blob; error?: string }>;
initializeEncoder: () => Promise<unknown>;
};
vi.spyOn(exporter, "initializeEncoder").mockResolvedValue({
codec: "avc1.640034",
hardwareAcceleration: "prefer-hardware",
});
const result = await exporter.export();
expect(result.success).toBe(true);
expect(FrameRenderer).toHaveBeenCalledWith(
expect.objectContaining({
cursorClickEffect: "echo",
cursorClickEffectColor: "#22C55E",
cursorClickEffectScale: 1.4,
cursorClickEffectOpacity: 0.65,
cursorClickEffectDurationMs: 720,
}),
);
});
});
@@ -235,6 +235,28 @@ describe("ModernVideoExporter native static-layout eligibility", () => {
).toBeNull();
});
it("skips native static-layout when cursor click effects are enabled", () => {
const exporter = createExporter({
showCursor: true,
cursorClickEffect: "echo",
cursorTelemetry: [
{ timeMs: 0, cx: 0.25, cy: 0.35 },
{ timeMs: 1_000, cx: 0.5, cy: 0.55, interactionType: "click" },
],
});
expect(
exporter.getNativeStaticLayoutSkipReason(
{
audioMode: "copy-source",
audioSourcePath: "recording.mp4",
},
videoInfo,
60,
),
).toBe("unsupported-cursor-click-effect");
});
it("reports frame overlays as the remaining native overlay blocker", () => {
const exporter = createExporter({ frame: "macbook" });
+18
View File
@@ -5,6 +5,7 @@ import type {
CaptionCue,
ClipRegion,
CropRegion,
CursorClickEffectStyle,
CursorStyle,
CursorTelemetryPoint,
Padding,
@@ -135,6 +136,11 @@ interface VideoExporterConfig extends ExportConfig {
cameraSpringDampingMultiplier?: number;
cameraSpringMassMultiplier?: number;
cursorMotionBlur?: number;
cursorClickEffect?: CursorClickEffectStyle;
cursorClickEffectColor?: string;
cursorClickEffectScale?: number;
cursorClickEffectOpacity?: number;
cursorClickEffectDurationMs?: number;
cursorClickBounce?: number;
cursorClickBounceDuration?: number;
cursorSway?: number;
@@ -624,6 +630,11 @@ export class ModernVideoExporter {
cameraSpringDampingMultiplier: this.config.cameraSpringDampingMultiplier,
cameraSpringMassMultiplier: this.config.cameraSpringMassMultiplier,
cursorMotionBlur: this.config.cursorMotionBlur,
cursorClickEffect: this.config.cursorClickEffect,
cursorClickEffectColor: this.config.cursorClickEffectColor,
cursorClickEffectScale: this.config.cursorClickEffectScale,
cursorClickEffectOpacity: this.config.cursorClickEffectOpacity,
cursorClickEffectDurationMs: this.config.cursorClickEffectDurationMs,
cursorClickBounce: this.config.cursorClickBounce,
cursorClickBounceDuration: this.config.cursorClickBounceDuration,
cursorSway: this.config.cursorSway,
@@ -1507,10 +1518,17 @@ export class ModernVideoExporter {
}
const speedRegions = this.config.speedRegions ?? [];
const hasCursorClickEffect =
this.config.showCursor === true &&
(this.config.cursorTelemetry?.length ?? 0) > 0 &&
(this.config.cursorClickEffect ?? "none") !== "none";
const configuredWallpaper = this.config.wallpaper?.trim() ?? "";
if (isVideoWallpaperSource(configuredWallpaper)) {
reasons.push("unsupported-background-video");
}
if (hasCursorClickEffect) {
reasons.push("unsupported-cursor-click-effect");
}
const hasZoomRegions = (this.config.zoomRegions ?? []).length > 0;
const needsTimelineMap = this.shouldUseNativeStaticLayoutTimelineMap(