Merge pull request #413 from webadderallorg/codex/cursor-telemetry-write-api

feat: add writable cursor telemetry API
This commit is contained in:
webadderall
2026-05-05 14:57:09 +10:00
committed by GitHub
12 changed files with 533 additions and 465 deletions
+29 -7
View File
@@ -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<string, SystemCursorAsset>;
@@ -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<string>;
getLinuxWindowSystem: () => Promise<"wayland" | "x11" | null>;
revealInFolder: (
filePath: string,
) => Promise<{ success: boolean; error?: string; message?: string }>;
+54
View File
@@ -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();
});
});
+74 -2
View File
@@ -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<CursorTelemetryPoint>;
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) {
+37 -62
View File
@@ -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<CursorTelemetryPoint>;
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),
};
}
},
);
}
+22 -6
View File
@@ -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);
},
+128 -224
View File
@@ -160,7 +160,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 +171,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 +198,7 @@ type EditorHistorySnapshot = {
audioRegions: AudioRegion[];
autoCaptions: CaptionCue[];
selectedZoomId: string | null;
selectedTrimId: string | null;
selectedClipId: string | null;
selectedSpeedId: string | null;
selectedAnnotationId: string | null;
selectedAudioId: string | null;
};
@@ -238,6 +237,57 @@ type SmokeExportConfig = {
fps?: ExportMp4FrameRate;
};
const EXPORT_BLOB_STREAM_CHUNK_BYTES = 16 * 1024 * 1024;
async function streamExportBlobToTempFile(blob: Blob, extension: string): Promise<string | null> {
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;
@@ -615,11 +665,9 @@ export default function VideoEditor() {
const [cursorTelemetrySourcePath, setCursorTelemetrySourcePath] = useState<string | null>(null);
const [selectedZoomId, setSelectedZoomId] = useState<string | null>(null);
const [trimRegions, setTrimRegions] = useState<TrimRegion[]>([]);
const [selectedTrimId, setSelectedTrimId] = useState<string | null>(null);
const [clipRegions, setClipRegions] = useState<ClipRegion[]>([]);
const [selectedClipId, setSelectedClipId] = useState<string | null>(null);
const [speedRegions, setSpeedRegions] = useState<SpeedRegion[]>([]);
const [selectedSpeedId, setSelectedSpeedId] = useState<string | null>(null);
const [annotationRegions, setAnnotationRegions] = useState<AnnotationRegion[]>([]);
const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null);
const [audioRegions, setAudioRegions] = useState<AudioRegion[]>([]);
@@ -697,9 +745,7 @@ export default function VideoEditor() {
const projectBrowserFallbackTriggerRef = useRef<HTMLButtonElement | null>(null);
const projectNameInputRef = useRef<HTMLInputElement | null>(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();
@@ -1300,6 +1346,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();
@@ -1693,6 +1776,7 @@ export default function VideoEditor() {
borderRadius,
padding,
frame,
cropRegion,
webcam,
zoomRegions,
trimRegions,
@@ -1741,6 +1825,7 @@ export default function VideoEditor() {
cursorSway,
borderRadius,
padding,
cropRegion,
webcam,
zoomRegions,
trimRegions,
@@ -1773,9 +1858,7 @@ export default function VideoEditor() {
audioRegions,
autoCaptions,
selectedZoomId,
selectedTrimId,
selectedClipId,
selectedSpeedId,
selectedAnnotationId,
selectedAudioId,
};
@@ -1787,9 +1870,7 @@ export default function VideoEditor() {
audioRegions,
autoCaptions,
selectedZoomId,
selectedTrimId,
selectedClipId,
selectedSpeedId,
selectedAnnotationId,
selectedAudioId,
]);
@@ -1805,9 +1886,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 +1898,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 +2029,7 @@ export default function VideoEditor() {
setGifSizePreset(normalizedEditor.gifSizePreset);
setSelectedZoomId(null);
setSelectedTrimId(null);
setSelectedClipId(null);
setSelectedSpeedId(null);
setSelectedAnnotationId(null);
setSelectedAudioId(null);
@@ -1964,18 +2037,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),
@@ -3010,6 +3075,16 @@ export default function VideoEditor() {
const id = `clip-${nextClipIdRef.current++}`;
autoFullTrackClipIdRef.current = id;
autoFullTrackClipEndMsRef.current = totalMs;
if (trimRegions.length > 0) {
const derivedClipRegions = trimsToClips(trimRegions, totalMs);
nextClipIdRef.current = deriveNextId(
"clip",
derivedClipRegions.map((region) => region.id),
);
setClipRegions(derivedClipRegions);
clipInitializedRef.current = true;
return;
}
setClipRegions([{ id, startMs: 0, endMs: totalMs, speed: 1 }]);
}
clipInitializedRef.current = true;
@@ -3026,7 +3101,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 +3111,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 +3184,6 @@ export default function VideoEditor() {
setSelectedZoomId(id);
if (id) {
setActiveEffectSection("zoom");
setSelectedTrimId(null);
setSelectedAnnotationId(null);
setSelectedAudioId(null);
} else {
@@ -3132,20 +3191,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 +3217,6 @@ export default function VideoEditor() {
}
setZoomRegions((prev) => [...prev, newRegion]);
setSelectedZoomId(id);
setSelectedTrimId(null);
setSelectedAnnotationId(null);
extensionHost.emitEvent({
type: "timeline:region-added",
@@ -3262,19 +3310,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 +3320,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 +3376,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 +3554,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 +3575,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 +3627,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 +3645,6 @@ export default function VideoEditor() {
setAnnotationRegions((prev) => [...prev, newRegion]);
setSelectedAnnotationId(id);
setSelectedZoomId(null);
setSelectedTrimId(null);
}, []);
const handleAnnotationSpanChange = useCallback(
@@ -3900,12 +3845,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 +3854,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 +4038,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 +4072,7 @@ export default function VideoEditor() {
}
}
}
}, [isPlaying, currentTime, audioRegions, speedRegions]);
}, [isPlaying, currentTime, audioRegions, effectiveSpeedRegions]);
useEffect(() => {
if (previewSourceAudioFallbackPaths.length === 0) {
@@ -4147,7 +4080,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 +4134,8 @@ export default function VideoEditor() {
isPlaying,
previewSourceAudioFallbackPaths,
sourceAudioFallbackStartDelayMsByPath,
speedRegions,
]);
effectiveSpeedRegions,
]);
const showExportSuccessToast = useCallback((filePath: string) => {
toast.success(`Exported successfully to ${filePath}`, {
@@ -4363,21 +4296,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.",
@@ -4577,20 +4507,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 };
@@ -5675,8 +5601,6 @@ export default function VideoEditor() {
selectedZoomId && handleZoomModeChange(mode)
}
onZoomDelete={handleZoomDelete}
selectedTrimId={selectedTrimId}
onTrimDelete={handleTrimDelete}
selectedClipId={selectedClipId}
selectedClipSpeed={
selectedClipId
@@ -5795,15 +5719,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}
/>
)}
</div>
@@ -6212,22 +6127,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}
@@ -1048,11 +1048,11 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
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<TimelineEditorHandle, TimelineEditorProps>(
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<TimelineEditorHandle, TimelineEditorProps>(
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<TimelineEditorHandle, TimelineEditorProps>(
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<TimelineEditorHandle, TimelineEditorProps>(
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<TimelineEditorHandle, TimelineEditorProps>(
onAnnotationDelete,
onAudioDelete,
onClipDelete,
onSpeedDelete,
onTrimDelete,
onZoomDelete,
speedRegions,
trimRegions,
zoomRegions,
]);
@@ -1309,14 +1282,6 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
[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<TimelineEditorHandle, TimelineEditorProps>(
[onSelectAnnotation],
);
const handleSelectSpeed = useCallback(
(id: string | null) => {
setSelectAllBlocksActive(false);
onSelectSpeed?.(id);
},
[onSelectSpeed],
);
const handleSelectAudio = useCallback(
(id: string | null) => {
setSelectAllBlocksActive(false);
@@ -1519,17 +1476,28 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
}
const startPos = Math.max(0, Math.min(startMs, totalMs));
const activeClip = clipRegions.find(
(clip) => startPos >= clip.startMs && startPos < clip.endMs,
);
const nextClip = clipRegions.find((clip) => clip.startMs > startPos);
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
? activeClip.endMs - startPos
: nextClip
? nextClip.startMs - startPos
: 0;
const gapToNextRegion = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos;
const availableDuration = Math.min(gapToNextClipEdge, gapToNextRegion);
const isOverlapping = sorted.some(
(region) => startPos >= region.startMs && startPos < region.endMs,
);
return !isOverlapping && gapToNext >= defaultDuration;
return !isOverlapping && availableDuration >= defaultRegionDurationMs;
},
[videoDuration, totalMs, zoomRegions, defaultRegionDurationMs],
[videoDuration, totalMs, zoomRegions, defaultRegionDurationMs, clipRegions],
);
const addZoomAtMs = useCallback(
@@ -1547,7 +1515,7 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
if (!canPlaceZoomAtMs(startPos)) {
toast.error("Cannot place zoom here", {
description:
"Zoom already exists at this location or not enough space available.",
"Zoom already exists here or there is not enough room before the next zoom or clip end.",
});
return;
}
@@ -1649,46 +1617,6 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
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 +1624,6 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
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 +1806,12 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
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 +1852,10 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
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 +1866,18 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
}, [
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 +2351,12 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
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}
+53 -1
View File
@@ -1,6 +1,13 @@
import { describe, expect, it } from "vitest";
import { extendAutoFullTrackClip } from "./types";
import {
extendAutoFullTrackClip,
findClipAtTimelineTime,
mapSourceTimeToTimelineTime,
mapTimelineTimeToSourceTime,
trimsToClips,
} from "./types";
import { deriveNextId } from "./projectPersistence";
describe("extendAutoFullTrackClip", () => {
it("extends the default full-track clip when metadata duration grows", () => {
@@ -105,3 +112,48 @@ 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();
});
it("derives the next clip id after converting trim gaps into clip ids", () => {
const clipsFromTrims = trimsToClips(
[
{ id: "trim-gap-1", startMs: 1_000, endMs: 2_000 },
{ id: "trim-gap-2", startMs: 4_000, endMs: 5_000 },
],
6_000,
);
expect(clipsFromTrims.map((clip) => clip.id)).toEqual(["clip-1", "clip-2", "clip-3"]);
expect(deriveNextId("clip", clipsFromTrims.map((clip) => clip.id))).toBe(4);
});
});
+86
View File
@@ -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,
@@ -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<number | null>(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);
});
});
@@ -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);
-6
View File
@@ -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<ShortcutAction, string> = {
addZoom: "Add Zoom",
addTrim: "Add Trim",
splitClip: "Split Clip",
addSpeed: "Add Speed",
addAnnotation: "Add Annotation",
addKeyframe: "Add Keyframe",
deleteSelected: "Delete Selected",