diff --git a/LICENSE.md b/LICENSE.md index 47f7b9bc..e7ad2a92 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -9,6 +9,7 @@ Version 3, 19 November 2007 source code** (including all edits) p**ublicly available** under this same AGPLv3 license. - You CANNOT use the "Recordly" name or branding for your own project. +- If you use Recordly's code or create code derived from Recordly you must attribute Recordly in the user-facing UI and the repo. Copyright (C) 2026 webadderall diff --git a/README.md b/README.md index d42c71f6..664dfe0f 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ Language: EN | [简中](README.zh-CN.md)

- Recordly logo + Recordly logo

@@ -55,7 +55,7 @@ Add webcam footage as an overlay bubble, position it with presets or custom coor Use drag-and-drop timeline tools for zooms, trims, speed regions, annotations, extra audio regions, and crop-aware edits. Save and reopen work as `.recordly` project files.

- Recordly timeline editor screenshot + timeline editor

## Extensions & Marketplace diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 8b634980..773784ae 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -151,12 +151,12 @@ interface Window { message?: string; error?: string; }>; - pauseCursorCapture: (boundaryMs?: number) => Promise<{ + pauseCursorCapture: () => Promise<{ success: boolean; message?: string; error?: string; }>; - resumeCursorCapture: (boundaryMs?: number) => Promise<{ + resumeCursorCapture: () => Promise<{ success: boolean; message?: string; error?: string; @@ -246,7 +246,7 @@ interface Window { }, ) => Promise<{ success: boolean; - data?: Uint8Array; + tempPath?: string; error?: string; metrics?: RendererFfmpegAudioMuxMetrics; }>; @@ -313,6 +313,15 @@ interface Window { message?: string; error?: string; }>; + setCursorTelemetry: ( + videoPath: string | undefined, + samples: CursorTelemetryPoint[], + ) => Promise<{ + success: boolean; + samples: CursorTelemetryPoint[]; + message?: string; + error?: string; + }>; getSystemCursorAssets: () => Promise<{ success: boolean; cursors: Record; @@ -410,16 +419,28 @@ interface Window { }>; setCurrentVideoPath: ( path: string, - options?: { preserveProjectPath?: boolean }, + options?: { + preserveProjectPath?: boolean; + hideOverlayCursorByDefault?: boolean; + }, ) => Promise<{ success: boolean; webcamPath: string | null }>; - setCurrentRecordingSession: (session: { - videoPath: string; - webcamPath?: string | null; - timeOffsetMs?: number; - }, options?: { preserveProjectPath?: boolean }) => Promise<{ success: boolean }>; + setCurrentRecordingSession: ( + session: { + videoPath: string; + webcamPath?: string | null; + timeOffsetMs?: number; + hideOverlayCursorByDefault?: boolean; + }, + options?: { preserveProjectPath?: boolean }, + ) => Promise<{ success: boolean }>; getCurrentRecordingSession: () => Promise<{ success: boolean; - session?: { videoPath: string; webcamPath?: string | null; timeOffsetMs?: number }; + session?: { + videoPath: string; + webcamPath?: string | null; + timeOffsetMs?: number; + hideOverlayCursorByDefault?: boolean; + }; }>; getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>; clearCurrentVideoPath: () => Promise<{ success: boolean }>; @@ -529,6 +550,7 @@ interface Window { onMenuSaveProject: (callback: () => void) => () => void; onMenuSaveProjectAs: (callback: () => void) => () => void; getPlatform: () => Promise; + getLinuxWindowSystem: () => Promise<"wayland" | "x11" | null>; revealInFolder: ( filePath: string, ) => Promise<{ success: boolean; error?: string; message?: string }>; diff --git a/electron/ipc/cursor/telemetry.test.ts b/electron/ipc/cursor/telemetry.test.ts index de9b65e7..8c9b06b8 100644 --- a/electron/ipc/cursor/telemetry.test.ts +++ b/electron/ipc/cursor/telemetry.test.ts @@ -1,4 +1,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { CURSOR_TELEMETRY_VERSION } from "../constants"; + +const { writeFile, rm } = vi.hoisted(() => ({ + writeFile: vi.fn(), + rm: vi.fn(), +})); + +vi.mock("node:fs/promises", () => ({ + default: { + writeFile, + rm, + }, +})); vi.mock("electron", () => ({ app: { @@ -18,14 +31,18 @@ vi.mock("../utils", () => ({ import { getCursorCaptureElapsedMs, + normalizeCursorTelemetrySamples, pauseCursorCapture, resetCursorCaptureClock, resumeCursorCapture, + writeCursorTelemetry, } from "./telemetry"; import { setCursorCaptureStartTimeMs } from "../state"; describe("cursor telemetry pause clock", () => { beforeEach(() => { + writeFile.mockReset(); + rm.mockReset(); setCursorCaptureStartTimeMs(1_000); resetCursorCaptureClock(); }); @@ -48,4 +65,41 @@ describe("cursor telemetry pause clock", () => { expect(getCursorCaptureElapsedMs(1_900)).toBe(550); }); + + it("normalizes cursor telemetry samples before persisting them", async () => { + const samples = normalizeCursorTelemetrySamples([ + { timeMs: 30, cx: 2, cy: -1, interactionType: "click", cursorType: "pointer" }, + { timeMs: -10, cx: Number.NaN, cy: 0.2, interactionType: "drag", cursorType: "ibeam" }, + { timeMs: 10, cx: 0.25, cy: 0.75, interactionType: "move", cursorType: "text" }, + ]); + + expect(samples).toEqual([ + { timeMs: 0, cx: 0.5, cy: 0.2, interactionType: undefined, cursorType: undefined }, + { timeMs: 10, cx: 0.25, cy: 0.75, interactionType: "move", cursorType: "text" }, + { timeMs: 30, cx: 1, cy: 0, interactionType: "click", cursorType: "pointer" }, + ]); + + await writeCursorTelemetry("/tmp/recording.mp4", samples); + + expect(writeFile).toHaveBeenCalledWith( + "/tmp/recording.cursor.json", + JSON.stringify( + { + version: CURSOR_TELEMETRY_VERSION, + samples, + }, + null, + 2, + ), + "utf-8", + ); + expect(rm).not.toHaveBeenCalled(); + }); + + it("removes the sidecar when saving an empty cursor telemetry payload", async () => { + await writeCursorTelemetry("/tmp/recording.mp4", []); + + expect(rm).toHaveBeenCalledWith("/tmp/recording.cursor.json", { force: true }); + expect(writeFile).not.toHaveBeenCalled(); + }); }); diff --git a/electron/ipc/cursor/telemetry.ts b/electron/ipc/cursor/telemetry.ts index 18b8a174..2c14705a 100644 --- a/electron/ipc/cursor/telemetry.ts +++ b/electron/ipc/cursor/telemetry.ts @@ -28,6 +28,79 @@ export function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } +export function normalizeCursorTelemetrySamples(rawSamples: unknown): CursorTelemetryPoint[] { + const samples = Array.isArray(rawSamples) + ? rawSamples + : Array.isArray((rawSamples as { samples?: unknown[] } | null | undefined)?.samples) + ? ((rawSamples as { samples: unknown[] }).samples ?? []) + : []; + const boundedSamples = samples.slice(0, MAX_CURSOR_SAMPLES); + + return boundedSamples + .filter((sample: unknown) => Boolean(sample && typeof sample === "object")) + .map((sample: unknown) => { + const point = sample as Partial; + return { + timeMs: + typeof point.timeMs === "number" && Number.isFinite(point.timeMs) + ? Math.max(0, point.timeMs) + : 0, + cx: + typeof point.cx === "number" && Number.isFinite(point.cx) + ? clamp(point.cx, 0, 1) + : 0.5, + cy: + typeof point.cy === "number" && Number.isFinite(point.cy) + ? clamp(point.cy, 0, 1) + : 0.5, + interactionType: + point.interactionType === "click" || + point.interactionType === "double-click" || + point.interactionType === "right-click" || + point.interactionType === "middle-click" || + point.interactionType === "move" || + point.interactionType === "mouseup" + ? point.interactionType + : undefined, + cursorType: + point.cursorType === "arrow" || + point.cursorType === "text" || + point.cursorType === "pointer" || + point.cursorType === "crosshair" || + point.cursorType === "open-hand" || + point.cursorType === "closed-hand" || + point.cursorType === "resize-ew" || + point.cursorType === "resize-ns" || + point.cursorType === "not-allowed" + ? point.cursorType + : undefined, + }; + }) + .sort((a, b) => a.timeMs - b.timeMs); +} + +export async function writeCursorTelemetry(videoPath: string, samples: unknown) { + const telemetryPath = getTelemetryPathForVideo(videoPath); + const normalizedSamples = normalizeCursorTelemetrySamples(samples); + + if (normalizedSamples.length === 0) { + await fs.rm(telemetryPath, { force: true }); + return normalizedSamples; + } + + await fs.writeFile( + telemetryPath, + JSON.stringify( + { version: CURSOR_TELEMETRY_VERSION, samples: normalizedSamples }, + null, + 2, + ), + "utf-8", + ); + + return normalizedSamples; +} + export function stopCursorCapture() { if (cursorCaptureInterval) { clearTimeout(cursorCaptureInterval); @@ -168,9 +241,9 @@ export function pushCursorSample( } } -export function sampleCursorPoint(sampledAtMs = Date.now()) { +export function sampleCursorPoint() { const point = getNormalizedCursorPoint(); - pushCursorSample(point.cx, point.cy, getCursorCaptureElapsedMs(sampledAtMs), "move"); + pushCursorSample(point.cx, point.cy, getCursorCaptureElapsedMs(), "move"); } export async function persistPendingCursorTelemetry(videoPath: string) { diff --git a/electron/ipc/export/native-video.test.ts b/electron/ipc/export/native-video.test.ts new file mode 100644 index 00000000..de103dcf --- /dev/null +++ b/electron/ipc/export/native-video.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("electron", () => ({ + app: { + getPath: vi.fn(() => "/tmp"), + }, +})); + +vi.mock("../ffmpeg/binary", () => ({ + getFfmpegBinaryPath: vi.fn(() => "/usr/bin/ffmpeg"), +})); + +vi.mock("../state", () => ({ + cachedNativeVideoEncoder: null, + setCachedNativeVideoEncoder: vi.fn(), +})); + +const fsMocks = vi.hoisted(() => ({ + writeFile: vi.fn(async () => undefined), + readFile: vi.fn(), + stat: vi.fn(async () => ({ size: 5_000_000_000 })), + unlink: vi.fn(async () => undefined), +})); + +vi.mock("node:fs/promises", () => ({ + default: fsMocks, + ...fsMocks, +})); + +const execFileMock = vi.hoisted(() => + vi.fn((_cmd: string, _args: string[], _opts: unknown, cb: (err: Error | null) => void) => { + cb(null); + return { stdout: "", stderr: "" } as unknown; + }), +); + +vi.mock("node:child_process", () => ({ + execFile: execFileMock, + spawn: vi.fn(), +})); + +import { muxExportedVideoAudioBuffer } from "./native-video"; + +describe("muxExportedVideoAudioBuffer", () => { + it("returns the muxed output path without reading the muxed file into memory", async () => { + const videoData = new ArrayBuffer(64); + const result = await muxExportedVideoAudioBuffer(videoData, { audioMode: "none" }); + + // Path-based contract: caller (IPC handler) registers ownership and + // hands the path to the renderer's finalize-exported-video flow. + expect(typeof result.outputPath).toBe("string"); + expect(result.outputPath.length).toBeGreaterThan(0); + // The 2 GiB bug was a fs.readFile of the muxed output. The fix relies on + // stat-only metric collection — readFile must stay unused. + expect(fsMocks.readFile).not.toHaveBeenCalled(); + // We still record byte size so export metrics survive the change. + expect(result.metrics.muxedVideoBytes).toBe(5_000_000_000); + }); + + it("preserves the input temp path when audioMode='none' (no re-mux)", async () => { + const videoData = new ArrayBuffer(32); + const result = await muxExportedVideoAudioBuffer(videoData, { audioMode: "none" }); + + // muxNativeVideoExportAudio short-circuits when audioMode === "none" and + // returns the input path unchanged. We surface that so the renderer can + // finalize the same temp file the buffer was written to. + expect(result.outputPath).toMatch(/recordly-export-video-/); + }); +}); diff --git a/electron/ipc/export/native-video.ts b/electron/ipc/export/native-video.ts index a9205369..0167ab71 100644 --- a/electron/ipc/export/native-video.ts +++ b/electron/ipc/export/native-video.ts @@ -492,6 +492,8 @@ export async function muxExportedVideoAudioBuffer( `recordly-export-video-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.mp4`, ); const metrics: NativeVideoAudioMuxMetrics = {}; + let succeeded = false; + let outputPath = tempVideoPath; try { const tempVideoWriteStartedAt = getNowMs(); @@ -500,23 +502,35 @@ export async function muxExportedVideoAudioBuffer( metrics.tempVideoBytes = videoData.byteLength; const finalized = await muxNativeVideoExportAudio(tempVideoPath, options); Object.assign(metrics, finalized.metrics); - const muxedVideoReadStartedAt = getNowMs(); - const muxedData = await fs.readFile(finalized.outputPath); - metrics.muxedVideoReadMs = getNowMs() - muxedVideoReadStartedAt; - metrics.muxedVideoBytes = muxedData.byteLength; + outputPath = finalized.outputPath; + // Record byte size via stat instead of reading the whole file into a + // Buffer — fs.readFile throws ERR_FS_FILE_TOO_LARGE on >2 GiB outputs. + try { + const stat = await fs.stat(outputPath); + metrics.muxedVideoBytes = stat.size; + } catch { + // Stat failures are non-fatal; size is purely metric data. + } + succeeded = true; return { - data: new Uint8Array(muxedData), + outputPath, metrics, }; } finally { - await Promise.allSettled([ - removeTemporaryExportFile(tempVideoPath), - removeTemporaryExportFile( - path.join( - path.dirname(tempVideoPath), - `${path.basename(tempVideoPath, path.extname(tempVideoPath))}-final.mp4`, - ), - ), - ]); + // Always remove the unmuxed intermediate when the muxer wrote a separate + // file. Only remove the muxed output on failure — on success the caller + // owns it and is responsible for moving/deleting it. + const cleanupTargets: string[] = []; + if (outputPath !== tempVideoPath) { + cleanupTargets.push(tempVideoPath); + } + if (!succeeded) { + cleanupTargets.push(outputPath); + } + if (cleanupTargets.length > 0) { + await Promise.allSettled( + cleanupTargets.map((target) => removeTemporaryExportFile(target)), + ); + } } } diff --git a/electron/ipc/register/export.ts b/electron/ipc/register/export.ts index 63b6f6df..7518d0dc 100644 --- a/electron/ipc/register/export.ts +++ b/electron/ipc/register/export.ts @@ -382,9 +382,14 @@ export function registerExportHandlers() { async (_, videoData: ArrayBuffer, options?: NativeVideoExportFinishOptions) => { try { const result = await muxExportedVideoAudioBuffer(videoData, options ?? {}); + // Register the muxed output so finalize-exported-video / discard- + // exported-temp accept it. Returning a temp path (instead of the + // muxed bytes) keeps us off Node's >2 GiB fs.readFile cap and + // avoids a redundant copy through the renderer. + registerOwnedExportPath(result.outputPath); return { success: true, - data: result.data, + tempPath: result.outputPath, metrics: result.metrics, }; } catch (error) { diff --git a/electron/ipc/register/project.ts b/electron/ipc/register/project.ts index 570d7b58..7074d97f 100644 --- a/electron/ipc/register/project.ts +++ b/electron/ipc/register/project.ts @@ -45,6 +45,10 @@ function normalizeRecordingTimeOffsetMs(value: unknown): number { return typeof value === "number" && Number.isFinite(value) ? Math.round(value) : 0; } +function normalizeBoolean(value: unknown, fallback = false): boolean { + return typeof value === "boolean" ? value : fallback; +} + /** * Produces a filesystem-safe project base name without the project extension. */ @@ -527,7 +531,7 @@ export function registerProjectHandlers() { return { success: false, error: String(error), message: 'Failed to open projects folder.' } } }) - ipcMain.handle('set-current-video-path', async (_, path: string, options?: { preserveProjectPath?: boolean }) => { + ipcMain.handle('set-current-video-path', async (_, path: string, options?: { preserveProjectPath?: boolean; hideOverlayCursorByDefault?: boolean }) => { setCurrentVideoPath(normalizeVideoSourcePath(path) ?? path) approveUserPath(currentVideoPath) const resolvedSession = await resolveRecordingSession(currentVideoPath) @@ -537,29 +541,37 @@ export function registerProjectHandlers() { timeOffsetMs: 0, } - setCurrentRecordingSession(resolvedSession) + const nextSession = { + ...resolvedSession, + hideOverlayCursorByDefault: + normalizeBoolean(options?.hideOverlayCursorByDefault) || + normalizeBoolean(resolvedSession.hideOverlayCursorByDefault), + } + + setCurrentRecordingSession(nextSession) await replaceApprovedSessionLocalReadPaths([ resolvedSession.videoPath, resolvedSession.webcamPath, ]) - if (resolvedSession.webcamPath) { - await persistRecordingSessionManifest(resolvedSession) + if (nextSession.webcamPath) { + await persistRecordingSessionManifest(nextSession) } if (!options?.preserveProjectPath) { setCurrentProjectPath(null) } - return { success: true, webcamPath: resolvedSession.webcamPath ?? null } + return { success: true, webcamPath: nextSession.webcamPath ?? null } }) - ipcMain.handle('set-current-recording-session', async (_, session: { videoPath: string; webcamPath?: string | null; timeOffsetMs?: number }, options?: { preserveProjectPath?: boolean }) => { + ipcMain.handle('set-current-recording-session', async (_, session: { videoPath: string; webcamPath?: string | null; timeOffsetMs?: number; hideOverlayCursorByDefault?: boolean }, options?: { preserveProjectPath?: boolean }) => { const normalizedVideoPath = normalizeVideoSourcePath(session.videoPath) ?? session.videoPath setCurrentVideoPath(normalizedVideoPath) setCurrentRecordingSession({ videoPath: normalizedVideoPath, webcamPath: normalizeVideoSourcePath(session.webcamPath ?? null), timeOffsetMs: normalizeRecordingTimeOffsetMs(session.timeOffsetMs), + hideOverlayCursorByDefault: normalizeBoolean(session.hideOverlayCursorByDefault), }); await replaceApprovedSessionLocalReadPaths([ currentRecordingSession!.videoPath, diff --git a/electron/ipc/register/recording.ts b/electron/ipc/register/recording.ts index 0e491b80..40a8d2f6 100644 --- a/electron/ipc/register/recording.ts +++ b/electron/ipc/register/recording.ts @@ -18,7 +18,7 @@ import { startWindowBoundsCapture, stopWindowBoundsCapture } from "../cursor/bou import { startInteractionCapture, stopInteractionCapture } from "../cursor/interaction"; import { startNativeCursorMonitor, stopNativeCursorMonitor } from "../cursor/monitor"; import { - clamp, + normalizeCursorTelemetrySamples, pauseCursorCapture, resumeCursorCapture, resetCursorCaptureClock, @@ -26,6 +26,7 @@ import { snapshotCursorTelemetryForPersistence, startCursorSampling, stopCursorCapture, + writeCursorTelemetry, } from "../cursor/telemetry"; import { getFfmpegBinaryPath } from "../ffmpeg/binary"; import { @@ -1312,23 +1313,15 @@ export function registerRecordingHandlers( } }); - ipcMain.handle("pause-cursor-capture", (_event, boundaryMs?: number) => { - const timestamp = - typeof boundaryMs === "number" && Number.isFinite(boundaryMs) - ? boundaryMs - : Date.now(); - sampleCursorPoint(timestamp); - pauseCursorCapture(timestamp); + ipcMain.handle("pause-cursor-capture", () => { + sampleCursorPoint(); + pauseCursorCapture(Date.now()); return { success: true }; }); - ipcMain.handle("resume-cursor-capture", (_event, boundaryMs?: number) => { - const timestamp = - typeof boundaryMs === "number" && Number.isFinite(boundaryMs) - ? boundaryMs - : Date.now(); - resumeCursorCapture(timestamp); - sampleCursorPoint(timestamp); + ipcMain.handle("resume-cursor-capture", () => { + resumeCursorCapture(Date.now()); + sampleCursorPoint(); return { success: true }; }); @@ -1342,53 +1335,7 @@ export function registerRecordingHandlers( try { const content = await fs.readFile(telemetryPath, "utf-8"); const parsed = JSON.parse(content); - const rawSamples = Array.isArray(parsed) - ? parsed - : Array.isArray(parsed?.samples) - ? parsed.samples - : []; - - const samples: CursorTelemetryPoint[] = rawSamples - .filter((sample: unknown) => Boolean(sample && typeof sample === "object")) - .map((sample: unknown) => { - const point = sample as Partial; - return { - timeMs: - typeof point.timeMs === "number" && Number.isFinite(point.timeMs) - ? Math.max(0, point.timeMs) - : 0, - cx: - typeof point.cx === "number" && Number.isFinite(point.cx) - ? clamp(point.cx, 0, 1) - : 0.5, - cy: - typeof point.cy === "number" && Number.isFinite(point.cy) - ? clamp(point.cy, 0, 1) - : 0.5, - interactionType: - point.interactionType === "click" || - point.interactionType === "double-click" || - point.interactionType === "right-click" || - point.interactionType === "middle-click" || - point.interactionType === "move" || - point.interactionType === "mouseup" - ? point.interactionType - : undefined, - cursorType: - point.cursorType === "arrow" || - point.cursorType === "text" || - point.cursorType === "pointer" || - point.cursorType === "crosshair" || - point.cursorType === "open-hand" || - point.cursorType === "closed-hand" || - point.cursorType === "resize-ew" || - point.cursorType === "resize-ns" || - point.cursorType === "not-allowed" - ? point.cursorType - : undefined, - }; - }) - .sort((a: CursorTelemetryPoint, b: CursorTelemetryPoint) => a.timeMs - b.timeMs); + const samples = normalizeCursorTelemetrySamples(parsed); return { success: true, samples }; } catch (error) { @@ -1405,4 +1352,32 @@ export function registerRecordingHandlers( }; } }); + + ipcMain.handle( + "set-cursor-telemetry", + async (_, videoPath: string | undefined, samples: CursorTelemetryPoint[]) => { + const targetVideoPath = normalizeVideoSourcePath(videoPath ?? currentVideoPath); + if (!targetVideoPath) { + return { + success: false, + samples: [], + message: "No video path available for cursor telemetry", + error: "Missing video path", + }; + } + + try { + const normalizedSamples = await writeCursorTelemetry(targetVideoPath, samples); + return { success: true, samples: normalizedSamples }; + } catch (error) { + console.error("Failed to save cursor telemetry:", error); + return { + success: false, + samples: [], + message: "Failed to save cursor telemetry", + error: String(error), + }; + } + }, + ); } diff --git a/electron/ipc/types.ts b/electron/ipc/types.ts index 7c7de9c1..58f5425b 100644 --- a/electron/ipc/types.ts +++ b/electron/ipc/types.ts @@ -47,6 +47,7 @@ export type RecordingSessionData = { videoPath: string; webcamPath?: string | null; timeOffsetMs?: number; + hideOverlayCursorByDefault?: boolean; }; export type PauseSegment = { diff --git a/electron/preload.ts b/electron/preload.ts index c9e464f2..4d988b35 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -202,7 +202,7 @@ contextBridge.exposeInMainWorld("electronAPI", { ) => { return ipcRenderer.invoke("mux-exported-video-audio", videoData, options) as Promise<{ success: boolean; - data?: Uint8Array; + tempPath?: string; error?: string; metrics?: NativeVideoAudioMuxMetrics; }>; @@ -293,11 +293,11 @@ contextBridge.exposeInMainWorld("electronAPI", { resumeNativeScreenRecording: () => { return ipcRenderer.invoke("resume-native-screen-recording"); }, - pauseCursorCapture: (boundaryMs?: number) => { - return ipcRenderer.invoke("pause-cursor-capture", boundaryMs); + pauseCursorCapture: () => { + return ipcRenderer.invoke("pause-cursor-capture"); }, - resumeCursorCapture: (boundaryMs?: number) => { - return ipcRenderer.invoke("resume-cursor-capture", boundaryMs); + resumeCursorCapture: () => { + return ipcRenderer.invoke("resume-cursor-capture"); }, startFfmpegRecording: (source: ProcessedDesktopSource) => { return ipcRenderer.invoke("start-ffmpeg-recording", source); @@ -327,6 +327,9 @@ contextBridge.exposeInMainWorld("electronAPI", { getCursorTelemetry: (videoPath?: string) => { return ipcRenderer.invoke("get-cursor-telemetry", videoPath); }, + setCursorTelemetry: (videoPath: string | undefined, samples: CursorTelemetryPoint[]) => { + return ipcRenderer.invoke("set-cursor-telemetry", videoPath, samples); + }, getSystemCursorAssets: () => { return ipcRenderer.invoke("get-system-cursor-assets"); }, @@ -436,14 +439,24 @@ contextBridge.exposeInMainWorld("electronAPI", { }) => { return ipcRenderer.invoke("generate-auto-captions", options); }, - setCurrentVideoPath: (path: string, options?: { preserveProjectPath?: boolean }) => { + setCurrentVideoPath: ( + path: string, + options?: { + preserveProjectPath?: boolean; + hideOverlayCursorByDefault?: boolean; + }, + ) => { return ipcRenderer.invoke("set-current-video-path", path, options); }, - setCurrentRecordingSession: (session: { - videoPath: string; - webcamPath?: string | null; - timeOffsetMs?: number; - }, options?: { preserveProjectPath?: boolean }) => { + setCurrentRecordingSession: ( + session: { + videoPath: string; + webcamPath?: string | null; + timeOffsetMs?: number; + hideOverlayCursorByDefault?: boolean; + }, + options?: { preserveProjectPath?: boolean }, + ) => { return ipcRenderer.invoke("set-current-recording-session", session, options); }, getCurrentRecordingSession: () => { @@ -603,6 +616,9 @@ contextBridge.exposeInMainWorld("electronAPI", { getPlatform: () => { return ipcRenderer.invoke("get-platform"); }, + getLinuxWindowSystem: () => { + return ipcRenderer.invoke("get-linux-window-system"); + }, revealInFolder: (filePath: string) => { return ipcRenderer.invoke("reveal-in-folder", filePath); }, diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 694a51bc..7256235f 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -1,4 +1,11 @@ -import { Palette, Trash as Trash2, UploadSimple as Upload, X } from "@phosphor-icons/react"; +import { + CursorClick, + Palette, + PresentationChart, + Trash as Trash2, + UploadSimple as Upload, + X, +} from "@phosphor-icons/react"; import { AnimatePresence, LayoutGroup, motion } from "motion/react"; import { useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; @@ -19,6 +26,14 @@ import { getRenderableVideoUrl, getWallpaperThumbnailUrl, } from "@/lib/assetPath"; +import { + TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT, + TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION, + TEMPORAL_MOTION_BLUR_MAX_SAMPLE_COUNT, + TEMPORAL_MOTION_BLUR_MAX_SHUTTER_FRACTION, + TEMPORAL_MOTION_BLUR_MIN_SAMPLE_COUNT, + TEMPORAL_MOTION_BLUR_MIN_SHUTTER_FRACTION, +} from "@/lib/exporter/temporalMotionBlur"; import type { ExtensionSettingField } from "@/lib/extensions"; import { extensionHost, type FrameInstance } from "@/lib/extensions"; import { cn } from "@/lib/utils"; @@ -34,6 +49,7 @@ import { useI18n, useScopedT } from "../../contexts/I18nContext"; import type { AppLocale } from "../../i18n/config"; import { SUPPORTED_LOCALES } from "../../i18n/config"; import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel"; +import { CURSOR_MOTION_PRESETS, type CursorMotionPresetId } from "./cursorMotionPresets"; import { loadEditorPreferences, saveEditorPreferences } from "./editorPreferences"; import { SliderControl } from "./SliderControl"; import { KeyboardShortcutsDialog } from "./TutorialHelp"; @@ -48,7 +64,6 @@ import type { EditorEffectSection, FigureData, Padding, - PlaybackSpeed, WebcamOverlaySettings, WebcamPositionPreset, ZoomDepth, @@ -62,7 +77,6 @@ import { DEFAULT_CURSOR_CLICK_BOUNCE_DURATION, DEFAULT_CURSOR_MOTION_BLUR, DEFAULT_CURSOR_SIZE, - DEFAULT_CURSOR_SMOOTHING, DEFAULT_CURSOR_STYLE, DEFAULT_CURSOR_SWAY, DEFAULT_PADDING, @@ -74,8 +88,8 @@ import { DEFAULT_WEBCAM_REACT_TO_ZOOM, DEFAULT_WEBCAM_SHADOW, DEFAULT_WEBCAM_SIZE, - DEFAULT_ZOOM_MOTION_BLUR, - SPEED_OPTIONS, + DEFAULT_ZOOM_IN_DURATION_MS, + DEFAULT_ZOOM_OUT_DURATION_MS, } from "./types"; import { fromCursorSwaySliderValue, toCursorSwaySliderValue } from "./videoPlayback/cursorSway"; import { isZeroPadding } from "./videoPlayback/layoutUtils"; @@ -379,6 +393,66 @@ function ExtensionSettingsSection({ ); } +const MOTION_PRESET_ORDER: CursorMotionPresetId[] = ["focused", "smooth"]; + +function MotionPresetCards({ + title, + activePresetId, + onApply, + tSettings, +}: { + title: string; + activePresetId: CursorMotionPresetId | null; + onApply: (presetId: CursorMotionPresetId) => void; + tSettings: (key: string, fallback?: string) => string; +}) { + return ( +
+
{title}
+
+ {MOTION_PRESET_ORDER.map((presetId) => { + const Icon = presetId === "focused" ? CursorClick : PresentationChart; + const isActive = activePresetId === presetId; + + return ( + + ); + })} +
+
+ ); +} + interface SettingsPanelProps { panelMode?: "editor" | "background"; activeEffectSection?: EditorEffectSection; @@ -390,8 +464,6 @@ interface SettingsPanelProps { selectedZoomMode?: ZoomMode | null; onZoomModeChange?: (mode: ZoomMode) => void; onZoomDelete?: (id: string) => void; - selectedTrimId?: string | null; - onTrimDelete?: (id: string) => void; selectedClipId?: string | null; selectedClipSpeed?: number | null; selectedClipMuted?: boolean | null; @@ -406,8 +478,12 @@ interface SettingsPanelProps { onShadowChange?: (intensity: number) => void; backgroundBlur?: number; onBackgroundBlurChange?: (amount: number) => void; - zoomMotionBlur?: number; - onZoomMotionBlurChange?: (amount: number) => void; + zoomTemporalMotionBlur?: number; + onZoomTemporalMotionBlurChange?: (amount: number) => void; + zoomMotionBlurSampleCount?: number | null; + onZoomMotionBlurSampleCountChange?: (count: number | null) => void; + zoomMotionBlurShutterFraction?: number | null; + onZoomMotionBlurShutterFractionChange?: (fraction: number | null) => void; connectZooms?: boolean; onConnectZoomsChange?: (enabled: boolean) => void; autoApplyFreshRecordingAutoZooms?: boolean; @@ -438,8 +514,12 @@ interface SettingsPanelProps { onCursorSizeChange?: (size: number) => void; cursorSmoothing?: number; onCursorSmoothingChange?: (smoothing: number) => void; - zoomSmoothness?: number; - onZoomSmoothnessChange?: (smoothness: number) => void; + cursorSpringStiffnessMultiplier?: number; + onCursorSpringStiffnessMultiplierChange?: (multiplier: number) => void; + cursorSpringDampingMultiplier?: number; + onCursorSpringDampingMultiplierChange?: (multiplier: number) => void; + cursorSpringMassMultiplier?: number; + onCursorSpringMassMultiplierChange?: (multiplier: number) => void; zoomClassicMode?: boolean; onZoomClassicModeChange?: (enabled: boolean) => void; cursorMotionBlur?: number; @@ -490,10 +570,6 @@ interface SettingsPanelProps { onClearAutoCaptions?: () => void; onDownloadWhisperSmallModel?: () => void; onDeleteWhisperSmallModel?: () => void; - selectedSpeedId?: string | null; - selectedSpeedValue?: PlaybackSpeed | null; - onSpeedChange?: (speed: PlaybackSpeed) => void; - onSpeedDelete?: (id: string) => void; } const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [ @@ -771,8 +847,6 @@ export function SettingsPanel({ selectedZoomMode, onZoomModeChange, onZoomDelete, - selectedTrimId, - onTrimDelete, selectedClipId, selectedClipSpeed, selectedClipMuted, @@ -787,12 +861,20 @@ export function SettingsPanel({ onShadowChange, backgroundBlur = 0, onBackgroundBlurChange, - zoomMotionBlur = 0, - onZoomMotionBlurChange, + zoomTemporalMotionBlur = 0, + onZoomTemporalMotionBlurChange, + zoomMotionBlurSampleCount = TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT, + onZoomMotionBlurSampleCountChange, + zoomMotionBlurShutterFraction = TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION, + onZoomMotionBlurShutterFractionChange, connectZooms = true, onConnectZoomsChange, autoApplyFreshRecordingAutoZooms = true, onAutoApplyFreshRecordingAutoZoomsChange, + zoomInDurationMs = DEFAULT_ZOOM_IN_DURATION_MS, + onZoomInDurationMsChange, + zoomOutDurationMs = DEFAULT_ZOOM_OUT_DURATION_MS, + onZoomOutDurationMsChange, showCursor = false, onShowCursorChange, loopCursor = false, @@ -803,8 +885,12 @@ export function SettingsPanel({ onCursorSizeChange, cursorSmoothing = 2, onCursorSmoothingChange, - zoomSmoothness = 0.5, - onZoomSmoothnessChange, + cursorSpringStiffnessMultiplier = 1, + onCursorSpringStiffnessMultiplierChange, + cursorSpringDampingMultiplier = 1, + onCursorSpringDampingMultiplierChange, + cursorSpringMassMultiplier = 1, + onCursorSpringMassMultiplierChange, zoomClassicMode = false, onZoomClassicModeChange, cursorMotionBlur = DEFAULT_CURSOR_MOTION_BLUR, @@ -853,10 +939,6 @@ export function SettingsPanel({ onClearAutoCaptions, onDownloadWhisperSmallModel, onDeleteWhisperSmallModel, - selectedSpeedId, - selectedSpeedValue, - onSpeedChange, - onSpeedDelete, }: SettingsPanelProps) { const tSettings = useScopedT("settings"); const { locale, setLocale, t } = useI18n(); @@ -1060,6 +1142,7 @@ export function SettingsPanel({ () => ({ ...builtInCursorPreviewUrls, ...extensionCursorPreviewUrls }), [builtInCursorPreviewUrls, extensionCursorPreviewUrls], ); + const showDevMotionControls = import.meta.env.DEV; const cursorStyleOptions = useMemo( () => [ ...BUILTIN_CURSOR_STYLE_OPTIONS, @@ -1338,12 +1421,6 @@ export function SettingsPanel({ ); - const handleTrimDeleteClick = () => { - if (selectedTrimId && onTrimDelete) { - onTrimDelete(selectedTrimId); - } - }; - const crop = cropRegion ?? { x: 0, y: 0, @@ -1390,8 +1467,13 @@ export function SettingsPanel({ }; const resetZoomSection = () => { - onZoomSmoothnessChange?.(0.5); - onZoomMotionBlurChange?.(initialEditorPreferences.zoomMotionBlur); + onZoomTemporalMotionBlurChange?.(initialEditorPreferences.zoomTemporalMotionBlur); + onZoomMotionBlurSampleCountChange?.(initialEditorPreferences.zoomMotionBlurSampleCount); + onZoomMotionBlurShutterFractionChange?.( + initialEditorPreferences.zoomMotionBlurShutterFraction, + ); + onZoomInDurationMsChange?.(initialEditorPreferences.zoomInDurationMs); + onZoomOutDurationMsChange?.(initialEditorPreferences.zoomOutDurationMs); onZoomClassicModeChange?.(false); }; @@ -1401,17 +1483,65 @@ export function SettingsPanel({ onCursorStyleChange?.(initialEditorPreferences.cursorStyle); onCursorSizeChange?.(initialEditorPreferences.cursorSize); onCursorSmoothingChange?.(initialEditorPreferences.cursorSmoothing); + onCursorSpringStiffnessMultiplierChange?.( + initialEditorPreferences.cursorSpringStiffnessMultiplier, + ); + onCursorSpringDampingMultiplierChange?.( + initialEditorPreferences.cursorSpringDampingMultiplier, + ); + onCursorSpringMassMultiplierChange?.(initialEditorPreferences.cursorSpringMassMultiplier); onCursorMotionBlurChange?.(initialEditorPreferences.cursorMotionBlur); onCursorClickBounceChange?.(initialEditorPreferences.cursorClickBounce); onCursorClickBounceDurationChange?.(DEFAULT_CURSOR_CLICK_BOUNCE_DURATION); onCursorSwayChange?.(initialEditorPreferences.cursorSway); }; + const activeMotionPresetId = useMemo(() => { + return ( + MOTION_PRESET_ORDER.find((presetId) => { + const preset = CURSOR_MOTION_PRESETS[presetId]; + return ( + preset.zoomInDurationMs === zoomInDurationMs && + preset.zoomOutDurationMs === zoomOutDurationMs && + preset.cursorSize === cursorSize && + preset.cursorSmoothing === cursorSmoothing && + preset.cursorSpringStiffnessMultiplier === cursorSpringStiffnessMultiplier && + preset.cursorSpringDampingMultiplier === cursorSpringDampingMultiplier && + preset.cursorSpringMassMultiplier === cursorSpringMassMultiplier && + preset.cursorMotionBlur === cursorMotionBlur && + preset.cursorClickBounce === cursorClickBounce && + preset.cursorClickBounceDuration === cursorClickBounceDuration + ); + }) ?? null + ); + }, [ + cursorClickBounce, + cursorClickBounceDuration, + cursorMotionBlur, + cursorSize, + cursorSmoothing, + cursorSpringDampingMultiplier, + cursorSpringMassMultiplier, + cursorSpringStiffnessMultiplier, + zoomInDurationMs, + zoomOutDurationMs, + ]); + + const applyMotionPreset = (presetId: CursorMotionPresetId) => { + const preset = CURSOR_MOTION_PRESETS[presetId]; + onZoomInDurationMsChange?.(preset.zoomInDurationMs); + onZoomOutDurationMsChange?.(preset.zoomOutDurationMs); + onCursorSizeChange?.(preset.cursorSize); + onCursorSmoothingChange?.(preset.cursorSmoothing); + onCursorSpringStiffnessMultiplierChange?.(preset.cursorSpringStiffnessMultiplier); + onCursorSpringDampingMultiplierChange?.(preset.cursorSpringDampingMultiplier); + onCursorSpringMassMultiplierChange?.(preset.cursorSpringMassMultiplier); + onCursorMotionBlurChange?.(preset.cursorMotionBlur); + onCursorClickBounceChange?.(preset.cursorClickBounce); + onCursorClickBounceDurationChange?.(preset.cursorClickBounceDuration); + }; + const resetFrameSection = () => { - onShadowChange?.(initialEditorPreferences.shadowIntensity); - onBorderRadiusChange?.(initialEditorPreferences.borderRadius); - onPaddingChange?.(DEFAULT_PADDING); - onFrameChange?.(null); onAspectRatioChange?.(initialEditorPreferences.aspectRatio); removeBackgroundStateRef.current = null; }; @@ -2444,6 +2574,15 @@ export function SettingsPanel({ +
+ +
+
{t("editor.keyboardShortcuts.title")} @@ -2564,29 +2703,87 @@ export function SettingsPanel({ /> {!zoomClassicMode && ( - onZoomSmoothnessChange?.(v)} - formatValue={(v) => (v <= 0 ? tSettings("effects.off") : v.toFixed(2))} - parseInput={(text) => parseFloat(text)} - /> +
+ {tSettings( + "effects.motionPresetsZoomHint", + "Zoom motion presets are available in Settings.", + )} +
+ )} + {showDevMotionControls ? ( +
+
+
+ {tSettings("effects.exportBlurDebug", "Export Blur Debug")} +
+
+ {tSettings( + "effects.exportBlurDebugHint", + "Development-only temporal blur tuning for export and preview parity checks.", + )} +
+
+ onZoomTemporalMotionBlurChange?.(value)} + formatValue={(value) => `${value.toFixed(2)}×`} + parseInput={(text) => parseFloat(text.replace(/×$/, ""))} + /> + + onZoomMotionBlurSampleCountChange?.(Math.round(value)) + } + formatValue={(value) => `${Math.round(value)} samples`} + parseInput={(text) => parseFloat(text.replace(/samples?$/i, "").trim())} + /> + onZoomMotionBlurShutterFractionChange?.(value)} + formatValue={(value) => `${Math.round(value * 100)}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, "")) / 100} + /> +
+ ) : ( +
+
+ {tSettings( + "effects.exportBlurLocked", + "Export blur is fixed for this build.", + )} +
+
+ {`${TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT} samples · ${Math.round(TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION * 100)}% shutter`} +
+
)} - onZoomMotionBlurChange?.(v)} - formatValue={(v) => `${v.toFixed(2)}×`} - parseInput={(text) => parseFloat(text.replace(/×$/, ""))} - /> {selectedZoomId && (
@@ -3134,70 +3390,9 @@ export function SettingsPanel({
- {selectedTrimId && ( -
- -
- )} - - {selectedSpeedId && ( -
-
- - {tSettings("speed.playbackSpeed")} - - {selectedSpeedValue && ( - - {SPEED_OPTIONS.find((o) => o.speed === selectedSpeedValue) - ?.label ?? `${selectedSpeedValue}×`} - - )} -
-
- {SPEED_OPTIONS.map((option) => { - const isActive = selectedSpeedValue === option.speed; - return ( - - ); - })} -
- -
- )} - {selectedAudioId && (
diff --git a/src/components/video-editor/SliderControl.tsx b/src/components/video-editor/SliderControl.tsx index 2efafa2d..d9db8998 100644 --- a/src/components/video-editor/SliderControl.tsx +++ b/src/components/video-editor/SliderControl.tsx @@ -1,3 +1,5 @@ +import type { PointerEvent as ReactPointerEvent } from "react"; +import { useCallback, useRef } from "react"; import { cn } from "@/lib/utils"; interface SliderControlProps { @@ -13,6 +15,18 @@ interface SliderControlProps { accentColor?: "purple" | "blue"; } +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +function quantizeToStep(value: number, min: number, step: number) { + if (!(step > 0)) { + return value; + } + + return min + Math.round((value - min) / step) * step; +} + export function SliderControl({ label, value, @@ -25,16 +39,94 @@ export function SliderControl({ parseInput: _parseInput, accentColor = "blue", }: SliderControlProps) { + const rootRef = useRef(null); const pct = Math.min(100, Math.max(0, ((value - min) / (max - min || 1)) * 100)); const dividerClass = accentColor === "purple" ? "bg-foreground/95 shadow-[0_0_10px_rgba(139,92,246,0.28)]" : "bg-foreground/95 shadow-[0_0_10px_rgba(37,99,235,0.28)]"; + const setValueFromClientX = useCallback( + (clientX: number) => { + const root = rootRef.current; + if (!root) { + return; + } + + const bounds = root.getBoundingClientRect(); + if (!(bounds.width > 0)) { + return; + } + + const normalized = clamp((clientX - bounds.left) / bounds.width, 0, 1); + const rawValue = min + normalized * (max - min); + const nextValue = clamp(quantizeToStep(rawValue, min, step), min, max); + onChange(Number(nextValue.toFixed(6))); + }, + [max, min, onChange, step], + ); + + const handlePointerDown = useCallback( + (event: ReactPointerEvent) => { + event.preventDefault(); + const pointerId = event.pointerId; + const target = event.currentTarget; + + target.setPointerCapture(pointerId); + setValueFromClientX(event.clientX); + + const handlePointerMove = (moveEvent: PointerEvent) => { + if (moveEvent.pointerId !== pointerId) { + return; + } + + setValueFromClientX(moveEvent.clientX); + }; + + const finishPointer = (finishEvent: PointerEvent) => { + if (finishEvent.pointerId !== pointerId) { + return; + } + + target.releasePointerCapture(pointerId); + target.removeEventListener("pointermove", handlePointerMove); + target.removeEventListener("pointerup", finishPointer); + target.removeEventListener("pointercancel", finishPointer); + }; + + target.addEventListener("pointermove", handlePointerMove); + target.addEventListener("pointerup", finishPointer); + target.addEventListener("pointercancel", finishPointer); + }, + [setValueFromClientX], + ); + return ( -
+
{ + if (event.key === "ArrowLeft" || event.key === "ArrowDown") { + event.preventDefault(); + onChange(clamp(quantizeToStep(value - step, min, step), min, max)); + } + + if (event.key === "ArrowRight" || event.key === "ArrowUp") { + event.preventDefault(); + onChange(clamp(quantizeToStep(value + step, min, step), min, max)); + } + }} + className="relative flex h-10 w-full select-none items-center overflow-hidden rounded-xl bg-editor-bg/80 px-1.5 outline-none focus-visible:ring-1 focus-visible:ring-[#2563EB]/40" + >
0 ? `max(calc(${pct}% - 6px), 2.1rem)` : 0, }} @@ -52,17 +144,6 @@ export function SliderControl({ {formatValue(value)} - onChange(Number(e.target.value))} - aria-label={label} - aria-valuetext={formatValue(value)} - className="absolute inset-0 h-full w-full cursor-ew-resize opacity-0" - />
); } diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 6483ca13..ba757a5c 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -105,7 +105,6 @@ const PhSettings = (props: { className?: string; weight?: "fill" | "regular" }) import { extensionHost } from "@/lib/extensions"; import { resolveAutoCaptionSourcePath } from "./autoCaptionSource"; import { CropControl } from "./CropControl"; -import { type CaptionEditTarget, updateCaptionCuesForEditedTarget } from "./captionEditing"; import { ExportSettingsMenu } from "./ExportSettingsMenu"; import ExtensionManager from "./ExtensionManager"; import { @@ -160,7 +159,6 @@ import { DEFAULT_CROP_REGION, DEFAULT_CURSOR_STYLE, DEFAULT_FIGURE_DATA, - DEFAULT_PLAYBACK_SPEED, DEFAULT_WEBCAM_OVERLAY, DEFAULT_WEBCAM_TIME_OFFSET_MS, DEFAULT_ZOOM_IN_DURATION_MS, @@ -173,9 +171,11 @@ import { type FigureData, getClipSourceEndMs, type Padding, - type PlaybackSpeed, + mapSourceTimeToTimelineTime as resolveSourceTimeToTimelineTime, + mapTimelineTimeToSourceTime as resolveTimelineTimeToSourceTime, type SpeedRegion, type TrimRegion, + trimsToClips, type WebcamOverlaySettings, type ZoomDepth, type ZoomFocus, @@ -197,9 +197,7 @@ type EditorHistorySnapshot = { audioRegions: AudioRegion[]; autoCaptions: CaptionCue[]; selectedZoomId: string | null; - selectedTrimId: string | null; selectedClipId: string | null; - selectedSpeedId: string | null; selectedAnnotationId: string | null; selectedAudioId: string | null; }; @@ -238,6 +236,57 @@ type SmokeExportConfig = { fps?: ExportMp4FrameRate; }; +const EXPORT_BLOB_STREAM_CHUNK_BYTES = 16 * 1024 * 1024; + +async function streamExportBlobToTempFile(blob: Blob, extension: string): Promise { + if ( + typeof window === "undefined" || + !window.electronAPI?.openExportStream || + !window.electronAPI?.writeExportStreamChunk || + !window.electronAPI?.closeExportStream + ) { + return null; + } + + const openResult = await window.electronAPI.openExportStream({ extension }); + if (!openResult.success || !openResult.streamId || !openResult.tempPath) { + throw new Error(openResult.error || "Failed to open export stream"); + } + + const { streamId } = openResult; + let position = 0; + + try { + while (position < blob.size) { + const chunk = blob.slice(position, position + EXPORT_BLOB_STREAM_CHUNK_BYTES); + const chunkBuffer = await chunk.arrayBuffer(); + const writeResult = await window.electronAPI.writeExportStreamChunk( + streamId, + position, + new Uint8Array(chunkBuffer), + ); + if (!writeResult.success) { + throw new Error(writeResult.error || "Failed to write export stream chunk"); + } + position += chunkBuffer.byteLength; + } + + const closeResult = await window.electronAPI.closeExportStream(streamId); + if (!closeResult.success || !closeResult.tempPath) { + throw new Error(closeResult.error || "Failed to close export stream"); + } + + return closeResult.tempPath; + } catch (error) { + try { + await window.electronAPI.closeExportStream(streamId, { abort: true }); + } catch { + // Best-effort cleanup; preserve the original error below. + } + throw error; + } +} + type SaveProjectOptions = { silent?: boolean; remountPreviewAfterSave?: boolean; @@ -280,7 +329,7 @@ function getEncodingModeBitrateMultiplier(encodingMode: ExportEncodingMode): num return 0.9; case "balanced": default: - return 0.5; + return 0.7; } } @@ -548,6 +597,15 @@ export default function VideoEditor() { ); const [backgroundBlur, setBackgroundBlur] = useState(initialEditorPreferences.backgroundBlur); const [zoomMotionBlur, setZoomMotionBlur] = useState(initialEditorPreferences.zoomMotionBlur); + const [zoomTemporalMotionBlur, setZoomTemporalMotionBlur] = useState( + initialEditorPreferences.zoomTemporalMotionBlur, + ); + const [zoomMotionBlurSampleCount, setZoomMotionBlurSampleCount] = useState( + initialEditorPreferences.zoomMotionBlurSampleCount, + ); + const [zoomMotionBlurShutterFraction, setZoomMotionBlurShutterFraction] = useState< + number | null + >(initialEditorPreferences.zoomMotionBlurShutterFraction); const [autoApplyFreshRecordingAutoZooms, setAutoApplyFreshRecordingAutoZooms] = useState( initialEditorPreferences.autoApplyFreshRecordingAutoZooms, ); @@ -585,6 +643,18 @@ export default function VideoEditor() { const [cursorSmoothing, setCursorSmoothing] = useState( initialEditorPreferences.cursorSmoothing, ); + const [cursorSpringStiffnessMultiplier, setCursorSpringStiffnessMultiplier] = useState( + initialEditorPreferences.cursorSpringStiffnessMultiplier, + ); + const [cursorSpringDampingMultiplier, setCursorSpringDampingMultiplier] = useState( + initialEditorPreferences.cursorSpringDampingMultiplier, + ); + const [cursorSpringMassMultiplier, setCursorSpringMassMultiplier] = useState( + initialEditorPreferences.cursorSpringMassMultiplier, + ); + const [sessionShowCursorOverride, setSessionShowCursorOverride] = useState( + null, + ); const [zoomSmoothness, setZoomSmoothness] = useState(0.5); const [zoomClassicMode, setZoomClassicMode] = useState(false); const [cursorMotionBlur, setCursorMotionBlur] = useState( @@ -615,11 +685,9 @@ export default function VideoEditor() { const [cursorTelemetrySourcePath, setCursorTelemetrySourcePath] = useState(null); const [selectedZoomId, setSelectedZoomId] = useState(null); const [trimRegions, setTrimRegions] = useState([]); - const [selectedTrimId, setSelectedTrimId] = useState(null); const [clipRegions, setClipRegions] = useState([]); const [selectedClipId, setSelectedClipId] = useState(null); const [speedRegions, setSpeedRegions] = useState([]); - const [selectedSpeedId, setSelectedSpeedId] = useState(null); const [annotationRegions, setAnnotationRegions] = useState([]); const [selectedAnnotationId, setSelectedAnnotationId] = useState(null); const [audioRegions, setAudioRegions] = useState([]); @@ -650,6 +718,7 @@ export default function VideoEditor() { const [sourceAudioFallbackPaths, setSourceAudioFallbackPaths] = useState([]); const [sourceAudioFallbackStartDelayMsByPath, setSourceAudioFallbackStartDelayMsByPath] = useState>({}); + const effectiveShowCursor = sessionShowCursorOverride ?? showCursor; const [aspectRatio, setAspectRatio] = useState( initialEditorPreferences.aspectRatio, ); @@ -697,9 +766,7 @@ export default function VideoEditor() { const projectBrowserFallbackTriggerRef = useRef(null); const projectNameInputRef = useRef(null); const nextZoomIdRef = useRef(1); - const nextTrimIdRef = useRef(1); const nextClipIdRef = useRef(1); - const nextSpeedIdRef = useRef(1); const nextAudioIdRef = useRef(1); const { shortcuts, isMac } = useShortcuts(); @@ -771,6 +838,9 @@ export default function VideoEditor() { shadowIntensity, backgroundBlur, zoomMotionBlur, + zoomTemporalMotionBlur, + zoomMotionBlurSampleCount, + zoomMotionBlurShutterFraction, connectZooms, zoomInDurationMs, zoomInOverlapMs, @@ -785,6 +855,9 @@ export default function VideoEditor() { cursorStyle, cursorSize, cursorSmoothing, + cursorSpringStiffnessMultiplier, + cursorSpringDampingMultiplier, + cursorSpringMassMultiplier, cursorMotionBlur, cursorClickBounce, cursorClickBounceDuration, @@ -812,6 +885,9 @@ export default function VideoEditor() { shadowIntensity, backgroundBlur, zoomMotionBlur, + zoomTemporalMotionBlur, + zoomMotionBlurSampleCount, + zoomMotionBlurShutterFraction, connectZooms, zoomInDurationMs, zoomInOverlapMs, @@ -826,6 +902,9 @@ export default function VideoEditor() { cursorStyle, cursorSize, cursorSmoothing, + cursorSpringStiffnessMultiplier, + cursorSpringDampingMultiplier, + cursorSpringMassMultiplier, cursorMotionBlur, cursorClickBounce, cursorClickBounceDuration, @@ -894,6 +973,9 @@ export default function VideoEditor() { setShadowIntensity(snapshot.shadowIntensity); setBackgroundBlur(snapshot.backgroundBlur); setZoomMotionBlur(snapshot.zoomMotionBlur); + setZoomTemporalMotionBlur(snapshot.zoomTemporalMotionBlur); + setZoomMotionBlurSampleCount(snapshot.zoomMotionBlurSampleCount); + setZoomMotionBlurShutterFraction(snapshot.zoomMotionBlurShutterFraction); setConnectZooms(snapshot.connectZooms); setZoomInDurationMs(snapshot.zoomInDurationMs); setZoomInOverlapMs(snapshot.zoomInOverlapMs); @@ -908,6 +990,9 @@ export default function VideoEditor() { setCursorStyle(snapshot.cursorStyle); setCursorSize(snapshot.cursorSize); setCursorSmoothing(snapshot.cursorSmoothing); + setCursorSpringStiffnessMultiplier(snapshot.cursorSpringStiffnessMultiplier); + setCursorSpringDampingMultiplier(snapshot.cursorSpringDampingMultiplier); + setCursorSpringMassMultiplier(snapshot.cursorSpringMassMultiplier); setCursorMotionBlur(snapshot.cursorMotionBlur); setCursorClickBounce(snapshot.cursorClickBounce); setCursorClickBounceDuration(snapshot.cursorClickBounceDuration); @@ -979,7 +1064,7 @@ export default function VideoEditor() { updatedAt: timestamp, snapshot, }; - const nextPresets = [nextPreset, ...editorPresets]; + const nextPresets: EditorPreset[] = [nextPreset, ...editorPresets]; if (!saveEditorPresets(nextPresets)) { toast.error( @@ -1119,6 +1204,9 @@ export default function VideoEditor() { shadowIntensity, backgroundBlur, zoomMotionBlur, + zoomTemporalMotionBlur, + zoomMotionBlurSampleCount, + zoomMotionBlurShutterFraction, connectZooms, zoomInDurationMs, zoomInOverlapMs, @@ -1164,10 +1252,13 @@ export default function VideoEditor() { previewWidth, previewHeight, cursorTelemetry, - showCursor, + showCursor: effectiveShowCursor, cursorStyle, cursorSize, cursorSmoothing, + cursorSpringStiffnessMultiplier, + cursorSpringDampingMultiplier, + cursorSpringMassMultiplier, zoomSmoothness, zoomClassicMode, cursorMotionBlur, @@ -1243,6 +1334,9 @@ export default function VideoEditor() { cursorMotionBlur, cursorSize, cursorSmoothing, + cursorSpringDampingMultiplier, + cursorSpringMassMultiplier, + cursorSpringStiffnessMultiplier, zoomSmoothness, cursorStyle, cursorSway, @@ -1251,7 +1345,7 @@ export default function VideoEditor() { padding, resolvedWebcamVideoUrl, shadowIntensity, - showCursor, + effectiveShowCursor, speedRegions, wallpaper, webcam, @@ -1259,6 +1353,9 @@ export default function VideoEditor() { zoomInEasing, zoomInOverlapMs, zoomMotionBlur, + zoomTemporalMotionBlur, + zoomMotionBlurSampleCount, + zoomMotionBlurShutterFraction, zoomOutDurationMs, zoomOutEasing, zoomRegions, @@ -1279,6 +1376,11 @@ export default function VideoEditor() { })); }, []); + const handleShowCursorChange = useCallback((nextShowCursor: boolean) => { + setSessionShowCursorOverride(null); + setShowCursor(nextShowCursor); + }, []); + const remountPreview = useCallback(() => { setIsPreviewReady(false); setPreviewVersion((version) => version + 1); @@ -1297,6 +1399,43 @@ export default function VideoEditor() { return run; }, []); + const saveBlobExport = useCallback( + async (blob: Blob, fileName: string, outputPath: string | null = null) => { + const extension = fileName.split(".").pop()?.toLowerCase() || "bin"; + + try { + const tempFilePath = await streamExportBlobToTempFile(blob, extension); + if (tempFilePath) { + return { + saveResult: await window.electronAPI.finalizeExportedVideo({ + tempPath: tempFilePath, + fileName, + outputPath, + }), + pendingSave: { + fileName, + tempFilePath, + } satisfies PendingExportSave, + }; + } + } catch (error) { + console.warn("[export] Falling back to in-memory blob save", error); + } + + const arrayBuffer = await blob.arrayBuffer(); + return { + saveResult: outputPath + ? await window.electronAPI.writeExportedVideoToPath(arrayBuffer, outputPath) + : await window.electronAPI.saveExportedVideo(arrayBuffer, fileName), + pendingSave: { + fileName, + arrayBuffer, + } satisfies PendingExportSave, + }; + }, + [], + ); + useEffect(() => { return () => { exporterRef.current?.cancel(); @@ -1519,6 +1658,9 @@ export default function VideoEditor() { shadowIntensity: number; backgroundBlur: number; zoomMotionBlur: number; + zoomTemporalMotionBlur: number; + zoomMotionBlurSampleCount: number | null; + zoomMotionBlurShutterFraction: number | null; connectZooms: boolean; zoomInDurationMs: number; zoomInOverlapMs: number; @@ -1533,6 +1675,9 @@ export default function VideoEditor() { cursorStyle: CursorStyle; cursorSize: number; cursorSmoothing: number; + cursorSpringStiffnessMultiplier: number; + cursorSpringDampingMultiplier: number; + cursorSpringMassMultiplier: number; zoomSmoothness: number; zoomClassicMode: boolean; cursorMotionBlur: number; @@ -1667,6 +1812,9 @@ export default function VideoEditor() { shadowIntensity, backgroundBlur, zoomMotionBlur, + zoomTemporalMotionBlur, + zoomMotionBlurSampleCount, + zoomMotionBlurShutterFraction, connectZooms, zoomInDurationMs, zoomInOverlapMs, @@ -1681,6 +1829,9 @@ export default function VideoEditor() { cursorStyle, cursorSize, cursorSmoothing, + cursorSpringStiffnessMultiplier, + cursorSpringDampingMultiplier, + cursorSpringMassMultiplier, zoomSmoothness, zoomClassicMode, cursorMotionBlur, @@ -1690,6 +1841,7 @@ export default function VideoEditor() { borderRadius, padding, frame, + cropRegion, webcam, zoomRegions, trimRegions, @@ -1716,6 +1868,9 @@ export default function VideoEditor() { shadowIntensity, backgroundBlur, zoomMotionBlur, + zoomTemporalMotionBlur, + zoomMotionBlurSampleCount, + zoomMotionBlurShutterFraction, connectZooms, zoomInDurationMs, zoomInOverlapMs, @@ -1730,6 +1885,9 @@ export default function VideoEditor() { cursorStyle, cursorSize, cursorSmoothing, + cursorSpringStiffnessMultiplier, + cursorSpringDampingMultiplier, + cursorSpringMassMultiplier, zoomSmoothness, zoomClassicMode, cursorMotionBlur, @@ -1738,6 +1896,7 @@ export default function VideoEditor() { cursorSway, borderRadius, padding, + cropRegion, webcam, zoomRegions, trimRegions, @@ -1770,9 +1929,7 @@ export default function VideoEditor() { audioRegions, autoCaptions, selectedZoomId, - selectedTrimId, selectedClipId, - selectedSpeedId, selectedAnnotationId, selectedAudioId, }; @@ -1784,9 +1941,7 @@ export default function VideoEditor() { audioRegions, autoCaptions, selectedZoomId, - selectedTrimId, selectedClipId, - selectedSpeedId, selectedAnnotationId, selectedAudioId, ]); @@ -1802,9 +1957,7 @@ export default function VideoEditor() { setAudioRegions(cloned.audioRegions); setAutoCaptions(cloned.autoCaptions); setSelectedZoomId(cloned.selectedZoomId); - setSelectedTrimId(cloned.selectedTrimId); setSelectedClipId(cloned.selectedClipId); - setSelectedSpeedId(cloned.selectedSpeedId); setSelectedAnnotationId(cloned.selectedAnnotationId); setSelectedAudioId(cloned.selectedAudioId); @@ -1816,10 +1969,6 @@ export default function VideoEditor() { "clip", cloned.clipRegions.map((region) => region.id), ); - nextSpeedIdRef.current = deriveNextId( - "speed", - cloned.speedRegions.map((region) => region.id), - ); nextAnnotationIdRef.current = deriveNextId( "annotation", cloned.annotationRegions.map((region) => region.id), @@ -1906,6 +2055,9 @@ export default function VideoEditor() { setShadowIntensity(normalizedEditor.shadowIntensity); setBackgroundBlur(normalizedEditor.backgroundBlur); setZoomMotionBlur(normalizedEditor.zoomMotionBlur); + setZoomTemporalMotionBlur(normalizedEditor.zoomTemporalMotionBlur); + setZoomMotionBlurSampleCount(normalizedEditor.zoomMotionBlurSampleCount); + setZoomMotionBlurShutterFraction(normalizedEditor.zoomMotionBlurShutterFraction); setConnectZooms(normalizedEditor.connectZooms); setZoomInDurationMs(normalizedEditor.zoomInDurationMs); setZoomInOverlapMs(normalizedEditor.zoomInOverlapMs); @@ -1915,11 +2067,15 @@ export default function VideoEditor() { setZoomInEasing(normalizedEditor.zoomInEasing); setZoomOutEasing(normalizedEditor.zoomOutEasing); setConnectedZoomEasing(normalizedEditor.connectedZoomEasing); + setSessionShowCursorOverride(null); setShowCursor(normalizedEditor.showCursor); setLoopCursor(normalizedEditor.loopCursor); setCursorStyle(normalizedEditor.cursorStyle); setCursorSize(normalizedEditor.cursorSize); setCursorSmoothing(normalizedEditor.cursorSmoothing); + setCursorSpringStiffnessMultiplier(normalizedEditor.cursorSpringStiffnessMultiplier); + setCursorSpringDampingMultiplier(normalizedEditor.cursorSpringDampingMultiplier); + setCursorSpringMassMultiplier(normalizedEditor.cursorSpringMassMultiplier); setZoomSmoothness(normalizedEditor.zoomSmoothness); setZoomClassicMode(normalizedEditor.zoomClassicMode); setCursorMotionBlur(normalizedEditor.cursorMotionBlur); @@ -1954,9 +2110,7 @@ export default function VideoEditor() { setGifSizePreset(normalizedEditor.gifSizePreset); setSelectedZoomId(null); - setSelectedTrimId(null); setSelectedClipId(null); - setSelectedSpeedId(null); setSelectedAnnotationId(null); setSelectedAudioId(null); @@ -1964,18 +2118,10 @@ export default function VideoEditor() { "zoom", normalizedEditor.zoomRegions.map((region) => region.id), ); - nextTrimIdRef.current = deriveNextId( - "trim", - normalizedEditor.trimRegions.map((region) => region.id), - ); nextClipIdRef.current = deriveNextId( "clip", normalizedEditor.clipRegions.map((region: ClipRegion) => region.id), ); - nextSpeedIdRef.current = deriveNextId( - "speed", - normalizedEditor.speedRegions.map((region) => region.id), - ); nextAudioIdRef.current = deriveNextId( "audio", normalizedEditor.audioRegions.map((region) => region.id), @@ -2242,6 +2388,9 @@ export default function VideoEditor() { pendingFreshRecordingAutoZoomPathRef.current = autoApplyFreshRecordingAutoZooms ? sourceVideoUrl : null; + setSessionShowCursorOverride( + sessionResult.session.hideOverlayCursorByDefault ? false : null, + ); setWebcam((prev) => ({ ...prev, enabled: Boolean(sessionResult.session?.webcamPath), @@ -2261,6 +2410,7 @@ export default function VideoEditor() { setCurrentProjectPath(null); setLastSavedSnapshot(null); pendingFreshRecordingAutoZoomPathRef.current = null; + setSessionShowCursorOverride(null); setWebcam((prev) => ({ ...prev, enabled: false, @@ -2316,6 +2466,9 @@ export default function VideoEditor() { shadowIntensity, backgroundBlur, zoomMotionBlur, + zoomTemporalMotionBlur, + zoomMotionBlurSampleCount, + zoomMotionBlurShutterFraction, autoApplyFreshRecordingAutoZooms, connectZooms, zoomInDurationMs, @@ -2331,6 +2484,9 @@ export default function VideoEditor() { cursorStyle, cursorSize, cursorSmoothing, + cursorSpringStiffnessMultiplier, + cursorSpringDampingMultiplier, + cursorSpringMassMultiplier, cursorMotionBlur, cursorClickBounce, cursorClickBounceDuration, @@ -2357,6 +2513,9 @@ export default function VideoEditor() { shadowIntensity, backgroundBlur, zoomMotionBlur, + zoomTemporalMotionBlur, + zoomMotionBlurSampleCount, + zoomMotionBlurShutterFraction, autoApplyFreshRecordingAutoZooms, connectZooms, zoomInDurationMs, @@ -2372,6 +2531,9 @@ export default function VideoEditor() { cursorStyle, cursorSize, cursorSmoothing, + cursorSpringStiffnessMultiplier, + cursorSpringDampingMultiplier, + cursorSpringMassMultiplier, cursorMotionBlur, cursorClickBounce, cursorClickBounceDuration, @@ -2574,14 +2736,6 @@ export default function VideoEditor() { setAutoCaptionSettings((prev) => ({ ...prev, enabled: false })); }, []); - const handleSaveAutoCaptionEdit = useCallback( - (target: CaptionEditTarget, text: string) => { - setAutoCaptions((captions) => updateCaptionCuesForEditedTarget(captions, target, text)); - toast.success(t("settings.captions.editSaved", "Caption updated")); - }, - [t], - ); - const saveProject = useCallback( async (forceSaveAs: boolean, options?: SaveProjectOptions) => { clearPendingProjectAutosave(); @@ -3016,10 +3170,30 @@ export default function VideoEditor() { if (totalMs <= 0) return; if (!clipInitializedRef.current) { if (clipRegions.length === 0) { - const id = `clip-${nextClipIdRef.current++}`; - autoFullTrackClipIdRef.current = id; - autoFullTrackClipEndMsRef.current = totalMs; - setClipRegions([{ id, startMs: 0, endMs: totalMs, speed: 1 }]); + const nextClipRegions = + trimRegions.length > 0 + ? trimsToClips(trimRegions, totalMs) + : (() => { + const id = `clip-${nextClipIdRef.current++}`; + autoFullTrackClipIdRef.current = id; + autoFullTrackClipEndMsRef.current = totalMs; + return [{ id, startMs: 0, endMs: totalMs, speed: 1 }]; + })(); + + if (trimRegions.length > 0) { + nextClipIdRef.current = deriveNextId( + "clip", + nextClipRegions.map((region) => region.id), + ); + } + + setClipRegions(nextClipRegions); + if (speedRegions.length > 0) { + // Legacy speed regions no longer have dedicated editing surfaces. + // Clear them during clip bootstrap so old projects do not keep + // hidden playback changes that users cannot inspect or edit. + setSpeedRegions([]); + } } clipInitializedRef.current = true; return; @@ -3035,7 +3209,7 @@ export default function VideoEditor() { autoFullTrackClipEndMsRef.current = totalMs; setClipRegions(extendedClipRegions); - }, [duration, clipRegions]); + }, [duration, clipRegions, trimRegions, speedRegions]); // Derive trimRegions from clipRegions so export/playback pipelines stay unchanged useEffect(() => { @@ -3045,27 +3219,12 @@ export default function VideoEditor() { }, [clipRegions, duration]); const mapTimelineTimeToSourceTime = useCallback( - (timeMs: number) => { - for (const clip of clipRegions) { - if (timeMs < clip.startMs || timeMs > clip.endMs) continue; - const speed = Number.isFinite(clip.speed) && clip.speed > 0 ? clip.speed : 1; - return Math.round(clip.startMs + (timeMs - clip.startMs) * speed); - } - return Math.round(timeMs); - }, + (timeMs: number) => resolveTimelineTimeToSourceTime(timeMs, clipRegions), [clipRegions], ); const mapSourceTimeToTimelineTime = useCallback( - (timeMs: number) => { - for (const clip of clipRegions) { - const sourceEndMs = getClipSourceEndMs(clip); - if (timeMs < clip.startMs || timeMs > sourceEndMs) continue; - const speed = Number.isFinite(clip.speed) && clip.speed > 0 ? clip.speed : 1; - return Math.round(clip.startMs + (timeMs - clip.startMs) / speed); - } - return Math.round(timeMs); - }, + (timeMs: number) => resolveSourceTimeToTimelineTime(timeMs, clipRegions), [clipRegions], ); @@ -3133,7 +3292,6 @@ export default function VideoEditor() { setSelectedZoomId(id); if (id) { setActiveEffectSection("zoom"); - setSelectedTrimId(null); setSelectedAnnotationId(null); setSelectedAudioId(null); } else { @@ -3141,20 +3299,10 @@ export default function VideoEditor() { } }, []); - const handleSelectTrim = useCallback((id: string | null) => { - setSelectedTrimId(id); - if (id) { - setSelectedZoomId(null); - setSelectedAnnotationId(null); - setSelectedAudioId(null); - } - }, []); - const handleSelectAnnotation = useCallback((id: string | null) => { setSelectedAnnotationId(id); if (id) { setSelectedZoomId(null); - setSelectedTrimId(null); setSelectedAudioId(null); } }, []); @@ -3177,7 +3325,6 @@ export default function VideoEditor() { } setZoomRegions((prev) => [...prev, newRegion]); setSelectedZoomId(id); - setSelectedTrimId(null); setSelectedAnnotationId(null); extensionHost.emitEvent({ type: "timeline:region-added", @@ -3271,19 +3418,6 @@ export default function VideoEditor() { zoomRegions, ]); - const handleTrimAdded = useCallback((span: Span) => { - const id = `trim-${nextTrimIdRef.current++}`; - const newRegion: TrimRegion = { - id, - startMs: Math.round(span.start), - endMs: Math.round(span.end), - }; - setTrimRegions((prev) => [...prev, newRegion]); - setSelectedTrimId(id); - setSelectedZoomId(null); - setSelectedAnnotationId(null); - }, []); - const handleZoomSpanChange = useCallback((id: string, span: Span) => { setZoomRegions((prev) => prev.map((region) => @@ -3298,20 +3432,6 @@ export default function VideoEditor() { ); }, []); - const handleTrimSpanChange = useCallback((id: string, span: Span) => { - setTrimRegions((prev) => - prev.map((region) => - region.id === id - ? { - ...region, - startMs: Math.round(span.start), - endMs: Math.round(span.end), - } - : region, - ), - ); - }, []); - const handleZoomFocusChange = useCallback((id: string, focus: ZoomFocus) => { setZoomRegions((prev) => prev.map((region) => @@ -3364,16 +3484,6 @@ export default function VideoEditor() { [selectedZoomId], ); - const handleTrimDelete = useCallback( - (id: string) => { - setTrimRegions((prev) => prev.filter((region) => region.id !== id)); - if (selectedTrimId === id) { - setSelectedTrimId(null); - } - }, - [selectedTrimId], - ); - const handleSelectClip = useCallback((id: string | null) => { setSelectedClipId(id); if (id) { @@ -3552,62 +3662,11 @@ export default function VideoEditor() { [clipRegions, selectedClipId], ); - const handleSelectSpeed = useCallback((id: string | null) => { - setSelectedSpeedId(id); - if (id) { - setSelectedZoomId(null); - setSelectedTrimId(null); - setSelectedAnnotationId(null); - setSelectedAudioId(null); - } - }, []); - - const handleSpeedAdded = useCallback((span: Span) => { - const id = `speed-${nextSpeedIdRef.current++}`; - const newRegion: SpeedRegion = { - id, - startMs: Math.round(span.start), - endMs: Math.round(span.end), - speed: DEFAULT_PLAYBACK_SPEED, - }; - setSpeedRegions((prev) => [...prev, newRegion]); - setSelectedSpeedId(id); - setSelectedZoomId(null); - setSelectedTrimId(null); - setSelectedAnnotationId(null); - }, []); - - const handleSpeedSpanChange = useCallback((id: string, span: Span) => { - setSpeedRegions((prev) => - prev.map((region) => - region.id === id - ? { - ...region, - startMs: Math.round(span.start), - endMs: Math.round(span.end), - } - : region, - ), - ); - }, []); - - const handleSpeedDelete = useCallback( - (id: string) => { - setSpeedRegions((prev) => prev.filter((region) => region.id !== id)); - if (selectedSpeedId === id) { - setSelectedSpeedId(null); - } - }, - [selectedSpeedId], - ); - const handleSelectAudio = useCallback((id: string | null) => { setSelectedAudioId(id); if (id) { setSelectedZoomId(null); - setSelectedTrimId(null); setSelectedAnnotationId(null); - setSelectedSpeedId(null); } }, []); @@ -3624,9 +3683,7 @@ export default function VideoEditor() { setAudioRegions((prev) => [...prev, newRegion]); setSelectedAudioId(id); setSelectedZoomId(null); - setSelectedTrimId(null); setSelectedAnnotationId(null); - setSelectedSpeedId(null); }, []); const handleAudioSpanChange = useCallback((id: string, span: Span, trackIndex?: number) => { @@ -3681,18 +3738,6 @@ export default function VideoEditor() { [selectedAudioId], ); - const handleSpeedChange = useCallback( - (speed: PlaybackSpeed) => { - if (!selectedSpeedId) return; - setSpeedRegions((prev) => - prev.map((region) => - region.id === selectedSpeedId ? { ...region, speed } : region, - ), - ); - }, - [selectedSpeedId], - ); - const handleAnnotationAdded = useCallback((span: Span, trackIndex = 0) => { const id = `annotation-${nextAnnotationIdRef.current++}`; const zIndex = nextAnnotationZIndexRef.current++; // Assign z-index based on creation order @@ -3711,7 +3756,6 @@ export default function VideoEditor() { setAnnotationRegions((prev) => [...prev, newRegion]); setSelectedAnnotationId(id); setSelectedZoomId(null); - setSelectedTrimId(null); }, []); const handleAnnotationSpanChange = useCallback( @@ -3912,12 +3956,6 @@ export default function VideoEditor() { } }, [selectedZoomId, zoomRegions]); - useEffect(() => { - if (selectedTrimId && !trimRegions.some((region) => region.id === selectedTrimId)) { - setSelectedTrimId(null); - } - }, [selectedTrimId, trimRegions]); - useEffect(() => { if ( selectedAnnotationId && @@ -3927,12 +3965,6 @@ export default function VideoEditor() { } }, [selectedAnnotationId, annotationRegions]); - useEffect(() => { - if (selectedSpeedId && !speedRegions.some((region) => region.id === selectedSpeedId)) { - setSelectedSpeedId(null); - } - }, [selectedSpeedId, speedRegions]); - useEffect(() => { if (selectedAudioId && !audioRegions.some((region) => region.id === selectedAudioId)) { setSelectedAudioId(null); @@ -4117,7 +4149,7 @@ export default function VideoEditor() { // Sync audio playback with video currentTime and isPlaying state useEffect(() => { const currentTimeMs = currentTime * 1000; - const activeSpeedRegion = speedRegions.find( + const activeSpeedRegion = effectiveSpeedRegions.find( (region) => currentTimeMs >= region.startMs && currentTimeMs < region.endMs, ); const targetPlaybackRate = activeSpeedRegion ? activeSpeedRegion.speed : 1; @@ -4151,7 +4183,7 @@ export default function VideoEditor() { } } } - }, [isPlaying, currentTime, audioRegions, speedRegions]); + }, [isPlaying, currentTime, audioRegions, effectiveSpeedRegions]); useEffect(() => { if (previewSourceAudioFallbackPaths.length === 0) { @@ -4159,7 +4191,7 @@ export default function VideoEditor() { return; } - const activeSpeedRegion = speedRegions.find( + const activeSpeedRegion = effectiveSpeedRegions.find( (region) => currentTime * 1000 >= region.startMs && currentTime * 1000 < region.endMs, ); const targetPlaybackRate = activeSpeedRegion ? activeSpeedRegion.speed : 1; @@ -4213,7 +4245,7 @@ export default function VideoEditor() { isPlaying, previewSourceAudioFallbackPaths, sourceAudioFallbackStartDelayMsByPath, - speedRegions, + effectiveSpeedRegions, ]); const showExportSuccessToast = useCallback((filePath: string) => { @@ -4328,6 +4360,9 @@ export default function VideoEditor() { shadowIntensity: effectiveShadowIntensity, backgroundBlur, zoomMotionBlur, + zoomTemporalMotionBlur, + zoomMotionBlurSampleCount, + zoomMotionBlurShutterFraction, connectZooms, zoomInDurationMs, zoomInOverlapMs, @@ -4350,10 +4385,13 @@ export default function VideoEditor() { autoCaptionSettings, zoomRegions: effectiveZoomRegions, cursorTelemetry: effectiveCursorTelemetry, - showCursor, + showCursor: effectiveShowCursor, cursorStyle, cursorSize, cursorSmoothing, + cursorSpringStiffnessMultiplier, + cursorSpringDampingMultiplier, + cursorSpringMassMultiplier, zoomSmoothness, zoomClassicMode, cursorMotionBlur, @@ -4375,21 +4413,18 @@ export default function VideoEditor() { const result = await gifExporter.export(); if (result.success && result.blob) { - const arrayBuffer = await result.blob.arrayBuffer(); const timestamp = Date.now(); const fileName = `export-${timestamp}.gif`; markExportAsSaving(); - const saveResult = - smokeExportConfig.enabled && smokeExportConfig.outputPath - ? await window.electronAPI.writeExportedVideoToPath( - arrayBuffer, - smokeExportConfig.outputPath, - ) - : await window.electronAPI.saveExportedVideo(arrayBuffer, fileName); + const { saveResult, pendingSave } = await saveBlobExport( + result.blob, + fileName, + smokeExportConfig.enabled ? smokeExportConfig.outputPath : null, + ); if (saveResult.canceled) { - pendingExportSaveRef.current = { arrayBuffer, fileName }; + pendingExportSaveRef.current = pendingSave; setHasPendingExportSave(true); setExportError( "Save dialog canceled. Click Save Again to save without re-rendering.", @@ -4504,6 +4539,9 @@ export default function VideoEditor() { shadowIntensity: effectiveShadowIntensity, backgroundBlur, zoomMotionBlur, + zoomTemporalMotionBlur, + zoomMotionBlurSampleCount, + zoomMotionBlurShutterFraction, connectZooms, zoomInDurationMs, zoomInOverlapMs, @@ -4525,10 +4563,13 @@ export default function VideoEditor() { autoCaptionSettings, zoomRegions: effectiveZoomRegions, cursorTelemetry: effectiveCursorTelemetry, - showCursor, + showCursor: effectiveShowCursor, cursorStyle, cursorSize, cursorSmoothing, + cursorSpringStiffnessMultiplier, + cursorSpringDampingMultiplier, + cursorSpringMassMultiplier, zoomSmoothness, zoomClassicMode, cursorMotionBlur, @@ -4589,20 +4630,16 @@ export default function VideoEditor() { }); pendingOnCancel = { fileName, tempFilePath: result.tempFilePath }; } else if (result.blob) { - // Legacy fallback: small exports may still surface a Blob (GIF, - // smoke tests in non-Electron environments, etc.). - const arrayBuffer = await result.blob.arrayBuffer(); - saveResult = - smokeExportConfig.enabled && smokeExportConfig.outputPath - ? await window.electronAPI.writeExportedVideoToPath( - arrayBuffer, - smokeExportConfig.outputPath, - ) - : await window.electronAPI.saveExportedVideo( - arrayBuffer, - fileName, - ); - pendingOnCancel = { fileName, arrayBuffer }; + // Legacy fallback: some export paths still surface a Blob, but in + // Electron we stream it into a temp file first so save/finalize + // never requires a giant renderer ArrayBuffer. + const blobSave = await saveBlobExport( + result.blob, + fileName, + smokeExportConfig.enabled ? smokeExportConfig.outputPath : null, + ); + saveResult = blobSave.saveResult; + pendingOnCancel = blobSave.pendingSave; } else { saveResult = { success: false, message: "Export produced no output" }; pendingOnCancel = { fileName }; @@ -4756,6 +4793,9 @@ export default function VideoEditor() { shadowIntensity, backgroundBlur, zoomMotionBlur, + zoomTemporalMotionBlur, + zoomMotionBlurSampleCount, + zoomMotionBlurShutterFraction, connectZooms, zoomInDurationMs, zoomInOverlapMs, @@ -4765,11 +4805,14 @@ export default function VideoEditor() { zoomInEasing, zoomOutEasing, connectedZoomEasing, - showCursor, + effectiveShowCursor, cursorStyle, effectiveCursorTelemetry, cursorSize, cursorSmoothing, + cursorSpringStiffnessMultiplier, + cursorSpringDampingMultiplier, + cursorSpringMassMultiplier, zoomSmoothness, zoomClassicMode, cursorMotionBlur, @@ -5270,7 +5313,7 @@ export default function VideoEditor() { )}
@@ -5401,6 +5444,10 @@ export default function VideoEditor() {
+ @@ -5952,7 +6002,6 @@ export default function VideoEditor() { showShadow={shadowIntensity > 0} shadowIntensity={shadowIntensity} backgroundBlur={backgroundBlur} - zoomMotionBlur={zoomMotionBlur} connectZooms={connectZooms} zoomInDurationMs={zoomInDurationMs} zoomInOverlapMs={zoomInOverlapMs} @@ -5977,7 +6026,6 @@ export default function VideoEditor() { annotationRegions={annotationRegions} autoCaptions={autoCaptions} autoCaptionSettings={autoCaptionSettings} - onEditAutoCaption={handleSaveAutoCaptionEdit} selectedAnnotationId={selectedAnnotationId} onSelectAnnotation={handleSelectAnnotation} onAnnotationPositionChange={ @@ -5985,10 +6033,19 @@ export default function VideoEditor() { } onAnnotationSizeChange={handleAnnotationSizeChange} cursorTelemetry={effectiveCursorTelemetry} - showCursor={showCursor} + showCursor={effectiveShowCursor} cursorStyle={cursorStyle} cursorSize={cursorSize} cursorSmoothing={cursorSmoothing} + cursorSpringStiffnessMultiplier={ + cursorSpringStiffnessMultiplier + } + cursorSpringDampingMultiplier={ + cursorSpringDampingMultiplier + } + cursorSpringMassMultiplier={ + cursorSpringMassMultiplier + } zoomSmoothness={zoomSmoothness} zoomClassicMode={zoomClassicMode} cursorMotionBlur={cursorMotionBlur} @@ -6250,22 +6307,11 @@ export default function VideoEditor() { selectedZoomId={selectedZoomId} onSelectZoom={handleSelectZoom} trimRegions={trimRegions} - onTrimAdded={handleTrimAdded} - onTrimSpanChange={handleTrimSpanChange} - onTrimDelete={handleTrimDelete} - selectedTrimId={selectedTrimId} - onSelectTrim={handleSelectTrim} clipRegions={clipRegions} onClipSplit={handleClipSplit} onClipSpanChange={handleClipSpanChange} selectedClipId={selectedClipId} onSelectClip={handleSelectClip} - speedRegions={speedRegions} - onSpeedAdded={handleSpeedAdded} - onSpeedSpanChange={handleSpeedSpanChange} - onSpeedDelete={handleSpeedDelete} - selectedSpeedId={selectedSpeedId} - onSelectSpeed={handleSelectSpeed} audioRegions={audioRegions} onAudioAdded={handleAudioAdded} onAudioSpanChange={handleAudioSpanChange} diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index e9fce7e6..2c7499d0 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -1,13 +1,6 @@ -import { - Application, - BlurFilter, - Container, - Graphics, - Sprite, - Texture, - VideoSource, -} from "pixi.js"; +import { Application, Container, Graphics, Rectangle, Sprite, Texture, VideoSource } from "pixi.js"; import { MotionBlurFilter } from "pixi-filters/motion-blur"; +import { ZoomBlurFilter } from "pixi-filters/zoom-blur"; import type React from "react"; import { forwardRef, @@ -18,7 +11,6 @@ import { useRef, useState, } from "react"; -import { useI18n } from "@/contexts/I18nContext"; import { getAssetPath, getRenderableAssetUrl, getRenderableVideoUrl } from "@/lib/assetPath"; import { clampMediaTimeToDuration, getMediaSyncPlaybackRate } from "@/lib/mediaTiming"; import { @@ -26,7 +18,6 @@ import { DEFAULT_WALLPAPER_RELATIVE_PATH, isVideoWallpaperSource, } from "@/lib/wallpapers"; -import { type CaptionEditTarget, normalizeCaptionEditText } from "./captionEditing"; import { buildActiveCaptionLayout } from "./captionLayout"; import { CAPTION_FONT_WEIGHT, @@ -238,7 +229,6 @@ interface VideoPlaybackProps { showShadow?: boolean; shadowIntensity?: number; backgroundBlur?: number; - zoomMotionBlur?: number; connectZooms?: boolean; zoomInDurationMs?: number; zoomInOverlapMs?: number; @@ -260,7 +250,6 @@ interface VideoPlaybackProps { annotationRegions?: AnnotationRegion[]; autoCaptions?: CaptionCue[]; autoCaptionSettings?: AutoCaptionSettings; - onEditAutoCaption?: (target: CaptionEditTarget, text: string) => void; selectedAnnotationId?: string | null; onSelectAnnotation?: (id: string | null) => void; onAnnotationPositionChange?: (id: string, position: { x: number; y: number }) => void; @@ -270,6 +259,9 @@ interface VideoPlaybackProps { cursorStyle?: CursorStyle; cursorSize?: number; cursorSmoothing?: number; + cursorSpringStiffnessMultiplier?: number; + cursorSpringDampingMultiplier?: number; + cursorSpringMassMultiplier?: number; zoomSmoothness?: number; zoomClassicMode?: boolean; cursorMotionBlur?: number; @@ -279,11 +271,6 @@ interface VideoPlaybackProps { volume?: number; } -type CaptionEditSession = { - target: CaptionEditTarget; - draft: string; -}; - export interface VideoPlaybackRef { video: HTMLVideoElement | null; app: Application | null; @@ -314,7 +301,6 @@ const VideoPlayback = forwardRef( showShadow, shadowIntensity = 0, backgroundBlur = 0, - zoomMotionBlur = 0, connectZooms = true, zoomInDurationMs = DEFAULT_ZOOM_IN_DURATION_MS, zoomInOverlapMs = DEFAULT_ZOOM_IN_OVERLAP_MS, @@ -336,7 +322,6 @@ const VideoPlayback = forwardRef( annotationRegions = [], autoCaptions = [], autoCaptionSettings, - onEditAutoCaption, selectedAnnotationId, onSelectAnnotation, onAnnotationPositionChange, @@ -346,6 +331,9 @@ const VideoPlayback = forwardRef( cursorStyle = "tahoe", cursorSize = DEFAULT_CURSOR_SIZE, cursorSmoothing = DEFAULT_CURSOR_SMOOTHING, + cursorSpringStiffnessMultiplier = 1, + cursorSpringDampingMultiplier = 1, + cursorSpringMassMultiplier = 1, zoomSmoothness = 0.5, zoomClassicMode = false, cursorMotionBlur = DEFAULT_CURSOR_MOTION_BLUR, @@ -356,14 +344,15 @@ const VideoPlayback = forwardRef( }, ref, ) => { - const { t } = useI18n(); - const editCurrentCaptionLabel = t("settings.captions.editCurrent", "Edit current caption"); const videoRef = useRef(null); const containerRef = useRef(null); const appRef = useRef(null); const videoSpriteRef = useRef(null); + const videoEffectsContainerRef = useRef(null); const videoContainerRef = useRef(null); const cursorContainerRef = useRef(null); + const zoomBlurFilterRef = useRef(null); + const motionBlurFilterRef = useRef(null); const cameraContainerRef = useRef(null); const timeUpdateAnimationRef = useRef(null); const [pixiReady, setPixiReady] = useState(false); @@ -378,17 +367,10 @@ const VideoPlayback = forwardRef( height: number; } | null>(null); const captionBoxRef = useRef(null); - const captionEditInputRef = useRef(null); - const captionEditSessionRef = useRef(null); - const [captionEditSession, setCaptionEditSession] = useState( - null, - ); const currentTimeRef = useRef(0); const zoomRegionsRef = useRef([]); const selectedZoomIdRef = useRef(null); const animationStateRef = useRef(createPlaybackAnimationState()); - const blurFilterRef = useRef(null); - const motionBlurFilterRef = useRef(null); const isDraggingFocusRef = useRef(false); const stageSizeRef = useRef({ width: 0, height: 0 }); const videoSizeRef = useRef({ width: 0, height: 0 }); @@ -424,7 +406,6 @@ const VideoPlayback = forwardRef( const lastWebcamSyncTimeRef = useRef(null); const lastBackgroundSyncTimeRef = useRef(null); const bgVideoRef = useRef(null); - const zoomMotionBlurRef = useRef(zoomMotionBlur); const connectZoomsRef = useRef(connectZooms); const zoomInDurationMsRef = useRef(zoomInDurationMs); const zoomInOverlapMsRef = useRef(zoomInOverlapMs); @@ -442,6 +423,9 @@ const VideoPlayback = forwardRef( const cursorSizeRef = useRef(cursorSize); const cursorStyleRef = useRef(cursorStyle); const cursorSmoothingRef = useRef(cursorSmoothing); + const cursorSpringStiffnessMultiplierRef = useRef(cursorSpringStiffnessMultiplier); + const cursorSpringDampingMultiplierRef = useRef(cursorSpringDampingMultiplier); + const cursorSpringMassMultiplierRef = useRef(cursorSpringMassMultiplier); const cursorMotionBlurRef = useRef(cursorMotionBlur); const cursorClickBounceRef = useRef(cursorClickBounce); const cursorClickBounceDurationRef = useRef(cursorClickBounceDuration); @@ -495,148 +479,6 @@ const VideoPlayback = forwardRef( measureText: (text) => measurementContext.measureText(text).width, }); }, [autoCaptionSettings, autoCaptions, currentTime]); - const activeCaptionEditTarget = activeCaptionLayout?.editTarget ?? null; - const activeCaptionEditTargetId = activeCaptionEditTarget?.id ?? null; - const isCaptionEditing = captionEditSession !== null; - const captionEditDraft = captionEditSession?.draft ?? ""; - const captionEditTargetId = captionEditSession?.target.id ?? null; - const captionEditTextMetrics = useMemo(() => { - if (!captionEditSession || !autoCaptionSettings || typeof document === "undefined") { - return null; - } - - const overlayWidth = overlayRef.current?.clientWidth || 960; - const fontSize = getCaptionScaledFontSize( - autoCaptionSettings.fontSize, - overlayWidth, - autoCaptionSettings.maxWidth, - ); - const maxTextWidthPx = getCaptionTextMaxWidth( - overlayWidth, - autoCaptionSettings.maxWidth, - fontSize, - ); - const measurementCanvas = document.createElement("canvas"); - const measurementContext = measurementCanvas.getContext("2d"); - if (!measurementContext) { - return null; - } - - measurementContext.font = `${CAPTION_FONT_WEIGHT} ${fontSize}px ${getDefaultCaptionFontFamily()}`; - const measuredWidth = Math.max( - ...captionEditSession.draft - .split(/\r?\n/) - .map((line) => measurementContext.measureText(line || " ").width), - ); - - return { - fontSize, - maxTextWidthPx, - widthPx: Math.ceil( - Math.min(maxTextWidthPx, Math.max(fontSize * 2, measuredWidth + 2)), - ), - }; - }, [autoCaptionSettings, captionEditSession]); - const captionEditSizeKey = captionEditSession - ? `${captionEditTextMetrics?.widthPx ?? 0}:${captionEditDraft}` - : ""; - - const beginCaptionEdit = useCallback(() => { - if (!activeCaptionLayout?.editTarget || !onEditAutoCaption) { - return; - } - - videoRef.current?.pause(); - onPlayStateChange(false); - const nextSession = { - target: activeCaptionLayout.editTarget, - draft: activeCaptionLayout.editTarget.text, - }; - captionEditSessionRef.current = nextSession; - setCaptionEditSession(nextSession); - }, [activeCaptionLayout, onEditAutoCaption, onPlayStateChange]); - - const commitCaptionEdit = useCallback(() => { - const session = captionEditSessionRef.current; - if (!session || !onEditAutoCaption) { - captionEditSessionRef.current = null; - setCaptionEditSession(null); - return; - } - - const normalizedDraft = normalizeCaptionEditText(session.draft); - captionEditSessionRef.current = null; - if (!normalizedDraft) { - setCaptionEditSession(null); - return; - } - - if (normalizedDraft !== normalizeCaptionEditText(session.target.text)) { - onEditAutoCaption(session.target, session.draft); - } - setCaptionEditSession(null); - }, [onEditAutoCaption]); - - const cancelCaptionEdit = useCallback(() => { - captionEditSessionRef.current = null; - setCaptionEditSession(null); - }, []); - - useEffect(() => { - if (!activeCaptionEditTarget) { - return; - } - - setCaptionEditSession((session) => { - if (!session || session.target.id === activeCaptionEditTargetId) { - return session; - } - - const nextSession = { - ...session, - target: activeCaptionEditTarget, - }; - captionEditSessionRef.current = nextSession; - return nextSession; - }); - }, [activeCaptionEditTarget, activeCaptionEditTargetId]); - - useEffect(() => { - if (!captionEditTargetId) { - return; - } - - const frame = requestAnimationFrame(() => { - const input = captionEditInputRef.current; - if (!input) { - return; - } - - input.focus(); - const cursorPosition = input.value.length; - input.setSelectionRange(cursorPosition, cursorPosition); - }); - - return () => cancelAnimationFrame(frame); - }, [captionEditTargetId]); - - useEffect(() => { - if (!captionEditSizeKey) { - return; - } - - const frame = requestAnimationFrame(() => { - const input = captionEditInputRef.current; - if (!input) { - return; - } - - input.style.height = "auto"; - input.style.height = `${input.scrollHeight}px`; - }); - - return () => cancelAnimationFrame(frame); - }, [captionEditSizeKey]); useEffect(() => { const captionBox = captionBoxRef.current; @@ -649,12 +491,6 @@ const VideoPlayback = forwardRef( } const frame = requestAnimationFrame(() => { - if (isCaptionEditing) { - captionBox.dataset.editingCaption = captionEditSizeKey; - } else { - delete captionBox.dataset.editingCaption; - } - const width = captionBox.offsetWidth; const height = captionBox.offsetHeight; if (width <= 0 || height <= 0) { @@ -679,7 +515,7 @@ const VideoPlayback = forwardRef( }); return () => cancelAnimationFrame(frame); - }, [activeCaptionLayout, autoCaptionSettings, captionEditSizeKey, isCaptionEditing]); + }, [activeCaptionLayout, autoCaptionSettings]); const motionBlurStateRef = useRef(createMotionBlurState()); const webcamEnabled = webcam?.enabled ?? false; const webcamMargin = webcam?.margin ?? 24; @@ -823,6 +659,28 @@ const VideoPlayback = forwardRef( [], ); + const syncPreviewMotionBlurQuality = useCallback(() => { + const app = appRef.current; + const videoEffectsContainer = videoEffectsContainerRef.current; + const zoomBlurFilter = zoomBlurFilterRef.current; + const motionBlurFilter = motionBlurFilterRef.current; + + if (!app || !videoEffectsContainer || !zoomBlurFilter || !motionBlurFilter) { + return; + } + + const filterResolution = Math.max( + 1, + app.renderer.resolution || window.devicePixelRatio || 1, + ); + const stageWidth = Math.max(1, stageSizeRef.current.width || app.screen.width); + const stageHeight = Math.max(1, stageSizeRef.current.height || app.screen.height); + + zoomBlurFilter.resolution = filterResolution; + motionBlurFilter.resolution = filterResolution; + videoEffectsContainer.filterArea = new Rectangle(0, 0, stageWidth, stageHeight); + }, []); + const layoutVideoContent = useCallback(() => { const container = containerRef.current; const app = appRef.current; @@ -880,6 +738,7 @@ const VideoPlayback = forwardRef( if (result) { stageSizeRef.current = result.stageSize; + syncPreviewMotionBlurQuality(); videoSizeRef.current = result.videoSize; baseScaleRef.current = result.baseScale; baseOffsetRef.current = result.baseOffset; @@ -963,6 +822,7 @@ const VideoPlayback = forwardRef( showShadow, shadowIntensity, applyWebcamBubbleLayout, + syncPreviewMotionBlurQuality, ]); useEffect(() => { @@ -1294,8 +1154,23 @@ const VideoPlayback = forwardRef( }, [speedRegions]); useEffect(() => { - zoomMotionBlurRef.current = zoomMotionBlur; - }, [zoomMotionBlur]); + const videoEffectsContainer = videoEffectsContainerRef.current; + const zoomBlurFilter = zoomBlurFilterRef.current; + const motionBlurFilter = motionBlurFilterRef.current; + + if (!videoEffectsContainer || !zoomBlurFilter || !motionBlurFilter) { + return; + } + + videoEffectsContainer.filters = null; + motionBlurFilter.velocity = { x: 0, y: 0 }; + motionBlurFilter.kernelSize = 5; + motionBlurFilter.offset = 0; + zoomBlurFilter.strength = 0; + zoomBlurFilter.innerRadius = 0; + zoomBlurFilter.radius = -1; + motionBlurStateRef.current = createMotionBlurState(); + }, [pixiReady]); useEffect(() => { connectZoomsRef.current = connectZooms; @@ -1363,6 +1238,18 @@ const VideoPlayback = forwardRef( cursorSmoothingRef.current = cursorSmoothing; }, [cursorSmoothing]); + useEffect(() => { + cursorSpringStiffnessMultiplierRef.current = cursorSpringStiffnessMultiplier; + }, [cursorSpringStiffnessMultiplier]); + + useEffect(() => { + cursorSpringDampingMultiplierRef.current = cursorSpringDampingMultiplier; + }, [cursorSpringDampingMultiplier]); + + useEffect(() => { + cursorSpringMassMultiplierRef.current = cursorSpringMassMultiplier; + }, [cursorSpringMassMultiplier]); + useEffect(() => { zoomSmoothnessRef.current = zoomSmoothness; }, [zoomSmoothness]); @@ -1421,10 +1308,6 @@ const VideoPlayback = forwardRef( cursorOverlayRef.current?.reset(); motionBlurStateRef.current = createMotionBlurState(); - if (blurFilterRef.current) { - blurFilterRef.current.blur = 0; - } - requestAnimationFrame(() => { const container = cameraContainerRef.current; const videoStage = videoContainerRef.current; @@ -1445,7 +1328,8 @@ const VideoPlayback = forwardRef( applyZoomTransform({ cameraContainer: container, - blurFilter: blurFilterRef.current, + zoomBlurFilter: zoomBlurFilterRef.current, + motionBlurFilter: motionBlurFilterRef.current, stageSize: stageSizeRef.current, baseMask: baseMaskRef.current, zoomScale: 1, @@ -1453,7 +1337,8 @@ const VideoPlayback = forwardRef( focusY: DEFAULT_FOCUS.cy, motionIntensity: 0, isPlaying: false, - motionBlurAmount: zoomMotionBlurRef.current, + motionBlurAmount: 0, + motionBlurState: motionBlurStateRef.current, }); requestAnimationFrame(() => { @@ -1642,10 +1527,19 @@ const VideoPlayback = forwardRef( cameraContainerRef.current = cameraContainer; app.stage.addChild(cameraContainer); + // Match the export scene graph so zoom motion blur is applied to the + // same layer in preview and export. + const videoEffectsContainer = new Container(); + videoEffectsContainerRef.current = videoEffectsContainer; + zoomBlurFilterRef.current = new ZoomBlurFilter({ strength: 0 }); + motionBlurFilterRef.current = new MotionBlurFilter([0, 0], 5, 0); + cameraContainer.addChild(videoEffectsContainer); + syncPreviewMotionBlurQuality(); + // Video container - holds the masked video sprite const videoContainer = new Container(); videoContainerRef.current = videoContainer; - cameraContainer.addChild(videoContainer); + videoEffectsContainer.addChild(videoContainer); // Device frame overlay container — sits above video but below cursor const frameContainer = new Container(); @@ -1663,6 +1557,11 @@ const VideoPlayback = forwardRef( dotRadius: DEFAULT_CURSOR_CONFIG.dotRadius * cursorSizeRef.current, style: cursorStyleRef.current, smoothingFactor: cursorSmoothingRef.current, + springTuning: { + stiffnessMultiplier: cursorSpringStiffnessMultiplierRef.current, + dampingMultiplier: cursorSpringDampingMultiplierRef.current, + massMultiplier: cursorSpringMassMultiplierRef.current, + }, motionBlur: cursorMotionBlurRef.current, clickBounce: cursorClickBounceRef.current, clickBounceDuration: cursorClickBounceDurationRef.current, @@ -1691,6 +1590,10 @@ const VideoPlayback = forwardRef( cursorOverlayRef.current.destroy(); cursorOverlayRef.current = null; } + zoomBlurFilterRef.current?.destroy(); + motionBlurFilterRef.current?.destroy(); + zoomBlurFilterRef.current = null; + motionBlurFilterRef.current = null; if (app && app.renderer) { app.destroy(true, { children: true, @@ -1700,6 +1603,7 @@ const VideoPlayback = forwardRef( } appRef.current = null; cameraContainerRef.current = null; + videoEffectsContainerRef.current = null; videoContainerRef.current = null; frameContainerRef.current = null; frameSpriteRef.current = null; @@ -1731,10 +1635,12 @@ const VideoPlayback = forwardRef( const video = videoRef.current; const app = appRef.current; + const videoEffectsContainer = videoEffectsContainerRef.current; const videoContainer = videoContainerRef.current; const cursorContainer = cursorContainerRef.current; - if (!video || !app || !videoContainer || !cursorContainer) return; + if (!video || !app || !videoEffectsContainer || !videoContainer || !cursorContainer) + return; if (video.videoWidth === 0 || video.videoHeight === 0) return; const source = VideoSource.from(video); @@ -1760,19 +1666,6 @@ const VideoPlayback = forwardRef( animationStateRef.current = createPlaybackAnimationState(); - const blurFilter = new BlurFilter(); - blurFilter.quality = 3; - blurFilter.resolution = app.renderer.resolution; - blurFilter.blur = 0; - const motionBlurFilter = new MotionBlurFilter([0, 0], 5, 0); - // Don't attach filters by default — the filter pipeline forces the video - // through an intermediate RenderTexture at renderer resolution, downsampling - // the native video and destroying detail. Filters are attached conditionally - // in the ticker only when zoom motion blur is actually active. - videoContainer.filters = null; - blurFilterRef.current = blurFilter; - motionBlurFilterRef.current = motionBlurFilter; - layoutVideoContent(); video.pause(); @@ -1814,15 +1707,7 @@ const VideoPlayback = forwardRef( } videoContainer.mask = null; maskGraphicsRef.current = null; - if (blurFilterRef.current) { - videoContainer.filters = []; - blurFilterRef.current.destroy(); - blurFilterRef.current = null; - } - if (motionBlurFilterRef.current) { - motionBlurFilterRef.current.destroy(); - motionBlurFilterRef.current = null; - } + videoEffectsContainer.filters = null; videoTexture.destroy(false); videoSpriteRef.current = null; @@ -1834,8 +1719,9 @@ const VideoPlayback = forwardRef( const app = appRef.current; const videoSprite = videoSpriteRef.current; + const videoEffectsContainer = videoEffectsContainerRef.current; const videoContainer = videoContainerRef.current; - if (!app || !videoSprite || !videoContainer) return; + if (!app || !videoSprite || !videoEffectsContainer || !videoContainer) return; const applyTransform = ( transform: { scale: number; x: number; y: number }, @@ -1850,7 +1736,7 @@ const VideoPlayback = forwardRef( const appliedTransform = applyZoomTransform({ cameraContainer, - blurFilter: blurFilterRef.current, + zoomBlurFilter: zoomBlurFilterRef.current, motionBlurFilter: motionBlurFilterRef.current, stageSize: stageSizeRef.current, baseMask: baseMaskRef.current, @@ -1861,7 +1747,7 @@ const VideoPlayback = forwardRef( motionIntensity, motionVector, isPlaying: isPlayingRef.current, - motionBlurAmount: zoomMotionBlurRef.current, + motionBlurAmount: 0, transformOverride: transform, motionBlurState: motionBlurStateRef.current, frameTimeMs: performance.now(), @@ -1878,6 +1764,8 @@ const VideoPlayback = forwardRef( currentTimeRef.current, { connectZooms: connectZoomsRef.current, + zoomInDurationMs: zoomInDurationMsRef.current, + zoomOutDurationMs: zoomOutDurationMsRef.current, }, ); @@ -2047,24 +1935,6 @@ const VideoPlayback = forwardRef( motionVector, ); - // Conditionally attach motion blur filter only when the camera is - // actually moving. When filters are attached, PixiJS routes the video - // through an intermediate RenderTexture at renderer resolution, which - // downsamples the native video and degrades preview quality. - // Hysteresis prevents flickering when motionIntensity oscillates near threshold. - const filtersActive = - Array.isArray(videoContainer.filters) && videoContainer.filters.length > 0; - const cameraIsMoving = filtersActive - ? motionIntensity > 0.002 - : motionIntensity > 0.008; - const needsFilters = - zoomMotionBlurRef.current > 0 && isPlayingRef.current && cameraIsMoving; - if (needsFilters && !filtersActive && motionBlurFilterRef.current) { - videoContainer.filters = [motionBlurFilterRef.current]; - } else if (!needsFilters && filtersActive) { - videoContainer.filters = null; - } - applyWebcamBubbleLayout(animationStateRef.current.appliedScale || 1); const timeMs = currentTimeRef.current; @@ -2277,6 +2147,11 @@ const VideoPlayback = forwardRef( overlay.setDotRadius(DEFAULT_CURSOR_CONFIG.dotRadius * cursorSize); overlay.setSmoothingFactor(cursorSmoothing); + overlay.setSpringTuning({ + stiffnessMultiplier: cursorSpringStiffnessMultiplier, + dampingMultiplier: cursorSpringDampingMultiplier, + massMultiplier: cursorSpringMassMultiplier, + }); overlay.setMotionBlur(cursorMotionBlur); overlay.setClickBounce(cursorClickBounce); overlay.setClickBounceDuration(cursorClickBounceDuration); @@ -2305,6 +2180,9 @@ const VideoPlayback = forwardRef( cursorStyle, cursorSize, cursorSmoothing, + cursorSpringStiffnessMultiplier, + cursorSpringDampingMultiplier, + cursorSpringMassMultiplier, cursorMotionBlur, cursorClickBounce, cursorClickBounceDuration, @@ -2636,34 +2514,7 @@ const VideoPlayback = forwardRef( }} >
{ - if (!captionEditSession) { - beginCaptionEdit(); - } - }} - onKeyDown={(event) => { - if (!onEditAutoCaption || captionEditSession) { - return; - } - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - beginCaptionEdit(); - } - }} style={{ backgroundColor: `rgba(0, 0, 0, ${autoCaptionSettings.backgroundOpacity})`, fontFamily: getDefaultCaptionFontFamily(), @@ -2701,137 +2552,42 @@ const VideoPlayback = forwardRef( ), )}px`, boxSizing: "border-box", - cursor: - onEditAutoCaption && !captionEditSession - ? "text" - : undefined, - pointerEvents: onEditAutoCaption ? "auto" : undefined, }} > - {captionEditSession ? ( -