mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-27 16:25:35 +00:00
Merge pull request #413 from webadderallorg/codex/cursor-telemetry-write-api
feat: add writable cursor telemetry API
This commit is contained in:
Vendored
+29
-7
@@ -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 }>;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user