diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 843305ca..f51693fa 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -5108,7 +5108,7 @@ export default function VideoEditor() { zoomInEasing={zoomInEasing} zoomOutEasing={zoomOutEasing} connectedZoomEasing={connectedZoomEasing} - borderRadius={16} + borderRadius={borderRadius} padding={padding} frame={frame} cropRegion={cropRegion} diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index eb4996d5..c1e51c3a 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -475,6 +475,7 @@ const VideoPlayback = forwardRef( ref, ) => { const videoRef = useRef(null); + const previewFrameRef = useRef(null); const containerRef = useRef(null); const appRef = useRef(null); const videoSpriteRef = useRef(null); @@ -1729,6 +1730,53 @@ const VideoPlayback = forwardRef( }); }, [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( return (
{/* Background layer */} diff --git a/src/components/video-editor/videoPlayback/layoutUtils.test.ts b/src/components/video-editor/videoPlayback/layoutUtils.test.ts new file mode 100644 index 00000000..39d21f03 --- /dev/null +++ b/src/components/video-editor/videoPlayback/layoutUtils.test.ts @@ -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); + }); +}); \ 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 66af0254..3cce0cd3 100644 --- a/src/components/video-editor/videoPlayback/layoutUtils.ts +++ b/src/components/video-editor/videoPlayback/layoutUtils.ts @@ -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 }); diff --git a/src/lib/exporter/gifExporter.test.ts b/src/lib/exporter/gifExporter.test.ts index 174112e8..8b261d97 100644 --- a/src/lib/exporter/gifExporter.test.ts +++ b/src/lib/exporter/gifExporter.test.ts @@ -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 * diff --git a/src/lib/exporter/gifExporter.ts b/src/lib/exporter/gifExporter.ts index eb5fa6b6..2630e592 100644 --- a/src/lib/exporter/gifExporter.ts +++ b/src/lib/exporter/gifExporter.ts @@ -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 diff --git a/src/lib/exporter/modernVideoExporter.fallback.test.ts b/src/lib/exporter/modernVideoExporter.fallback.test.ts index ccc1d8db..0b21b892 100644 --- a/src/lib/exporter/modernVideoExporter.fallback.test.ts +++ b/src/lib/exporter/modernVideoExporter.fallback.test.ts @@ -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; + }; + + 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, + }), + ); + }); }); diff --git a/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts b/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts index aee1b51d..9509f4e3 100644 --- a/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts +++ b/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts @@ -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" }); diff --git a/src/lib/exporter/modernVideoExporter.ts b/src/lib/exporter/modernVideoExporter.ts index dc22bee0..a68278db 100644 --- a/src/lib/exporter/modernVideoExporter.ts +++ b/src/lib/exporter/modernVideoExporter.ts @@ -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(