mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 23:05:49 +00:00
Fix export click effects and preview radius parity
This commit is contained in:
@@ -5108,7 +5108,7 @@ export default function VideoEditor() {
|
||||
zoomInEasing={zoomInEasing}
|
||||
zoomOutEasing={zoomOutEasing}
|
||||
connectedZoomEasing={connectedZoomEasing}
|
||||
borderRadius={16}
|
||||
borderRadius={borderRadius}
|
||||
padding={padding}
|
||||
frame={frame}
|
||||
cropRegion={cropRegion}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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" });
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user