From c04255db7b137a6c8391a0287335845b59604138 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:23:27 +1000 Subject: [PATCH 01/19] Stream blob exports through temp files --- src/components/video-editor/VideoEditor.tsx | 129 ++++++++++++++++---- 1 file changed, 106 insertions(+), 23 deletions(-) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 7dc6d812..4254b569 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -226,6 +226,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; @@ -1005,6 +1056,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(); @@ -1398,6 +1486,7 @@ export default function VideoEditor() { borderRadius, padding, frame, + cropRegion, webcam, zoomRegions, trimRegions, @@ -1446,6 +1535,7 @@ export default function VideoEditor() { cursorSway, borderRadius, padding, + cropRegion, webcam, zoomRegions, trimRegions, @@ -4059,21 +4149,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.", @@ -4273,20 +4360,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 }; From c0a1299687ce6e19a3e3b52b7ce0f4ed665fbcf0 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Fri, 1 May 2026 11:52:36 +1000 Subject: [PATCH 02/19] timeline: add hover ghost preview and safer zoom placement --- src/components/video-editor/VideoEditor.tsx | 8 +- src/components/video-editor/timeline/Row.tsx | 21 +- .../video-editor/timeline/TimelineEditor.tsx | 291 +++++++++++++++--- 3 files changed, 274 insertions(+), 46 deletions(-) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 4254b569..6c93876e 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -150,7 +150,6 @@ import { DEFAULT_PLAYBACK_SPEED, DEFAULT_WEBCAM_OVERLAY, DEFAULT_WEBCAM_TIME_OFFSET_MS, - DEFAULT_ZOOM_DEPTH, DEFAULT_ZOOM_IN_DURATION_MS, DEFAULT_ZOOM_IN_EASING, DEFAULT_ZOOM_IN_OVERLAP_MS, @@ -2940,13 +2939,14 @@ export default function VideoEditor() { const handleZoomAdded = useCallback( (span: Span) => { const id = `zoom-${nextZoomIdRef.current++}`; + const defaultDepth: ZoomDepth = 2; const newRegion: ZoomRegion = { id, startMs: Math.round(span.start), endMs: Math.round(span.end), - depth: DEFAULT_ZOOM_DEPTH, - focus: { cx: 0.5, cy: 0.5 }, - mode: "manual", + depth: defaultDepth, + focus: clampFocusToDepth({ cx: 0.5, cy: 0.5 }, defaultDepth), + mode: "auto", }; if (videoPath && pendingFreshRecordingAutoZoomPathRef.current === videoPath) { autoSuggestedVideoPathRef.current = videoPath; diff --git a/src/components/video-editor/timeline/Row.tsx b/src/components/video-editor/timeline/Row.tsx index f54e5a9c..0bf28afe 100644 --- a/src/components/video-editor/timeline/Row.tsx +++ b/src/components/video-editor/timeline/Row.tsx @@ -7,9 +7,24 @@ interface RowProps extends RowDefinition { hint?: string; isEmpty?: boolean; labelColor?: string; + onMouseEnter?: React.MouseEventHandler; + onMouseMove?: React.MouseEventHandler; + onMouseLeave?: React.MouseEventHandler; + onClick?: React.MouseEventHandler; } -export default function Row({ id, children, label, hint, isEmpty, labelColor = "#666" }: RowProps) { +export default function Row({ + id, + children, + label, + hint, + isEmpty, + labelColor = "#666", + onMouseEnter, + onMouseMove, + onMouseLeave, + onClick, +}: RowProps) { const { setNodeRef, rowWrapperStyle, rowStyle } = useRow({ id }); return ( @@ -34,6 +49,10 @@ export default function Row({ id, children, label, hint, isEmpty, labelColor = " ref={setNodeRef} className="relative h-full min-h-[26px] overflow-hidden" style={rowStyle} + onMouseEnter={onMouseEnter} + onMouseMove={onMouseMove} + onMouseLeave={onMouseLeave} + onClick={onClick} > {children} diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 128ac5c9..32643bec 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -57,6 +57,7 @@ import type { } from "../types"; import AudioWaveform from "./AudioWaveform"; import Item from "./Item"; +import glassStyles from "./ItemGlass.module.css"; import KeyframeMarkers from "./KeyframeMarkers"; import Row from "./Row"; import TimelineWrapper from "./TimelineWrapper"; @@ -410,11 +411,14 @@ function PlaybackCursor({ >
- {isDragging && ( -
- {formatPlayheadTime(clampedTime)} -
- )} +
+ {formatPlayheadTime(clampedTime)} +
); @@ -594,6 +598,8 @@ function Timeline({ videoDurationMs, currentTimeMs, onSeek, + onAddZoomAtMs, + canPlaceZoomAtMs, onSelectZoom, onSelectTrim, onSelectClip, @@ -615,12 +621,14 @@ function Timeline({ videoDurationMs: number; currentTimeMs: number; onSeek?: (time: number) => void; + canPlaceZoomAtMs?: (startMs: number) => boolean; onSelectZoom?: (id: string | null) => void; onSelectTrim?: (id: string | null) => void; onSelectClip?: (id: string | null) => void; onSelectAnnotation?: (id: string | null) => void; onSelectSpeed?: (id: string | null) => void; onSelectAudio?: (id: string | null) => void; + onAddZoomAtMs?: (startMs: number) => void; selectedZoomId: string | null; selectedTrimId?: string | null; selectedClipId?: string | null; @@ -632,8 +640,13 @@ function Timeline({ keyframes?: { id: string; time: number }[]; audioPeaks?: AudioPeaksData | null; }) { - const { setTimelineRef, style, sidebarWidth, range, pixelsToValue } = useTimelineContext(); + const { setTimelineRef, style, sidebarWidth, direction, range, valueToPixels, pixelsToValue } = + useTimelineContext(); const localTimelineRef = useRef(null); + const [isTimelineHovered, setIsTimelineHovered] = useState(false); + const [timelineHoverMs, setTimelineHoverMs] = useState(null); + const [isZoomRowHovered, setIsZoomRowHovered] = useState(false); + const [zoomRowHoverMs, setZoomRowHoverMs] = useState(null); const setRefs = useCallback( (node: HTMLDivElement | null) => { @@ -712,6 +725,125 @@ function Timeline({ const timelineRowsMinHeightPx = getTimelineRowsMinHeightPx(timelineRowCount); const timelineContentMinHeightPx = getTimelineContentMinHeightPx(timelineRowCount); const timelineViewportStretchFactor = getTimelineViewportStretchFactor(timelineRowCount); + const sideProperty = direction === "rtl" ? "right" : "left"; + const visibleDurationMs = Math.max(1, range.end - range.start); + const ghostStartMs = + zoomRowHoverMs === null ? null : Math.max(0, Math.min(zoomRowHoverMs, videoDurationMs)); + const ghostDurationMs = Math.min(1000, videoDurationMs); + const ghostEndMs = + ghostStartMs === null + ? null + : Math.max(ghostStartMs, Math.min(videoDurationMs, ghostStartMs + ghostDurationMs)); + const ghostStartOffsetPx = + ghostStartMs === null ? 0 : valueToPixels(Math.max(0, ghostStartMs - range.start)); + const ghostEndOffsetPx = + ghostEndMs === null ? 0 : valueToPixels(Math.max(0, ghostEndMs - range.start)); + const ghostWidthPx = Math.max(18, ghostEndOffsetPx - ghostStartOffsetPx); + const timelineGhostOffsetPx = + timelineHoverMs === null ? 0 : valueToPixels(Math.max(0, timelineHoverMs - range.start)); + const canShowGhostPlayhead = isTimelineHovered && timelineHoverMs !== null; + const canShowGhostZoom = + isZoomRowHovered && + ghostStartMs !== null && + (onAddZoomAtMs ? (canPlaceZoomAtMs?.(ghostStartMs) ?? true) : false); + + const updateTimelineHoverTime = useCallback( + (clientX: number, rect: DOMRect) => { + const contentWidth = Math.max(1, rect.width - sidebarWidth); + + const contentX = + direction === "rtl" + ? rect.right - sidebarWidth - clientX + : clientX - rect.left - sidebarWidth; + const clampedX = Math.max(0, Math.min(contentX, contentWidth)); + const ratio = clampedX / contentWidth; + const nextMs = range.start + ratio * visibleDurationMs; + setTimelineHoverMs(Math.max(0, Math.min(nextMs, videoDurationMs))); + }, + [direction, range.start, sidebarWidth, videoDurationMs, visibleDurationMs], + ); + + const handleTimelineMouseEnter = useCallback( + (event: React.MouseEvent) => { + setIsTimelineHovered(true); + updateTimelineHoverTime(event.clientX, event.currentTarget.getBoundingClientRect()); + }, + [updateTimelineHoverTime], + ); + + const handleTimelineMouseMove = useCallback( + (event: React.MouseEvent) => { + if (!isTimelineHovered) { + setIsTimelineHovered(true); + } + updateTimelineHoverTime(event.clientX, event.currentTarget.getBoundingClientRect()); + }, + [isTimelineHovered, updateTimelineHoverTime], + ); + + const handleTimelineMouseLeave = useCallback(() => { + setIsTimelineHovered(false); + setTimelineHoverMs(null); + setIsZoomRowHovered(false); + setZoomRowHoverMs(null); + }, []); + + const updateZoomRowHoverTime = useCallback( + (clientX: number, rect: DOMRect) => { + if (rect.width <= 0) { + return; + } + + const position = + direction === "rtl" + ? Math.max(0, Math.min(rect.right - clientX, rect.width)) + : Math.max(0, Math.min(clientX - rect.left, rect.width)); + const ratio = position / rect.width; + const nextMs = range.start + ratio * visibleDurationMs; + setZoomRowHoverMs(Math.max(0, Math.min(nextMs, videoDurationMs))); + }, + [direction, range.start, videoDurationMs, visibleDurationMs], + ); + + const handleZoomRowMouseEnter = useCallback( + (event: React.MouseEvent) => { + setIsZoomRowHovered(true); + updateZoomRowHoverTime(event.clientX, event.currentTarget.getBoundingClientRect()); + }, + [updateZoomRowHoverTime], + ); + + const handleZoomRowMouseMove = useCallback( + (event: React.MouseEvent) => { + if (!isZoomRowHovered) { + setIsZoomRowHovered(true); + } + updateZoomRowHoverTime(event.clientX, event.currentTarget.getBoundingClientRect()); + }, + [isZoomRowHovered, updateZoomRowHoverTime], + ); + + const handleZoomRowMouseLeave = useCallback(() => { + setIsZoomRowHovered(false); + setZoomRowHoverMs(null); + }, []); + + const handleZoomRowClick = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + if (!onAddZoomAtMs || zoomRowHoverMs === null) { + return; + } + + const startMs = Math.max(0, Math.min(zoomRowHoverMs, videoDurationMs)); + if (canPlaceZoomAtMs && !canPlaceZoomAtMs(startMs)) { + return; + } + + onAddZoomAtMs(startMs); + }, + [canPlaceZoomAtMs, onAddZoomAtMs, videoDurationMs, zoomRowHoverMs], + ); return (
@@ -732,6 +867,20 @@ function Timeline({ timelineRef={localTimelineRef} keyframes={keyframes} /> + {canShowGhostPlayhead && ( +
+
+
+ )}
- + + {canShowGhostZoom && ghostStartMs !== null && ( +
+
+
+
+
+
+ +
+
+
+
+ )} {zoomItems.map((item) => ( ( // scaling them with the full recording length. const defaultRegionDurationMs = useMemo(() => Math.min(1000, totalMs), [totalMs]); + const canPlaceZoomAtMs = useCallback( + (startMs: number) => { + if (!videoDuration || videoDuration === 0 || totalMs === 0) { + return false; + } + + const defaultDuration = Math.min(defaultRegionDurationMs, totalMs); + if (defaultDuration <= 0) { + return false; + } + + const startPos = Math.max(0, Math.min(startMs, totalMs)); + const sorted = [...zoomRegions].sort((a, b) => a.startMs - b.startMs); + const nextRegion = sorted.find((region) => region.startMs > startPos); + const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos; + + const isOverlapping = sorted.some( + (region) => startPos >= region.startMs && startPos < region.endMs, + ); + + return !isOverlapping && gapToNext >= defaultDuration; + }, + [videoDuration, totalMs, zoomRegions, defaultRegionDurationMs], + ); + + const addZoomAtMs = useCallback( + (startMs: number) => { + if (!videoDuration || videoDuration === 0 || totalMs === 0) { + return; + } + + const defaultDuration = Math.min(defaultRegionDurationMs, totalMs); + if (defaultDuration <= 0) { + return; + } + + const startPos = Math.max(0, Math.min(startMs, totalMs)); + if (!canPlaceZoomAtMs(startPos)) { + toast.error("Cannot place zoom here", { + description: + "Zoom already exists at this location or not enough space available.", + }); + return; + } + + onZoomAdded({ start: startPos, end: startPos + defaultDuration }); + }, + [videoDuration, totalMs, onZoomAdded, defaultRegionDurationMs, canPlaceZoomAtMs], + ); + const handleAddZoom = useCallback(() => { if (!videoDuration || videoDuration === 0 || totalMs === 0) { return; } - const defaultDuration = Math.min(defaultRegionDurationMs, totalMs); - if (defaultDuration <= 0) { - return; - } - - // Always place zoom at playhead - const startPos = Math.max(0, Math.min(currentTimeMs, totalMs)); - // Find the next zoom region after the playhead - const sorted = [...zoomRegions].sort((a, b) => a.startMs - b.startMs); - const nextRegion = sorted.find((region) => region.startMs > startPos); - const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos; - - // Check if playhead is inside any zoom region - const isOverlapping = sorted.some( - (region) => startPos >= region.startMs && startPos < region.endMs, - ); - if (isOverlapping || gapToNext <= 0) { - toast.error("Cannot place zoom here", { - description: - "Zoom already exists at this location or not enough space available.", - }); - return; - } - - const actualDuration = Math.min(defaultRegionDurationMs, gapToNext); - onZoomAdded({ start: startPos, end: startPos + actualDuration }); - }, [ - videoDuration, - totalMs, - currentTimeMs, - zoomRegions, - onZoomAdded, - defaultRegionDurationMs, - ]); + addZoomAtMs(currentTimeMs); + }, [addZoomAtMs, currentTimeMs, totalMs, videoDuration]); const handleSuggestZooms = useCallback(() => { if (!videoDuration || videoDuration === 0 || totalMs === 0) { @@ -2269,6 +2476,8 @@ const TimelineEditor = forwardRef( videoDurationMs={totalMs} currentTimeMs={currentTimeMs} onSeek={onSeek} + onAddZoomAtMs={addZoomAtMs} + canPlaceZoomAtMs={canPlaceZoomAtMs} onSelectZoom={handleSelectZoom} onSelectTrim={handleSelectTrim} onSelectClip={handleSelectClip} From 01f61dfada6a2b54f1a848c038ce904cff583f8d Mon Sep 17 00:00:00 2001 From: shafeq Date: Sat, 2 May 2026 09:48:05 +0800 Subject: [PATCH 03/19] fix(export): return temp path from buffer-mode FFmpeg muxer (>2 GiB) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exports of recordings whose muxed output exceeds 2 GiB failed with RangeError [ERR_FS_FILE_TOO_LARGE]: Node's fs.readFile rejects files larger than kIoMaxLength (2 ** 31 - 1). The legacy export pipeline hit this in muxExportedVideoAudioBuffer, which called await fs.readFile(finalized.outputPath) to ship the muxed bytes back to the renderer. Mirror the path-based contract that mux-exported-video-audio-from-path already uses: - muxExportedVideoAudioBuffer now returns { outputPath, metrics } and collects byte size via fs.stat instead of fs.readFile. The unmuxed intermediate is still cleaned up; the muxed output is left for the IPC handler to register and the renderer to finalize. - The mux-exported-video-audio IPC handler registers the muxed output via registerOwnedExportPath and returns { tempPath, metrics }. - preload.ts and electron-env.d.ts: tempPath replaces data in the renderer-facing return type. - videoExporter.ts and modernVideoExporter.ts (the finalizeExportWithFfmpegAudio fallback paths) now return { tempFilePath } so VideoEditor's existing finalize-exported-video flow handles the move — the same path the modern stream-mode export already takes. The renderer already preferred tempFilePath over blob in VideoEditor.tsx for MP4 saves (with the explicit comment "avoids ever allocating a multi-GiB ArrayBuffer in the renderer"), so this just removes the buffer-mode regression for large legacy exports. Adds electron/ipc/export/native-video.test.ts asserting the new contract: muxExportedVideoAudioBuffer returns a path, never calls fs.readFile, and still records muxedVideoBytes via stat. Closes #380 --- electron/electron-env.d.ts | 2 +- electron/ipc/export/native-video.test.ts | 69 ++++++++++++++++++++++++ electron/ipc/export/native-video.ts | 42 ++++++++++----- electron/ipc/register/export.ts | 7 ++- electron/preload.ts | 2 +- src/lib/exporter/modernVideoExporter.ts | 7 +-- src/lib/exporter/videoExporter.ts | 8 +-- 7 files changed, 113 insertions(+), 24 deletions(-) create mode 100644 electron/ipc/export/native-video.test.ts diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 8b634980..170641ae 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -246,7 +246,7 @@ interface Window { }, ) => Promise<{ success: boolean; - data?: Uint8Array; + tempPath?: string; error?: string; metrics?: RendererFfmpegAudioMuxMetrics; }>; 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/preload.ts b/electron/preload.ts index c9e464f2..422ce2c5 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; }>; diff --git a/src/lib/exporter/modernVideoExporter.ts b/src/lib/exporter/modernVideoExporter.ts index 9f4393ed..229351bd 100644 --- a/src/lib/exporter/modernVideoExporter.ts +++ b/src/lib/exporter/modernVideoExporter.ts @@ -1244,17 +1244,18 @@ export class ModernVideoExporter { this.finalizationStageMs.ffmpegAudioMuxBreakdown = result.metrics; } - if (!result.success || !result.data) { + if (!result.success || !result.tempPath) { return { success: false, error: result.error || "Failed to mux exported audio with FFmpeg", }; } - const videoBytes = result.data.slice(); + // Returning a temp path (instead of buffering the muxed bytes back into + // the renderer) is what keeps >2 GiB exports off Node's fs.readFile cap. return { success: true, - blob: new Blob([videoBytes.buffer], { type: "video/mp4" }), + tempFilePath: result.tempPath, }; } diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index bcc887ca..f8581910 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -981,7 +981,7 @@ export class VideoExporter { this.finalizationStageMs.ffmpegAudioMuxBreakdown = result.metrics; } - if (!result.success || !result.data) { + if (!result.success || !result.tempPath) { return { success: false, error: result.error || "Failed to mux exported audio with FFmpeg", @@ -989,11 +989,11 @@ export class VideoExporter { }; } - const blobData = new Uint8Array(result.data.byteLength); - blobData.set(result.data); + // Returning a temp path (instead of buffering the muxed bytes back into + // the renderer) is what keeps >2 GiB exports off Node's fs.readFile cap. return { success: true, - blob: new Blob([blobData.buffer], { type: "video/mp4" }), + tempFilePath: result.tempPath, metrics: this.buildExportMetrics(), }; } From 3e871b924de020c281c81a36a7eef05e2fc9b0a5 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Sat, 2 May 2026 20:14:54 +1000 Subject: [PATCH 04/19] clean up clip-first timeline editing --- src/components/video-editor/VideoEditor.tsx | 218 ++---------------- .../video-editor/timeline/TimelineEditor.tsx | 176 ++------------ src/components/video-editor/types.test.ts | 39 +++- src/components/video-editor/types.ts | 86 +++++++ .../videoPlayback/videoEventHandlers.test.ts | 25 ++ .../videoPlayback/videoEventHandlers.ts | 4 +- src/lib/shortcuts.ts | 6 - 7 files changed, 189 insertions(+), 365 deletions(-) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 6c93876e..2749de16 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -147,7 +147,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, @@ -159,10 +158,12 @@ import { extendAutoFullTrackClip, type FigureData, getClipSourceEndMs, + mapSourceTimeToTimelineTime as resolveSourceTimeToTimelineTime, + mapTimelineTimeToSourceTime as resolveTimelineTimeToSourceTime, type Padding, - type PlaybackSpeed, type SpeedRegion, type TrimRegion, + trimsToClips, type WebcamOverlaySettings, type ZoomDepth, type ZoomFocus, @@ -184,9 +185,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; }; @@ -653,11 +652,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([]); @@ -731,9 +728,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(); @@ -1567,9 +1562,7 @@ export default function VideoEditor() { audioRegions, autoCaptions, selectedZoomId, - selectedTrimId, selectedClipId, - selectedSpeedId, selectedAnnotationId, selectedAudioId, }; @@ -1581,9 +1574,7 @@ export default function VideoEditor() { audioRegions, autoCaptions, selectedZoomId, - selectedTrimId, selectedClipId, - selectedSpeedId, selectedAnnotationId, selectedAudioId, ]); @@ -1599,9 +1590,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); @@ -1613,10 +1602,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), @@ -1748,9 +1733,7 @@ export default function VideoEditor() { setGifSizePreset(normalizedEditor.gifSizePreset); setSelectedZoomId(null); - setSelectedTrimId(null); setSelectedClipId(null); - setSelectedSpeedId(null); setSelectedAnnotationId(null); setSelectedAudioId(null); @@ -1758,18 +1741,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), @@ -2796,6 +2771,11 @@ export default function VideoEditor() { const id = `clip-${nextClipIdRef.current++}`; autoFullTrackClipIdRef.current = id; autoFullTrackClipEndMsRef.current = totalMs; + if (trimRegions.length > 0) { + setClipRegions(trimsToClips(trimRegions, totalMs)); + clipInitializedRef.current = true; + return; + } setClipRegions([{ id, startMs: 0, endMs: totalMs, speed: 1 }]); } clipInitializedRef.current = true; @@ -2812,7 +2792,7 @@ export default function VideoEditor() { autoFullTrackClipEndMsRef.current = totalMs; setClipRegions(extendedClipRegions); - }, [duration, clipRegions]); + }, [duration, clipRegions, trimRegions]); // Derive trimRegions from clipRegions so export/playback pipelines stay unchanged useEffect(() => { @@ -2822,27 +2802,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], ); @@ -2910,7 +2875,6 @@ export default function VideoEditor() { setSelectedZoomId(id); if (id) { setActiveEffectSection("zoom"); - setSelectedTrimId(null); setSelectedAnnotationId(null); setSelectedAudioId(null); } else { @@ -2918,20 +2882,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); } }, []); @@ -2954,7 +2908,6 @@ export default function VideoEditor() { } setZoomRegions((prev) => [...prev, newRegion]); setSelectedZoomId(id); - setSelectedTrimId(null); setSelectedAnnotationId(null); extensionHost.emitEvent({ type: "timeline:region-added", @@ -3048,19 +3001,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) => @@ -3071,21 +3011,7 @@ export default function VideoEditor() { endMs: Math.round(span.end), } : region, - ), - ); - }, []); - - 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, - ), + ), ); }, []); @@ -3141,16 +3067,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) { @@ -3329,62 +3245,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); } }, []); @@ -3401,9 +3266,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) => { @@ -3455,18 +3318,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 @@ -3485,7 +3336,6 @@ export default function VideoEditor() { setAnnotationRegions((prev) => [...prev, newRegion]); setSelectedAnnotationId(id); setSelectedZoomId(null); - setSelectedTrimId(null); }, []); const handleAnnotationSpanChange = useCallback( @@ -3686,12 +3536,6 @@ export default function VideoEditor() { } }, [selectedZoomId, zoomRegions]); - useEffect(() => { - if (selectedTrimId && !trimRegions.some((region) => region.id === selectedTrimId)) { - setSelectedTrimId(null); - } - }, [selectedTrimId, trimRegions]); - useEffect(() => { if ( selectedAnnotationId && @@ -3701,12 +3545,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); @@ -3891,7 +3729,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; @@ -3925,7 +3763,7 @@ export default function VideoEditor() { } } } - }, [isPlaying, currentTime, audioRegions, speedRegions]); + }, [isPlaying, currentTime, audioRegions, effectiveSpeedRegions]); useEffect(() => { if (previewSourceAudioFallbackPaths.length === 0) { @@ -3933,7 +3771,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; @@ -3987,8 +3825,8 @@ export default function VideoEditor() { isPlaying, previewSourceAudioFallbackPaths, sourceAudioFallbackStartDelayMsByPath, - speedRegions, - ]); + effectiveSpeedRegions, + ]); const showExportSuccessToast = useCallback((filePath: string) => { toast.success(`Exported successfully to ${filePath}`, { @@ -5349,8 +5187,6 @@ export default function VideoEditor() { selectedZoomId && handleZoomModeChange(mode) } onZoomDelete={handleZoomDelete} - selectedTrimId={selectedTrimId} - onTrimDelete={handleTrimDelete} selectedClipId={selectedClipId} selectedClipSpeed={ selectedClipId @@ -5469,15 +5305,6 @@ export default function VideoEditor() { } onAnnotationBlurColorChange={handleAnnotationBlurColorChange} onAnnotationDelete={handleAnnotationDelete} - selectedSpeedId={selectedSpeedId} - selectedSpeedValue={ - selectedSpeedId - ? (speedRegions.find((r) => r.id === selectedSpeedId) - ?.speed ?? null) - : null - } - onSpeedChange={handleSpeedChange} - onSpeedDelete={handleSpeedDelete} /> )}
@@ -5885,22 +5712,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/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 32643bec..6528c13b 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -1048,11 +1048,11 @@ const TimelineEditor = forwardRef( selectedZoomId, onSelectZoom, trimRegions = [], - onTrimAdded, + onTrimAdded: _onTrimAdded, onTrimSpanChange, - onTrimDelete, - selectedTrimId, - onSelectTrim, + onTrimDelete: _onTrimDelete, + selectedTrimId: _selectedTrimId, + onSelectTrim: _onSelectTrim, clipRegions = [], onClipSplit, onClipSpanChange, @@ -1066,11 +1066,11 @@ const TimelineEditor = forwardRef( selectedAnnotationId, onSelectAnnotation, speedRegions = [], - onSpeedAdded, + onSpeedAdded: _onSpeedAdded, onSpeedSpanChange, - onSpeedDelete, - selectedSpeedId, - onSelectSpeed, + onSpeedDelete: _onSpeedDelete, + selectedSpeedId: _selectedSpeedId, + onSelectSpeed: _onSelectSpeed, audioRegions = [], onAudioAdded, onAudioSpanChange, @@ -1212,13 +1212,6 @@ const TimelineEditor = forwardRef( onSelectZoom(null); }, [selectedZoomId, onZoomDelete, onSelectZoom]); - // Delete selected trim item - const deleteSelectedTrim = useCallback(() => { - if (!selectedTrimId || !onTrimDelete || !onSelectTrim) return; - onTrimDelete(selectedTrimId); - onSelectTrim(null); - }, [selectedTrimId, onTrimDelete, onSelectTrim]); - const deleteSelectedClip = useCallback(() => { if (!selectedClipId || !onClipDelete || !onSelectClip) return; onClipDelete(selectedClipId); @@ -1231,12 +1224,6 @@ const TimelineEditor = forwardRef( onSelectAnnotation(null); }, [selectedAnnotationId, onAnnotationDelete, onSelectAnnotation]); - const deleteSelectedSpeed = useCallback(() => { - if (!selectedSpeedId || !onSpeedDelete || !onSelectSpeed) return; - onSpeedDelete(selectedSpeedId); - onSelectSpeed(null); - }, [selectedSpeedId, onSpeedDelete, onSelectSpeed]); - const deleteSelectedAudio = useCallback(() => { if (!selectedAudioId || !onAudioDelete || !onSelectAudio) return; onAudioDelete(selectedAudioId); @@ -1245,42 +1232,32 @@ const TimelineEditor = forwardRef( const clearSelectedBlocks = useCallback(() => { onSelectZoom(null); - onSelectTrim?.(null); onSelectClip?.(null); onSelectAnnotation?.(null); - onSelectSpeed?.(null); onSelectAudio?.(null); setSelectAllBlocksActive(false); }, [ onSelectAnnotation, onSelectAudio, onSelectClip, - onSelectSpeed, - onSelectTrim, onSelectZoom, ]); const hasAnyTimelineBlocks = zoomRegions.length > 0 || - trimRegions.length > 0 || clipRegions.length > 0 || annotationRegions.length > 0 || - speedRegions.length > 0 || audioRegions.length > 0; const deleteAllBlocks = useCallback(() => { const zoomIds = zoomRegions.map((region) => region.id); - const trimIds = trimRegions.map((region) => region.id); const clipIds = clipRegions.map((region) => region.id); const annotationIds = annotationRegions.map((region) => region.id); - const speedIds = speedRegions.map((region) => region.id); const audioIds = audioRegions.map((region) => region.id); zoomIds.forEach((id) => onZoomDelete(id)); - trimIds.forEach((id) => onTrimDelete?.(id)); clipIds.forEach((id) => onClipDelete?.(id)); annotationIds.forEach((id) => onAnnotationDelete?.(id)); - speedIds.forEach((id) => onSpeedDelete?.(id)); audioIds.forEach((id) => onAudioDelete?.(id)); clearSelectedBlocks(); @@ -1293,11 +1270,7 @@ const TimelineEditor = forwardRef( onAnnotationDelete, onAudioDelete, onClipDelete, - onSpeedDelete, - onTrimDelete, onZoomDelete, - speedRegions, - trimRegions, zoomRegions, ]); @@ -1309,14 +1282,6 @@ const TimelineEditor = forwardRef( [onSelectZoom], ); - const handleSelectTrim = useCallback( - (id: string | null) => { - setSelectAllBlocksActive(false); - onSelectTrim?.(id); - }, - [onSelectTrim], - ); - const handleSelectClip = useCallback( (id: string | null) => { setSelectAllBlocksActive(false); @@ -1333,14 +1298,6 @@ const TimelineEditor = forwardRef( [onSelectAnnotation], ); - const handleSelectSpeed = useCallback( - (id: string | null) => { - setSelectAllBlocksActive(false); - onSelectSpeed?.(id); - }, - [onSelectSpeed], - ); - const handleSelectAudio = useCallback( (id: string | null) => { setSelectAllBlocksActive(false); @@ -1519,17 +1476,26 @@ const TimelineEditor = forwardRef( } const startPos = Math.max(0, Math.min(startMs, totalMs)); + const activeClip = clipRegions.find( + (clip) => startPos >= clip.startMs && startPos < clip.endMs, + ); + if (!activeClip) { + return false; + } + const sorted = [...zoomRegions].sort((a, b) => a.startMs - b.startMs); const nextRegion = sorted.find((region) => region.startMs > startPos); - const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos; + const gapToNextClipEdge = activeClip.endMs - startPos; + const gapToNextRegion = nextRegion ? nextRegion.startMs - startPos : gapToNextClipEdge; + const availableDuration = Math.min(gapToNextClipEdge, gapToNextRegion); const isOverlapping = sorted.some( (region) => startPos >= region.startMs && startPos < region.endMs, ); - return !isOverlapping && gapToNext >= defaultDuration; + return !isOverlapping && availableDuration >= defaultDuration; }, - [videoDuration, totalMs, zoomRegions, defaultRegionDurationMs], + [videoDuration, totalMs, zoomRegions, defaultRegionDurationMs, clipRegions], ); const addZoomAtMs = useCallback( @@ -1547,7 +1513,7 @@ const TimelineEditor = forwardRef( if (!canPlaceZoomAtMs(startPos)) { toast.error("Cannot place zoom here", { description: - "Zoom already exists at this location or not enough space available.", + "Place zooms inside a kept clip and leave enough room before the clip ends.", }); return; } @@ -1649,46 +1615,6 @@ const TimelineEditor = forwardRef( handleSuggestZooms(); }, [autoSuggestZoomsTrigger, handleSuggestZooms, onAutoSuggestZoomsConsumed]); - const handleAddTrim = useCallback(() => { - if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onTrimAdded) { - return; - } - - const defaultDuration = Math.min(defaultRegionDurationMs, totalMs); - if (defaultDuration <= 0) { - return; - } - - // Always place trim at playhead - const startPos = Math.max(0, Math.min(currentTimeMs, totalMs)); - // Find the next trim region after the playhead - const sorted = [...trimRegions].sort((a, b) => a.startMs - b.startMs); - const nextRegion = sorted.find((region) => region.startMs > startPos); - const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos; - - // Check if playhead is inside any trim region - const isOverlapping = sorted.some( - (region) => startPos >= region.startMs && startPos < region.endMs, - ); - if (isOverlapping || gapToNext <= 0) { - toast.error("Cannot place trim here", { - description: - "Trim already exists at this location or not enough space available.", - }); - return; - } - - const actualDuration = Math.min(defaultRegionDurationMs, gapToNext); - onTrimAdded({ start: startPos, end: startPos + actualDuration }); - }, [ - videoDuration, - totalMs, - currentTimeMs, - trimRegions, - onTrimAdded, - defaultRegionDurationMs, - ]); - const handleSplitClip = useCallback(() => { if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onClipSplit) { return; @@ -1696,46 +1622,6 @@ const TimelineEditor = forwardRef( onClipSplit(currentTimeMs); }, [videoDuration, totalMs, currentTimeMs, onClipSplit]); - const handleAddSpeed = useCallback(() => { - if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onSpeedAdded) { - return; - } - - const defaultDuration = Math.min(defaultRegionDurationMs, totalMs); - if (defaultDuration <= 0) { - return; - } - - // Always place speed region at playhead - const startPos = Math.max(0, Math.min(currentTimeMs, totalMs)); - // Find the next speed region after the playhead - const sorted = [...speedRegions].sort((a, b) => a.startMs - b.startMs); - const nextRegion = sorted.find((region) => region.startMs > startPos); - const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos; - - // Check if playhead is inside any speed region - const isOverlapping = sorted.some( - (region) => startPos >= region.startMs && startPos < region.endMs, - ); - if (isOverlapping || gapToNext <= 0) { - toast.error("Cannot place speed here", { - description: - "Speed region already exists at this location or not enough space available.", - }); - return; - } - - const actualDuration = Math.min(defaultRegionDurationMs, gapToNext); - onSpeedAdded({ start: startPos, end: startPos + actualDuration }); - }, [ - videoDuration, - totalMs, - currentTimeMs, - speedRegions, - onSpeedAdded, - defaultRegionDurationMs, - ]); - const handleAddAudio = useCallback( async (preferredTrackIndex?: number) => { if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAudioAdded) { @@ -1918,18 +1804,12 @@ const TimelineEditor = forwardRef( if (matchesShortcut(e, keyShortcuts.addZoom, isMac)) { handleAddZoom(); } - if (matchesShortcut(e, keyShortcuts.addTrim, isMac)) { - handleAddTrim(); - } if (matchesShortcut(e, keyShortcuts.splitClip, isMac)) { handleSplitClip(); } if (matchesShortcut(e, keyShortcuts.addAnnotation, isMac)) { handleAddAnnotation(); } - if (matchesShortcut(e, keyShortcuts.addSpeed, isMac)) { - handleAddSpeed(); - } // Tab: Cycle through overlapping annotations at current time if (e.key === "Tab" && annotationRegions.length > 0) { @@ -1970,14 +1850,10 @@ const TimelineEditor = forwardRef( deleteSelectedKeyframe(); } else if (selectedZoomId) { deleteSelectedZoom(); - } else if (selectedTrimId) { - deleteSelectedTrim(); } else if (selectedClipId) { deleteSelectedClip(); } else if (selectedAnnotationId) { deleteSelectedAnnotation(); - } else if (selectedSpeedId) { - deleteSelectedSpeed(); } else if (selectedAudioId) { deleteSelectedAudio(); } @@ -1988,24 +1864,18 @@ const TimelineEditor = forwardRef( }, [ addKeyframe, handleAddZoom, - handleAddTrim, handleSplitClip, handleAddAnnotation, - handleAddSpeed, deleteAllBlocks, deleteSelectedKeyframe, deleteSelectedZoom, - deleteSelectedTrim, deleteSelectedClip, deleteSelectedAnnotation, - deleteSelectedSpeed, deleteSelectedAudio, selectedKeyframeId, selectedZoomId, - selectedTrimId, selectedClipId, selectedAnnotationId, - selectedSpeedId, selectedAudioId, annotationRegions, currentTimeMs, @@ -2479,16 +2349,12 @@ const TimelineEditor = forwardRef( onAddZoomAtMs={addZoomAtMs} canPlaceZoomAtMs={canPlaceZoomAtMs} onSelectZoom={handleSelectZoom} - onSelectTrim={handleSelectTrim} onSelectClip={handleSelectClip} onSelectAnnotation={handleSelectAnnotation} - onSelectSpeed={handleSelectSpeed} onSelectAudio={handleSelectAudio} selectedZoomId={selectedZoomId} - selectedTrimId={selectedTrimId} selectedClipId={selectedClipId} selectedAnnotationId={selectedAnnotationId} - selectedSpeedId={selectedSpeedId} selectedAudioId={selectedAudioId} selectAllBlocksActive={selectAllBlocksActive} onClearBlockSelection={clearSelectedBlocks} diff --git a/src/components/video-editor/types.test.ts b/src/components/video-editor/types.test.ts index 1d4412f1..12a63cb3 100644 --- a/src/components/video-editor/types.test.ts +++ b/src/components/video-editor/types.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; -import { extendAutoFullTrackClip } from "./types"; +import { + extendAutoFullTrackClip, + findClipAtTimelineTime, + mapSourceTimeToTimelineTime, + mapTimelineTimeToSourceTime, +} from "./types"; describe("extendAutoFullTrackClip", () => { it("extends the default full-track clip when metadata duration grows", () => { @@ -105,3 +110,35 @@ describe("extendAutoFullTrackClip", () => { ).toBeNull(); }); }); + +describe("clip timeline mapping", () => { + const clips = [ + { id: "clip-1", startMs: 0, endMs: 4_000, speed: 1 }, + { id: "clip-2", startMs: 6_000, endMs: 8_000, speed: 2 }, + ]; + + it("maps kept timeline time into source time", () => { + expect(mapTimelineTimeToSourceTime(1_500, clips)).toBe(1_500); + expect(mapTimelineTimeToSourceTime(7_000, clips)).toBe(8_000); + }); + + it("snaps timeline gaps to the nearest clip edge", () => { + expect(mapTimelineTimeToSourceTime(4_300, clips)).toBe(4_000); + expect(mapTimelineTimeToSourceTime(5_700, clips)).toBe(6_000); + }); + + it("maps kept source time back into timeline time", () => { + expect(mapSourceTimeToTimelineTime(1_500, clips)).toBe(1_500); + expect(mapSourceTimeToTimelineTime(8_000, clips)).toBe(7_000); + }); + + it("snaps removed source gaps to the nearest kept boundary", () => { + expect(mapSourceTimeToTimelineTime(4_200, clips)).toBe(4_000); + expect(mapSourceTimeToTimelineTime(5_900, clips)).toBe(6_000); + }); + + it("finds clips only inside visible kept spans", () => { + expect(findClipAtTimelineTime(500, clips)?.id).toBe("clip-1"); + expect(findClipAtTimelineTime(5_000, clips)).toBeNull(); + }); +}); diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index ec29d812..088a0f7d 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -155,6 +155,92 @@ export function getClipSourceEndMs(clip: ClipRegion): number { return Math.round(clip.startMs + displayDurationMs * speed); } +export function sortClipRegions(clips: ClipRegion[]): ClipRegion[] { + return [...clips].sort((left, right) => left.startMs - right.startMs); +} + +function getSafeClipSpeed(clip: ClipRegion) { + return Number.isFinite(clip.speed) && clip.speed > 0 ? clip.speed : 1; +} + +function clampToNearestClipBoundary( + timeMs: number, + clips: ClipRegion[], + kind: "timeline" | "source", +) { + let nearestTimeMs = Math.round(timeMs); + let nearestDistance = Number.POSITIVE_INFINITY; + + for (const clip of clips) { + const boundaries = + kind === "timeline" + ? [clip.startMs, clip.endMs] + : [clip.startMs, getClipSourceEndMs(clip)]; + + for (const boundary of boundaries) { + const distance = Math.abs(timeMs - boundary); + if (distance < nearestDistance) { + nearestDistance = distance; + nearestTimeMs = Math.round(boundary); + } + } + } + + return nearestTimeMs; +} + +export function mapTimelineTimeToSourceTime(timeMs: number, clips: ClipRegion[]): number { + const roundedTimeMs = Math.round(timeMs); + const sortedClips = sortClipRegions(clips); + + for (const clip of sortedClips) { + if (roundedTimeMs < clip.startMs || roundedTimeMs > clip.endMs) { + continue; + } + + return Math.round( + clip.startMs + (roundedTimeMs - clip.startMs) * getSafeClipSpeed(clip), + ); + } + + if (sortedClips.length === 0) { + return roundedTimeMs; + } + + return clampToNearestClipBoundary(roundedTimeMs, sortedClips, "timeline"); +} + +export function mapSourceTimeToTimelineTime(timeMs: number, clips: ClipRegion[]): number { + const roundedTimeMs = Math.round(timeMs); + const sortedClips = sortClipRegions(clips); + + for (const clip of sortedClips) { + const sourceEndMs = getClipSourceEndMs(clip); + if (roundedTimeMs < clip.startMs || roundedTimeMs > sourceEndMs) { + continue; + } + + return Math.round( + clip.startMs + (roundedTimeMs - clip.startMs) / getSafeClipSpeed(clip), + ); + } + + if (sortedClips.length === 0) { + return roundedTimeMs; + } + + return clampToNearestClipBoundary(roundedTimeMs, sortedClips, "source"); +} + +export function findClipAtTimelineTime(timeMs: number, clips: ClipRegion[]): ClipRegion | null { + const roundedTimeMs = Math.round(timeMs); + return ( + sortClipRegions(clips).find( + (clip) => roundedTimeMs >= clip.startMs && roundedTimeMs < clip.endMs, + ) ?? null + ); +} + export function extendAutoFullTrackClip( clips: ClipRegion[], autoClipId: string | null, diff --git a/src/components/video-editor/videoPlayback/videoEventHandlers.test.ts b/src/components/video-editor/videoPlayback/videoEventHandlers.test.ts index aca357f9..44414c53 100644 --- a/src/components/video-editor/videoPlayback/videoEventHandlers.test.ts +++ b/src/components/video-editor/videoPlayback/videoEventHandlers.test.ts @@ -151,4 +151,29 @@ describe("createVideoEventHandlers", () => { handlers.dispose(); expect(cancelVideoFrameCallback).toHaveBeenCalledWith(23); }); + + it("skips removed footage after a paused seek", () => { + const video = createMockVideo({ + currentTime: 1.25, + paused: true, + }); + const onTimeUpdate = vi.fn(); + const handlers = createVideoEventHandlers({ + video, + isSeekingRef: createMutableRef(true), + isPlayingRef: createMutableRef(false), + allowPlaybackRef: createMutableRef(true), + currentTimeRef: createMutableRef(0), + timeUpdateAnimationRef: createMutableRef(null), + onPlayStateChange: vi.fn(), + onTimeUpdate, + trimRegionsRef: createMutableRef([{ id: "trim-1", startMs: 1000, endMs: 2000 }]), + speedRegionsRef: createMutableRef([]), + }); + + handlers.handleSeeked(); + + expect(video.currentTime).toBe(2); + expect(onTimeUpdate).toHaveBeenLastCalledWith(2); + }); }); diff --git a/src/components/video-editor/videoPlayback/videoEventHandlers.ts b/src/components/video-editor/videoPlayback/videoEventHandlers.ts index fd544654..113d420b 100644 --- a/src/components/video-editor/videoPlayback/videoEventHandlers.ts +++ b/src/components/video-editor/videoPlayback/videoEventHandlers.ts @@ -167,8 +167,8 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) { const currentTimeMs = video.currentTime * 1000; const activeTrimRegion = findActiveTrimRegion(currentTimeMs); - // If we seeked into a trim region while playing, skip to the end - if (activeTrimRegion && isPlayingRef.current && !video.paused) { + // Never leave the preview parked on removed footage after a seek. + if (activeTrimRegion) { skipPastTrimRegion(activeTrimRegion); } else { emitTime(video.currentTime); diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index f1bc84b4..d2d9828c 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -1,8 +1,6 @@ export const SHORTCUT_ACTIONS = [ "addZoom", - "addTrim", "splitClip", - "addSpeed", "addAnnotation", "addKeyframe", "deleteSelected", @@ -76,9 +74,7 @@ export function findConflict( export const DEFAULT_SHORTCUTS: ShortcutsConfig = { addZoom: { key: "z" }, - addTrim: { key: "t" }, splitClip: { key: "c" }, - addSpeed: { key: "s" }, addAnnotation: { key: "a" }, addKeyframe: { key: "f" }, deleteSelected: { key: "d", ctrl: true }, @@ -87,9 +83,7 @@ export const DEFAULT_SHORTCUTS: ShortcutsConfig = { export const SHORTCUT_LABELS: Record = { addZoom: "Add Zoom", - addTrim: "Add Trim", splitClip: "Split Clip", - addSpeed: "Add Speed", addAnnotation: "Add Annotation", addKeyframe: "Add Keyframe", deleteSelected: "Delete Selected", From 6cc83b31e969119f43d2c01e9a031c73f0bccd89 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Mon, 4 May 2026 17:28:10 +1000 Subject: [PATCH 05/19] feat: add writable cursor telemetry API --- electron/electron-env.d.ts | 40 ++++++++--- electron/ipc/cursor/telemetry.test.ts | 54 +++++++++++++++ electron/ipc/cursor/telemetry.ts | 76 +++++++++++++++++++- electron/ipc/register/recording.ts | 99 ++++++++++----------------- electron/preload.ts | 36 +++++++--- 5 files changed, 222 insertions(+), 83 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 8b634980..d7ba1fe9 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; @@ -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..9191542a 100644 --- a/electron/ipc/cursor/telemetry.ts +++ b/electron/ipc/cursor/telemetry.ts @@ -28,6 +28,78 @@ 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 ?? []) + : []; + + return samples + .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 +240,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/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/preload.ts b/electron/preload.ts index c9e464f2..acc756e8 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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); }, From 0abc7a30980d202d542c247d72a6ce7ac18ddc74 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Mon, 4 May 2026 18:12:47 +1000 Subject: [PATCH 06/19] feat: bundle cursor motion and temporal blur updates --- electron/electron-env.d.ts | 40 +- electron/ipc/cursor/telemetry.test.ts | 54 ++ electron/ipc/cursor/telemetry.ts | 76 ++- electron/ipc/register/project.ts | 16 +- electron/ipc/register/recording.ts | 99 +-- electron/ipc/types.ts | 1 + electron/preload.ts | 36 +- src/components/video-editor/SettingsPanel.tsx | 330 +++++---- src/components/video-editor/SliderControl.tsx | 107 ++- src/components/video-editor/VideoEditor.tsx | 640 +----------------- src/components/video-editor/VideoPlayback.tsx | 526 ++++---------- .../video-editor/cursorMotionPresets.ts | 51 ++ .../video-editor/editorPreferences.ts | 70 +- .../video-editor/projectPersistence.ts | 59 +- .../video-editor/timeline/TimelineEditor.tsx | 176 +---- src/components/video-editor/types.test.ts | 39 +- src/components/video-editor/types.ts | 87 +++ .../videoPlayback/cursorFollowCamera.test.ts | 110 +++ .../videoPlayback/cursorFollowCamera.ts | 118 +++- .../videoPlayback/cursorRenderer.ts | 43 +- .../videoPlayback/motionSmoothing.ts | 55 +- .../videoPlayback/videoEventHandlers.test.ts | 25 + .../videoPlayback/videoEventHandlers.ts | 4 +- .../videoPlayback/zoomAnimation.test.ts | 27 +- .../videoPlayback/zoomRegionUtils.ts | 25 +- .../videoPlayback/zoomTransform.ts | 229 ++++++- src/hooks/useScreenRecorder.ts | 156 ++--- src/i18n/locales/en/settings.json | 28 +- src/i18n/locales/zh-CN/settings.json | 19 +- src/lib/exporter/forwardFrameSource.ts | 17 + src/lib/exporter/frameRenderer.test.ts | 7 +- src/lib/exporter/frameRenderer.ts | 486 ++++++++++++- src/lib/exporter/gifExporter.ts | 15 + src/lib/exporter/modernFrameRenderer.test.ts | 24 +- src/lib/exporter/modernFrameRenderer.ts | 485 ++++++++++++- src/lib/exporter/modernVideoExporter.ts | 28 +- src/lib/exporter/temporalMotionBlur.test.ts | 79 +++ src/lib/exporter/temporalMotionBlur.ts | 134 ++++ src/lib/exporter/videoExporter.ts | 14 + src/lib/shortcuts.ts | 6 - 40 files changed, 2838 insertions(+), 1703 deletions(-) create mode 100644 src/components/video-editor/cursorMotionPresets.ts create mode 100644 src/components/video-editor/videoPlayback/cursorFollowCamera.test.ts create mode 100644 src/lib/exporter/temporalMotionBlur.test.ts create mode 100644 src/lib/exporter/temporalMotionBlur.ts diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 8b634980..d7ba1fe9 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; @@ -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..9191542a 100644 --- a/electron/ipc/cursor/telemetry.ts +++ b/electron/ipc/cursor/telemetry.ts @@ -28,6 +28,78 @@ 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 ?? []) + : []; + + return samples + .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 +240,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/register/project.ts b/electron/ipc/register/project.ts index 570d7b58..a18d263d 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,7 +541,12 @@ export function registerProjectHandlers() { timeOffsetMs: 0, } - setCurrentRecordingSession(resolvedSession) + setCurrentRecordingSession({ + ...resolvedSession, + hideOverlayCursorByDefault: + normalizeBoolean(options?.hideOverlayCursorByDefault) || + normalizeBoolean(resolvedSession.hideOverlayCursorByDefault), + }) await replaceApprovedSessionLocalReadPaths([ resolvedSession.videoPath, resolvedSession.webcamPath, @@ -553,13 +562,14 @@ export function registerProjectHandlers() { return { success: true, webcamPath: resolvedSession.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..acc756e8 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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 9b55f272..0b680c20 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,10 @@ import { getRenderableVideoUrl, getWallpaperThumbnailUrl, } from "@/lib/assetPath"; +import { + TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT, + TEMPORAL_MOTION_BLUR_DEFAULT_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 +45,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 +60,6 @@ import type { EditorEffectSection, FigureData, Padding, - PlaybackSpeed, WebcamOverlaySettings, WebcamPositionPreset, ZoomDepth, @@ -62,7 +73,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 +84,9 @@ 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_SMOOTHNESS, + DEFAULT_ZOOM_OUT_DURATION_MS, } from "./types"; import { fromCursorSwaySliderValue, toCursorSwaySliderValue } from "./videoPlayback/cursorSway"; import { isZeroPadding } from "./videoPlayback/layoutUtils"; @@ -374,6 +385,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; @@ -385,8 +456,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; @@ -401,8 +470,11 @@ interface SettingsPanelProps { onShadowChange?: (intensity: number) => void; backgroundBlur?: number; onBackgroundBlurChange?: (amount: number) => void; - zoomMotionBlur?: number; - onZoomMotionBlurChange?: (amount: number) => void; + 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; @@ -433,8 +505,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; @@ -482,10 +558,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 }> = [ @@ -763,8 +835,6 @@ export function SettingsPanel({ selectedZoomMode, onZoomModeChange, onZoomDelete, - selectedTrimId, - onTrimDelete, selectedClipId, selectedClipSpeed, selectedClipMuted, @@ -779,12 +849,17 @@ export function SettingsPanel({ onShadowChange, backgroundBlur = 0, onBackgroundBlurChange, - zoomMotionBlur = 0, - onZoomMotionBlurChange, + onZoomTemporalMotionBlurChange, + onZoomMotionBlurSampleCountChange, + 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, @@ -795,8 +870,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, @@ -842,10 +921,6 @@ export function SettingsPanel({ onClearAutoCaptions, onDownloadWhisperSmallModel, onDeleteWhisperSmallModel, - selectedSpeedId, - selectedSpeedValue, - onSpeedChange, - onSpeedDelete, }: SettingsPanelProps) { const tSettings = useScopedT("settings"); const { locale, setLocale, t } = useI18n(); @@ -1326,12 +1401,6 @@ export function SettingsPanel({
); - const handleTrimDeleteClick = () => { - if (selectedTrimId && onTrimDelete) { - onTrimDelete(selectedTrimId); - } - }; - const crop = cropRegion ?? { x: 0, y: 0, @@ -1378,8 +1447,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); }; @@ -1389,17 +1463,66 @@ 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.zoomSmoothness === DEFAULT_ZOOM_SMOOTHNESS && + 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; }; @@ -1902,8 +2025,14 @@ export function SettingsPanel({ className="text-[10px] text-[#2563EB] transition-opacity hover:opacity-80" title={ padding.linked === false - ? tSettings("effects.paddingAdvancedHide", "Hide advanced padding controls") - : tSettings("effects.paddingAdvancedShow", "Show advanced padding controls") + ? tSettings( + "effects.paddingAdvancedHide", + "Hide advanced padding controls", + ) + : tSettings( + "effects.paddingAdvancedShow", + "Show advanced padding controls", + ) } > {tSettings("effects.paddingAdvanced", "Advanced")} @@ -2426,6 +2555,15 @@ export function SettingsPanel({
+
+ +
+
{t("editor.keyboardShortcuts.title")}
@@ -2546,29 +2684,21 @@ 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.", + )} +
)} - onZoomMotionBlurChange?.(v)} - formatValue={(v) => `${v.toFixed(2)}×`} - parseInput={(text) => parseFloat(text.replace(/×$/, ""))} - /> +
+
+ {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`} +
+
{selectedZoomId && ( -
- )} - - {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 13c3440a..979efb53 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -1,5 +1,4 @@ import { - BookmarkSimple, Check, CaretDown as ChevronDown, CaretUp as ChevronUp, @@ -39,8 +38,6 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { Input } from "@/components/ui/input"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Toaster } from "@/components/ui/sonner"; import { useI18n } from "@/contexts/I18nContext"; import { useShortcuts } from "@/contexts/ShortcutsContext"; @@ -80,7 +77,6 @@ import { getAspectRatioLabel, getAspectRatioValue, } from "@/utils/aspectRatioUtils"; -import { cn } from "@/lib/utils"; import { ExtensionIcon } from "./ExtensionIcon"; const PhCursorFill = (props: { className?: string; weight?: "fill" | "regular" }) => ( @@ -105,18 +101,9 @@ const PhSettings = (props: { className?: string; weight?: "fill" | "regular" }) import { extensionHost } from "@/lib/extensions"; import { resolveAutoCaptionSourcePath } from "./autoCaptionSource"; import { CropControl } from "./CropControl"; -import { updateCaptionCuesForEditedTarget, type CaptionEditTarget } from "./captionEditing"; import { ExportSettingsMenu } from "./ExportSettingsMenu"; import ExtensionManager from "./ExtensionManager"; -import { - loadEditorPreferences, - loadEditorPresets, - saveEditorPreferences, - saveEditorPresets, - serializeEditorPresetSnapshot, - type EditorPreset, - type EditorPresetSnapshot, -} from "./editorPreferences"; +import { loadEditorPreferences, saveEditorPreferences } from "./editorPreferences"; import ProjectBrowserDialog, { type ProjectLibraryEntry } from "./ProjectBrowserDialog"; import { createProjectData, @@ -160,7 +147,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, @@ -172,10 +158,12 @@ import { extendAutoFullTrackClip, type FigureData, getClipSourceEndMs, + mapSourceTimeToTimelineTime as resolveSourceTimeToTimelineTime, + mapTimelineTimeToSourceTime as resolveTimelineTimeToSourceTime, type Padding, - type PlaybackSpeed, type SpeedRegion, type TrimRegion, + trimsToClips, type WebcamOverlaySettings, type ZoomDepth, type ZoomFocus, @@ -197,9 +185,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; }; @@ -615,11 +601,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([]); @@ -682,10 +666,6 @@ export default function VideoEditor() { const [exportedFilePath, setExportedFilePath] = useState(undefined); const [hasPendingExportSave, setHasPendingExportSave] = useState(false); const [lastSavedSnapshot, setLastSavedSnapshot] = useState(null); - const [editorPresets, setEditorPresets] = useState(() => loadEditorPresets()); - const [activeEditorPresetId, setActiveEditorPresetId] = useState(null); - const [presetPopoverOpen, setPresetPopoverOpen] = useState(false); - const [presetNameDraft, setPresetNameDraft] = useState(""); const [showCropModal, setShowCropModal] = useState(false); const [previewVersion, setPreviewVersion] = useState(0); const [isPreviewReady, setIsPreviewReady] = useState(false); @@ -697,9 +677,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(); @@ -765,285 +743,6 @@ export default function VideoEditor() { setHistoryVersion((version) => version + 1); }, []); - const captureEditorPresetSnapshot = useCallback( - (): EditorPresetSnapshot => ({ - wallpaper, - shadowIntensity, - backgroundBlur, - zoomMotionBlur, - connectZooms, - zoomInDurationMs, - zoomInOverlapMs, - zoomOutDurationMs, - connectedZoomGapMs, - connectedZoomDurationMs, - zoomInEasing, - zoomOutEasing, - connectedZoomEasing, - showCursor, - loopCursor, - cursorStyle, - cursorSize, - cursorSmoothing, - cursorMotionBlur, - cursorClickBounce, - cursorClickBounceDuration, - cursorSway, - borderRadius, - padding: { ...padding }, - frame, - webcam: { ...webcam }, - aspectRatio, - exportEncodingMode, - exportBackendPreference, - exportPipelineModel, - exportQuality, - mp4FrameRate, - exportFormat, - gifFrameRate, - gifLoop, - gifSizePreset, - autoCaptionSettings: { ...autoCaptionSettings }, - whisperExecutablePath, - whisperModelPath, - }), - [ - wallpaper, - shadowIntensity, - backgroundBlur, - zoomMotionBlur, - connectZooms, - zoomInDurationMs, - zoomInOverlapMs, - zoomOutDurationMs, - connectedZoomGapMs, - connectedZoomDurationMs, - zoomInEasing, - zoomOutEasing, - connectedZoomEasing, - showCursor, - loopCursor, - cursorStyle, - cursorSize, - cursorSmoothing, - cursorMotionBlur, - cursorClickBounce, - cursorClickBounceDuration, - cursorSway, - borderRadius, - padding, - frame, - webcam, - aspectRatio, - exportEncodingMode, - exportBackendPreference, - exportPipelineModel, - exportQuality, - mp4FrameRate, - exportFormat, - gifFrameRate, - gifLoop, - gifSizePreset, - autoCaptionSettings, - whisperExecutablePath, - whisperModelPath, - ], - ); - - const currentPresetSnapshot = useMemo( - () => captureEditorPresetSnapshot(), - [captureEditorPresetSnapshot], - ); - const currentPresetSignature = useMemo( - () => serializeEditorPresetSnapshot(currentPresetSnapshot), - [currentPresetSnapshot], - ); - const currentEditorPreset = useMemo( - () => editorPresets.find((preset) => preset.id === activeEditorPresetId) ?? null, - [activeEditorPresetId, editorPresets], - ); - - useEffect(() => { - const activePreset = currentEditorPreset; - if ( - activePreset && - serializeEditorPresetSnapshot(activePreset.snapshot) === currentPresetSignature - ) { - return; - } - - const matchingPreset = - editorPresets.find( - (preset) => - serializeEditorPresetSnapshot(preset.snapshot) === currentPresetSignature, - ) ?? null; - const nextActivePresetId = matchingPreset?.id ?? null; - if (nextActivePresetId !== activeEditorPresetId) { - setActiveEditorPresetId(nextActivePresetId); - } - }, [activeEditorPresetId, currentEditorPreset, currentPresetSignature, editorPresets]); - - useEffect(() => { - if (!presetPopoverOpen) { - setPresetNameDraft(""); - } - }, [presetPopoverOpen]); - - const applyEditorPresetSnapshot = useCallback((snapshot: EditorPresetSnapshot) => { - setWallpaper(snapshot.wallpaper); - setShadowIntensity(snapshot.shadowIntensity); - setBackgroundBlur(snapshot.backgroundBlur); - setZoomMotionBlur(snapshot.zoomMotionBlur); - setConnectZooms(snapshot.connectZooms); - setZoomInDurationMs(snapshot.zoomInDurationMs); - setZoomInOverlapMs(snapshot.zoomInOverlapMs); - setZoomOutDurationMs(snapshot.zoomOutDurationMs); - setConnectedZoomGapMs(snapshot.connectedZoomGapMs); - setConnectedZoomDurationMs(snapshot.connectedZoomDurationMs); - setZoomInEasing(snapshot.zoomInEasing); - setZoomOutEasing(snapshot.zoomOutEasing); - setConnectedZoomEasing(snapshot.connectedZoomEasing); - setShowCursor(snapshot.showCursor); - setLoopCursor(snapshot.loopCursor); - setCursorStyle(snapshot.cursorStyle); - setCursorSize(snapshot.cursorSize); - setCursorSmoothing(snapshot.cursorSmoothing); - setCursorMotionBlur(snapshot.cursorMotionBlur); - setCursorClickBounce(snapshot.cursorClickBounce); - setCursorClickBounceDuration(snapshot.cursorClickBounceDuration); - setCursorSway(snapshot.cursorSway); - setBorderRadius(snapshot.borderRadius); - setPadding({ ...snapshot.padding }); - setFrame(snapshot.frame); - setWebcam({ ...snapshot.webcam }); - setAspectRatio(snapshot.aspectRatio); - setExportEncodingMode(snapshot.exportEncodingMode); - setExportBackendPreference(snapshot.exportBackendPreference); - setExportPipelineModel(snapshot.exportPipelineModel); - setExportQuality(snapshot.exportQuality); - setMp4FrameRate(snapshot.mp4FrameRate); - setExportFormat(snapshot.exportFormat); - setGifFrameRate(snapshot.gifFrameRate); - setGifLoop(snapshot.gifLoop); - setGifSizePreset(snapshot.gifSizePreset); - setAutoCaptionSettings({ ...snapshot.autoCaptionSettings }); - setWhisperExecutablePath(snapshot.whisperExecutablePath); - setWhisperModelPath(snapshot.whisperModelPath); - }, []); - - const handleApplyEditorPreset = useCallback( - (presetId: string) => { - const preset = editorPresets.find((item) => item.id === presetId); - if (!preset) { - return; - } - - setActiveEditorPresetId(preset.id); - applyEditorPresetSnapshot(preset.snapshot); - toast.success( - t("editor.presets.toasts.applied", "Applied preset \"{{name}}\"", { - name: preset.name, - }), - ); - }, - [applyEditorPresetSnapshot, editorPresets, t], - ); - - const handleSaveEditorPreset = useCallback( - (name: string) => { - const normalizedName = name.trim().replace(/\s+/g, " "); - if (normalizedName.length === 0) { - toast.error(t("editor.presets.errors.nameRequired", "Enter a preset name.")); - return false; - } - - const hasDuplicateName = editorPresets.some( - (preset) => preset.name.toLocaleLowerCase() === normalizedName.toLocaleLowerCase(), - ); - if (hasDuplicateName) { - toast.error( - t( - "editor.presets.errors.duplicateName", - "A preset with that name already exists.", - ), - ); - return false; - } - - const snapshot = captureEditorPresetSnapshot(); - const timestamp = new Date().toISOString(); - const nextPreset: EditorPreset = { - id: crypto.randomUUID(), - name: normalizedName, - createdAt: timestamp, - updatedAt: timestamp, - snapshot, - }; - const nextPresets = [ - nextPreset, - ...editorPresets, - ]; - - if (!saveEditorPresets(nextPresets)) { - toast.error( - t( - "editor.presets.errors.saveFailed", - "Could not save that preset. Check your browser storage settings and try again.", - ), - ); - return false; - } - - setEditorPresets(nextPresets); - setActiveEditorPresetId(nextPreset.id); - toast.success( - t("editor.presets.toasts.saved", "Saved preset \"{{name}}\"", { - name: normalizedName, - }), - ); - return true; - }, - [captureEditorPresetSnapshot, editorPresets, t], - ); - - const handleDeleteEditorPreset = useCallback( - (presetId: string) => { - const preset = editorPresets.find((item) => item.id === presetId); - if (!preset) { - return; - } - - const nextPresets = editorPresets.filter((item) => item.id !== presetId); - if (!saveEditorPresets(nextPresets)) { - toast.error( - t( - "editor.presets.errors.deleteFailed", - "Could not delete that preset. Check your browser storage settings and try again.", - ), - ); - return; - } - - setEditorPresets(nextPresets); - if (preset.id === activeEditorPresetId) { - setActiveEditorPresetId(null); - } - toast.success( - t("editor.presets.toasts.deleted", "Deleted preset \"{{name}}\"", { - name: preset.name, - }), - ); - }, - [activeEditorPresetId, editorPresets, t], - ); - - const handleSavePresetSubmit = useCallback(() => { - const didSave = handleSaveEditorPreset(presetNameDraft); - if (didSave) { - setPresetNameDraft(""); - } - }, [handleSaveEditorPreset, presetNameDraft]); - const clearPendingExportSave = useCallback(() => { const pending = pendingExportSaveRef.current; pendingExportSaveRef.current = null; @@ -1773,9 +1472,7 @@ export default function VideoEditor() { audioRegions, autoCaptions, selectedZoomId, - selectedTrimId, selectedClipId, - selectedSpeedId, selectedAnnotationId, selectedAudioId, }; @@ -1787,9 +1484,7 @@ export default function VideoEditor() { audioRegions, autoCaptions, selectedZoomId, - selectedTrimId, selectedClipId, - selectedSpeedId, selectedAnnotationId, selectedAudioId, ]); @@ -1805,9 +1500,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); @@ -1819,10 +1512,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), @@ -1954,9 +1643,7 @@ export default function VideoEditor() { setGifSizePreset(normalizedEditor.gifSizePreset); setSelectedZoomId(null); - setSelectedTrimId(null); setSelectedClipId(null); - setSelectedSpeedId(null); setSelectedAnnotationId(null); setSelectedAudioId(null); @@ -1964,18 +1651,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), @@ -2236,6 +1915,9 @@ export default function VideoEditor() { pendingFreshRecordingAutoZoomPathRef.current = autoApplyFreshRecordingAutoZooms ? sourceVideoUrl : null; + if (sessionResult.session.hideOverlayCursorByDefault) { + setShowCursor(false); + } setWebcam((prev) => ({ ...prev, enabled: Boolean(sessionResult.session?.webcamPath), @@ -2568,14 +2250,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(); @@ -3007,6 +2681,11 @@ export default function VideoEditor() { if (totalMs <= 0) return; if (!clipInitializedRef.current) { if (clipRegions.length === 0) { + if (trimRegions.length > 0) { + setClipRegions(trimsToClips(trimRegions, totalMs)); + clipInitializedRef.current = true; + return; + } const id = `clip-${nextClipIdRef.current++}`; autoFullTrackClipIdRef.current = id; autoFullTrackClipEndMsRef.current = totalMs; @@ -3026,7 +2705,7 @@ export default function VideoEditor() { autoFullTrackClipEndMsRef.current = totalMs; setClipRegions(extendedClipRegions); - }, [duration, clipRegions]); + }, [duration, clipRegions, trimRegions]); // Derive trimRegions from clipRegions so export/playback pipelines stay unchanged useEffect(() => { @@ -3036,27 +2715,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], ); @@ -3124,7 +2788,6 @@ export default function VideoEditor() { setSelectedZoomId(id); if (id) { setActiveEffectSection("zoom"); - setSelectedTrimId(null); setSelectedAnnotationId(null); setSelectedAudioId(null); } else { @@ -3132,20 +2795,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); } }, []); @@ -3168,7 +2821,6 @@ export default function VideoEditor() { } setZoomRegions((prev) => [...prev, newRegion]); setSelectedZoomId(id); - setSelectedTrimId(null); setSelectedAnnotationId(null); extensionHost.emitEvent({ type: "timeline:region-added", @@ -3262,19 +2914,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) => @@ -3285,21 +2924,7 @@ export default function VideoEditor() { endMs: Math.round(span.end), } : region, - ), - ); - }, []); - - 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, - ), + ), ); }, []); @@ -3355,16 +2980,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) { @@ -3543,62 +3158,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); } }, []); @@ -3615,9 +3179,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) => { @@ -3669,18 +3231,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 @@ -3699,7 +3249,6 @@ export default function VideoEditor() { setAnnotationRegions((prev) => [...prev, newRegion]); setSelectedAnnotationId(id); setSelectedZoomId(null); - setSelectedTrimId(null); }, []); const handleAnnotationSpanChange = useCallback( @@ -3900,12 +3449,6 @@ export default function VideoEditor() { } }, [selectedZoomId, zoomRegions]); - useEffect(() => { - if (selectedTrimId && !trimRegions.some((region) => region.id === selectedTrimId)) { - setSelectedTrimId(null); - } - }, [selectedTrimId, trimRegions]); - useEffect(() => { if ( selectedAnnotationId && @@ -3915,12 +3458,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); @@ -4105,7 +3642,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; @@ -4139,7 +3676,7 @@ export default function VideoEditor() { } } } - }, [isPlaying, currentTime, audioRegions, speedRegions]); + }, [isPlaying, currentTime, audioRegions, effectiveSpeedRegions]); useEffect(() => { if (previewSourceAudioFallbackPaths.length === 0) { @@ -4147,7 +3684,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; @@ -4201,8 +3738,8 @@ export default function VideoEditor() { isPlaying, previewSourceAudioFallbackPaths, sourceAudioFallbackStartDelayMsByPath, - speedRegions, - ]); + effectiveSpeedRegions, + ]); const showExportSuccessToast = useCallback((filePath: string) => { toast.success(`Exported successfully to ${filePath}`, { @@ -5261,111 +4798,6 @@ export default function VideoEditor() { className="flex items-center gap-2 justify-self-end pr-3" style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties} > - - - - - -
-
{ - event.preventDefault(); - handleSavePresetSubmit(); - }} - className="space-y-2" - > -

- {t("editor.presets.saveCurrentAs", "Save current preset as")} -

-
- setPresetNameDraft(event.target.value)} - className="h-9 rounded-xl border-foreground/10 bg-background/70 text-sm" - placeholder={t("editor.presets.namePlaceholder", "Preset name")} - aria-label={t("editor.presets.namePlaceholder", "Preset name")} - /> - -
-
- -
-

- {t("editor.presets.savedList", "Saved presets")} -

-
- {editorPresets.length === 0 ? ( -
- {t("editor.presets.empty", "No presets yet.")} -
- ) : ( - editorPresets.map((preset) => { - const isActive = preset.id === currentEditorPreset?.id; - return ( -
- - -
- ); - }) - )} -
-
-
-
-
@@ -5675,8 +5107,6 @@ export default function VideoEditor() { selectedZoomId && handleZoomModeChange(mode) } onZoomDelete={handleZoomDelete} - selectedTrimId={selectedTrimId} - onTrimDelete={handleTrimDelete} selectedClipId={selectedClipId} selectedClipSpeed={ selectedClipId @@ -5710,8 +5140,6 @@ export default function VideoEditor() { onShadowChange={setShadowIntensity} backgroundBlur={backgroundBlur} onBackgroundBlurChange={setBackgroundBlur} - zoomMotionBlur={zoomMotionBlur} - onZoomMotionBlurChange={setZoomMotionBlur} autoApplyFreshRecordingAutoZooms={autoApplyFreshRecordingAutoZooms} onAutoApplyFreshRecordingAutoZoomsChange={ setAutoApplyFreshRecordingAutoZooms @@ -5744,8 +5172,6 @@ export default function VideoEditor() { onCursorSizeChange={setCursorSize} cursorSmoothing={cursorSmoothing} onCursorSmoothingChange={setCursorSmoothing} - zoomSmoothness={zoomSmoothness} - onZoomSmoothnessChange={setZoomSmoothness} zoomClassicMode={zoomClassicMode} onZoomClassicModeChange={setZoomClassicMode} cursorMotionBlur={cursorMotionBlur} @@ -5795,15 +5221,6 @@ export default function VideoEditor() { } onAnnotationBlurColorChange={handleAnnotationBlurColorChange} onAnnotationDelete={handleAnnotationDelete} - selectedSpeedId={selectedSpeedId} - selectedSpeedValue={ - selectedSpeedId - ? (speedRegions.find((r) => r.id === selectedSpeedId) - ?.speed ?? null) - : null - } - onSpeedChange={handleSpeedChange} - onSpeedDelete={handleSpeedDelete} /> )}
@@ -5914,7 +5331,6 @@ export default function VideoEditor() { showShadow={shadowIntensity > 0} shadowIntensity={shadowIntensity} backgroundBlur={backgroundBlur} - zoomMotionBlur={zoomMotionBlur} connectZooms={connectZooms} zoomInDurationMs={zoomInDurationMs} zoomInOverlapMs={zoomInOverlapMs} @@ -5939,7 +5355,6 @@ export default function VideoEditor() { annotationRegions={annotationRegions} autoCaptions={autoCaptions} autoCaptionSettings={autoCaptionSettings} - onEditAutoCaption={handleSaveAutoCaptionEdit} selectedAnnotationId={selectedAnnotationId} onSelectAnnotation={handleSelectAnnotation} onAnnotationPositionChange={ @@ -6212,22 +5627,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 99362abb..11002c6a 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, @@ -234,7 +225,6 @@ interface VideoPlaybackProps { showShadow?: boolean; shadowIntensity?: number; backgroundBlur?: number; - zoomMotionBlur?: number; connectZooms?: boolean; zoomInDurationMs?: number; zoomInOverlapMs?: number; @@ -256,7 +246,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; @@ -266,6 +255,9 @@ interface VideoPlaybackProps { cursorStyle?: CursorStyle; cursorSize?: number; cursorSmoothing?: number; + cursorSpringStiffnessMultiplier?: number; + cursorSpringDampingMultiplier?: number; + cursorSpringMassMultiplier?: number; zoomSmoothness?: number; zoomClassicMode?: boolean; cursorMotionBlur?: number; @@ -275,11 +267,6 @@ interface VideoPlaybackProps { volume?: number; } -type CaptionEditSession = { - target: CaptionEditTarget; - draft: string; -}; - export interface VideoPlaybackRef { video: HTMLVideoElement | null; app: Application | null; @@ -310,7 +297,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, @@ -332,7 +318,6 @@ const VideoPlayback = forwardRef( annotationRegions = [], autoCaptions = [], autoCaptionSettings, - onEditAutoCaption, selectedAnnotationId, onSelectAnnotation, onAnnotationPositionChange, @@ -342,6 +327,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, @@ -352,14 +340,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); @@ -370,17 +359,10 @@ const VideoPlayback = forwardRef( const webcamBubbleRef = useRef(null); const webcamBubbleInnerRef = useRef(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 }); @@ -416,7 +398,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); @@ -434,6 +415,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); @@ -487,148 +471,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; @@ -641,12 +483,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) { @@ -671,7 +507,7 @@ const VideoPlayback = forwardRef( }); return () => cancelAnimationFrame(frame); - }, [activeCaptionLayout, autoCaptionSettings, captionEditSizeKey, isCaptionEditing]); + }, [activeCaptionLayout, autoCaptionSettings]); const motionBlurStateRef = useRef(createMotionBlurState()); const applyWebcamBubbleLayout = useCallback( @@ -763,6 +599,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; @@ -820,6 +678,7 @@ const VideoPlayback = forwardRef( if (result) { stageSizeRef.current = result.stageSize; + syncPreviewMotionBlurQuality(); videoSizeRef.current = result.videoSize; baseScaleRef.current = result.baseScale; baseOffsetRef.current = result.baseOffset; @@ -903,6 +762,7 @@ const VideoPlayback = forwardRef( showShadow, shadowIntensity, applyWebcamBubbleLayout, + syncPreviewMotionBlurQuality, ]); useEffect(() => { @@ -1234,8 +1094,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; @@ -1303,6 +1178,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]); @@ -1361,10 +1248,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; @@ -1385,7 +1268,8 @@ const VideoPlayback = forwardRef( applyZoomTransform({ cameraContainer: container, - blurFilter: blurFilterRef.current, + zoomBlurFilter: zoomBlurFilterRef.current, + motionBlurFilter: motionBlurFilterRef.current, stageSize: stageSizeRef.current, baseMask: baseMaskRef.current, zoomScale: 1, @@ -1393,7 +1277,8 @@ const VideoPlayback = forwardRef( focusY: DEFAULT_FOCUS.cy, motionIntensity: 0, isPlaying: false, - motionBlurAmount: zoomMotionBlurRef.current, + motionBlurAmount: 0, + motionBlurState: motionBlurStateRef.current, }); requestAnimationFrame(() => { @@ -1552,10 +1437,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(); @@ -1573,6 +1467,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, @@ -1601,6 +1500,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, @@ -1610,6 +1513,7 @@ const VideoPlayback = forwardRef( } appRef.current = null; cameraContainerRef.current = null; + videoEffectsContainerRef.current = null; videoContainerRef.current = null; frameContainerRef.current = null; frameSpriteRef.current = null; @@ -1641,10 +1545,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); @@ -1670,19 +1576,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(); @@ -1724,15 +1617,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; @@ -1744,8 +1629,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 }, @@ -1760,7 +1646,7 @@ const VideoPlayback = forwardRef( const appliedTransform = applyZoomTransform({ cameraContainer, - blurFilter: blurFilterRef.current, + zoomBlurFilter: zoomBlurFilterRef.current, motionBlurFilter: motionBlurFilterRef.current, stageSize: stageSizeRef.current, baseMask: baseMaskRef.current, @@ -1771,7 +1657,7 @@ const VideoPlayback = forwardRef( motionIntensity, motionVector, isPlaying: isPlayingRef.current, - motionBlurAmount: zoomMotionBlurRef.current, + motionBlurAmount: 0, transformOverride: transform, motionBlurState: motionBlurStateRef.current, frameTimeMs: performance.now(), @@ -1788,6 +1674,8 @@ const VideoPlayback = forwardRef( currentTimeRef.current, { connectZooms: connectZoomsRef.current, + zoomInDurationMs: zoomInDurationMsRef.current, + zoomOutDurationMs: zoomOutDurationMsRef.current, }, ); @@ -1957,24 +1845,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; @@ -2187,6 +2057,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); @@ -2215,6 +2090,9 @@ const VideoPlayback = forwardRef( cursorStyle, cursorSize, cursorSmoothing, + cursorSpringStiffnessMultiplier, + cursorSpringDampingMultiplier, + cursorSpringMassMultiplier, cursorMotionBlur, cursorClickBounce, cursorClickBounceDuration, @@ -2533,34 +2411,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(), @@ -2598,137 +2449,42 @@ const VideoPlayback = forwardRef( ), )}px`, boxSizing: "border-box", - cursor: - onEditAutoCaption && !captionEditSession - ? "text" - : undefined, - pointerEvents: onEditAutoCaption ? "auto" : undefined, }} > - {captionEditSession ? ( -