diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 1a926718f..fc6621764 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -670,7 +670,10 @@ interface Window { error?: string; canceled?: boolean; }>; - openVideoFilePicker: (options?: { includeProjects?: boolean }) => Promise<{ + openVideoFilePicker: (options?: { + includeProjects?: boolean; + preserveProjectPath?: boolean; + }) => Promise<{ success: boolean; kind?: "media" | "project"; path?: string; @@ -680,6 +683,14 @@ interface Window { canceled?: boolean; error?: string; }>; + importTimelineClip: (options: { sourcePath: string; clipPath: string }) => Promise<{ + success: boolean; + outputPath?: string; + sourceDurationMs?: number; + importedDurationMs?: number; + totalDurationMs?: number; + message?: string; + }>; openAudioFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>; openWhisperExecutablePicker: () => Promise<{ success: boolean; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 2a0f998eb..3e099dc84 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -8,6 +8,7 @@ import { registerProjectHandlers } from "./register/project"; import { registerRecordingHandlers } from "./register/recording"; import { registerSettingsHandlers } from "./register/settings"; import { registerSourceHandlers } from "./register/sources"; +import { registerTimelineClipImportHandlers } from "./register/timelineClipImport"; import { selectedSource, setNativeScreenRecordingActive, @@ -69,6 +70,7 @@ export function registerIpcHandlers( registerAssetHandlers(); registerExportHandlers(); registerCaptionHandlers(); + registerTimelineClipImportHandlers(); registerProjectHandlers(); registerSettingsHandlers(); } diff --git a/electron/ipc/project/atomicSave.ts b/electron/ipc/project/atomicSave.ts index d2440ffe5..3635aeb1c 100644 --- a/electron/ipc/project/atomicSave.ts +++ b/electron/ipc/project/atomicSave.ts @@ -56,7 +56,7 @@ async function writeSyncedTemporaryFile( } } -async function syncExistingFile(filePath: string): Promise { +export async function syncExistingFile(filePath: string): Promise { const handle = await fs.open(filePath, "r+"); try { await handle.sync(); @@ -65,7 +65,7 @@ async function syncExistingFile(filePath: string): Promise { } } -async function syncParentDirectory(parentDir: string): Promise { +export async function syncParentDirectory(parentDir: string): Promise { if (process.platform === "win32") { return; } diff --git a/electron/ipc/register/captions.ts b/electron/ipc/register/captions.ts index 7dfe5d671..90aea7aa4 100644 --- a/electron/ipc/register/captions.ts +++ b/electron/ipc/register/captions.ts @@ -17,6 +17,7 @@ const PROJECT_FILE_EXTENSIONS = [PROJECT_FILE_EXTENSION, ...LEGACY_PROJECT_FILE_ type OpenVideoFilePickerOptions = { includeProjects?: boolean; + preserveProjectPath?: boolean; }; export function registerCaptionHandlers() { @@ -62,7 +63,7 @@ export function registerCaptionHandlers() { } approveUserPath(selectedPath); - setCurrentProjectPath(null); + if (!options?.preserveProjectPath) setCurrentProjectPath(null); return { success: true, kind: "media", diff --git a/electron/ipc/register/timelineClipImport.ts b/electron/ipc/register/timelineClipImport.ts new file mode 100644 index 000000000..317903570 --- /dev/null +++ b/electron/ipc/register/timelineClipImport.ts @@ -0,0 +1,43 @@ +import path from "node:path"; +import { ipcMain } from "electron"; +import { isAllowedLocalReadPath } from "../project/manager"; +import { importTimelineClip } from "../timelineClipImport"; +import { approveUserPath, normalizeVideoSourcePath } from "../utils"; + +export function registerTimelineClipImportHandlers() { + ipcMain.handle( + "import-timeline-clip", + async (_, options?: { sourcePath?: unknown; clipPath?: unknown }) => { + try { + const sourcePath = normalizeVideoSourcePath( + typeof options?.sourcePath === "string" ? options.sourcePath : null, + ); + const clipPath = normalizeVideoSourcePath( + typeof options?.clipPath === "string" ? options.clipPath : null, + ); + if (!sourcePath || !clipPath) { + return { success: false, message: "Choose a video clip to import." }; + } + + const resolvedSourcePath = path.resolve(sourcePath); + const resolvedClipPath = path.resolve(clipPath); + if ( + !isAllowedLocalReadPath(resolvedSourcePath) || + !isAllowedLocalReadPath(resolvedClipPath) + ) { + return { success: false, message: "The selected media path is not approved." }; + } + + const result = await importTimelineClip(resolvedSourcePath, resolvedClipPath); + if (result.outputPath) approveUserPath(result.outputPath); + return result; + } catch (error) { + console.error("[clip-import] Failed to import timeline clip:", error); + return { + success: false, + message: error instanceof Error ? error.message : "Unable to import clip.", + }; + } + }, + ); +} diff --git a/electron/ipc/timelineClipImport.test.ts b/electron/ipc/timelineClipImport.test.ts new file mode 100644 index 000000000..7faeaa02e --- /dev/null +++ b/electron/ipc/timelineClipImport.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("./export/native-video", () => ({ probeNativeVideoMetadata: vi.fn() })); +vi.mock("./ffmpeg/binary", () => ({ getFfmpegBinaryPath: vi.fn(() => "ffmpeg") })); +vi.mock("./recording/diagnostics", () => ({ + getCompanionAudioFallbackInfo: vi.fn(async () => ({ + paths: [], + startDelayMsByPath: {}, + })), +})); +vi.mock("./utils", () => ({ + getRecordingsDir: vi.fn(async () => "/recordings"), + getTelemetryPathForVideo: vi.fn((videoPath: string) => `${videoPath}.cursor.json`), +})); + +import { buildTimelineClipImportArgs } from "./timelineClipImport"; + +const source = { + width: 1919, + height: 1079, + duration: 10, + frameRate: 60, + hasAudio: true, +}; +const clip = { + width: 1280, + height: 720, + duration: 2.5, + frameRate: 30, + hasAudio: false, +}; + +describe("buildTimelineClipImportArgs", () => { + it("normalizes both videos to the source format and supplies silence when needed", () => { + const args = buildTimelineClipImportArgs({ + sourcePath: "/recordings/source.mp4", + clipPath: "/home/user/clip.mov", + outputPath: "/recordings/composite.partial.mp4", + source, + clip, + }); + const filter = args[args.indexOf("-filter_complex") + 1]; + + expect(filter).toContain("scale=1920:1080:force_original_aspect_ratio=decrease"); + expect(filter).toContain("fps=60.000"); + expect(filter).toContain("[0:a:0]"); + expect(filter).toContain("anullsrc=r=48000:cl=stereo,atrim=duration=2.500000"); + expect(filter).toContain("concat=n=2:v=1:a=1[vout][aout]"); + expect(args.slice(args.indexOf("-fps_mode"), args.indexOf("-c:v"))).toEqual([ + "-fps_mode", + "cfr", + "-r", + "60.000", + ]); + expect(args.at(-1)).toBe("/recordings/composite.partial.mp4"); + }); + + it("mixes delayed companion tracks and adds their input files", () => { + const args = buildTimelineClipImportArgs({ + sourcePath: "/recordings/source.mp4", + clipPath: "/home/user/clip.mov", + outputPath: "/recordings/composite.partial.mp4", + source, + clip: { ...clip, hasAudio: true }, + additionalInputPaths: ["/recordings/source.mic.m4a"], + sourceAudioInputs: [{ inputIndex: 0 }, { inputIndex: 2, startDelayMs: 125 }], + clipAudioInputs: [{ inputIndex: 1 }], + }); + const filter = args[args.indexOf("-filter_complex") + 1]; + + expect(args).toContain("/recordings/source.mic.m4a"); + expect(filter).toContain("[2:a:0]"); + expect(filter).toContain("adelay=125|125"); + expect(filter).toContain("amix=inputs=2:duration=longest:normalize=0"); + }); +}); diff --git a/electron/ipc/timelineClipImport.ts b/electron/ipc/timelineClipImport.ts new file mode 100644 index 000000000..1eea0d3bc --- /dev/null +++ b/electron/ipc/timelineClipImport.ts @@ -0,0 +1,288 @@ +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { probeNativeVideoMetadata } from "./export/native-video"; +import { getFfmpegBinaryPath } from "./ffmpeg/binary"; +import { syncExistingFile, syncParentDirectory } from "./project/atomicSave"; +import { getCompanionAudioFallbackInfo } from "./recording/diagnostics"; +import { getRecordingsDir, getTelemetryPathForVideo } from "./utils"; + +const MAX_CAPTURED_FFMPEG_ERROR_CHARS = 32_000; + +export interface TimelineClipMediaMetadata { + width: number; + height: number; + duration: number; + frameRate: number; + hasAudio: boolean; +} + +export interface TimelineClipImportResult { + success: boolean; + outputPath?: string; + sourceDurationMs?: number; + importedDurationMs?: number; + totalDurationMs?: number; + message?: string; +} + +export interface TimelineClipAudioInput { + inputIndex: number; + startDelayMs?: number; +} + +function evenDimension(value: number) { + return Math.max(2, Math.round(value / 2) * 2); +} + +function safeFrameRate(value: number) { + return Math.min(120, Math.max(1, Number.isFinite(value) ? value : 30)); +} + +function audioFilter( + inputs: TimelineClipAudioInput[], + metadata: TimelineClipMediaMetadata, + label: string, +) { + if (inputs.length === 0) { + return `anullsrc=r=48000:cl=stereo,atrim=duration=${metadata.duration.toFixed(6)},asetpts=PTS-STARTPTS[${label}]`; + } + + const normalizedLabels = inputs.map((_, index) => `${label}input${index}`); + const filters = inputs.map((input, index) => { + const delay = Math.max(0, Math.round(input.startDelayMs ?? 0)); + const delayFilter = delay > 0 ? `,adelay=${delay}|${delay}` : ""; + return `[${input.inputIndex}:a:0]aresample=48000,aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo${delayFilter},apad,atrim=duration=${metadata.duration.toFixed(6)},asetpts=PTS-STARTPTS[${normalizedLabels[index]}]`; + }); + if (normalizedLabels.length === 1) { + filters.push(`[${normalizedLabels[0]}]anull[${label}]`); + } else { + filters.push( + `${normalizedLabels.map((inputLabel) => `[${inputLabel}]`).join("")}amix=inputs=${normalizedLabels.length}:duration=longest:normalize=0,alimiter=limit=0.95[${label}]`, + ); + } + return filters.join(";"); +} + +export function buildTimelineClipImportArgs(options: { + sourcePath: string; + clipPath: string; + outputPath: string; + source: TimelineClipMediaMetadata; + clip: TimelineClipMediaMetadata; + additionalInputPaths?: string[]; + sourceAudioInputs?: TimelineClipAudioInput[]; + clipAudioInputs?: TimelineClipAudioInput[]; +}) { + const width = evenDimension(options.source.width); + const height = evenDimension(options.source.height); + const frameRate = safeFrameRate(options.source.frameRate); + const videoFilter = (inputIndex: number, label: string) => + `[${inputIndex}:v:0]scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1,fps=${frameRate.toFixed(3)},format=yuv420p,setpts=PTS-STARTPTS[${label}]`; + const filter = [ + videoFilter(0, "v0"), + audioFilter( + options.sourceAudioInputs ?? (options.source.hasAudio ? [{ inputIndex: 0 }] : []), + options.source, + "a0", + ), + videoFilter(1, "v1"), + audioFilter( + options.clipAudioInputs ?? (options.clip.hasAudio ? [{ inputIndex: 1 }] : []), + options.clip, + "a1", + ), + "[v0][a0][v1][a1]concat=n=2:v=1:a=1[vout][aout]", + ].join(";"); + + return [ + "-hide_banner", + "-y", + "-i", + options.sourcePath, + "-i", + options.clipPath, + ...(options.additionalInputPaths ?? []).flatMap((inputPath) => ["-i", inputPath]), + "-filter_complex", + filter, + "-map", + "[vout]", + "-map", + "[aout]", + "-fps_mode", + "cfr", + "-r", + frameRate.toFixed(3), + "-c:v", + "libx264", + "-preset", + "veryfast", + "-crf", + "18", + "-c:a", + "aac", + "-b:a", + "192k", + "-movflags", + "+faststart", + "-max_muxing_queue_size", + "4096", + options.outputPath, + ]; +} + +async function resolveAudioInputs( + videoPath: string, + videoInputIndex: number, + metadata: TimelineClipMediaMetadata, + nextInputIndex: number, +) { + const fallback = await getCompanionAudioFallbackInfo(videoPath); + const paths = fallback.paths.length > 0 ? fallback.paths : metadata.hasAudio ? [videoPath] : []; + const additionalInputPaths: string[] = []; + const inputs: TimelineClipAudioInput[] = []; + for (const audioPath of paths) { + const resolvedAudioPath = path.resolve(audioPath); + const isEmbedded = resolvedAudioPath === path.resolve(videoPath); + inputs.push({ + inputIndex: isEmbedded ? videoInputIndex : nextInputIndex + additionalInputPaths.length, + startDelayMs: fallback.startDelayMsByPath[audioPath] ?? 0, + }); + if (!isEmbedded) additionalInputPaths.push(resolvedAudioPath); + } + return { inputs, additionalInputPaths }; +} + +async function runFfmpeg(ffmpegPath: string, args: string[]) { + await new Promise((resolve, reject) => { + const child = spawn(ffmpegPath, args, { + windowsHide: true, + stdio: ["ignore", "ignore", "pipe"], + }); + let errorOutput = ""; + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", (chunk: string) => { + errorOutput = `${errorOutput}${chunk}`.slice(-MAX_CAPTURED_FFMPEG_ERROR_CHARS); + }); + child.once("error", reject); + child.once("close", (code, signal) => { + if (code === 0) { + resolve(); + return; + } + reject( + new Error( + `Unable to prepare imported clip (FFmpeg ${signal ? `signal ${signal}` : `exit ${code ?? "unknown"}`}).${errorOutput ? `\n${errorOutput}` : ""}`, + ), + ); + }); + }); +} + +async function copyCursorTelemetry(sourcePath: string, outputPath: string) { + try { + await fs.copyFile( + getTelemetryPathForVideo(sourcePath), + getTelemetryPathForVideo(outputPath), + ); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + return false; + } +} + +export async function importTimelineClip( + sourcePath: string, + clipPath: string, +): Promise { + const normalizedSourcePath = path.resolve(sourcePath); + const normalizedClipPath = path.resolve(clipPath); + if (normalizedSourcePath === normalizedClipPath) { + throw new Error("The active recording cannot be imported into itself."); + } + + const [sourceStat, clipStat] = await Promise.all([ + fs.stat(normalizedSourcePath), + fs.stat(normalizedClipPath), + ]); + if (!sourceStat.isFile() || !clipStat.isFile()) { + throw new Error("Both the active recording and imported clip must be files."); + } + + const ffmpegPath = getFfmpegBinaryPath(); + const [source, clip] = await Promise.all([ + probeNativeVideoMetadata(ffmpegPath, normalizedSourcePath), + probeNativeVideoMetadata(ffmpegPath, normalizedClipPath), + ]); + if (source.duration <= 0 || clip.duration <= 0) { + throw new Error("The active recording or imported clip has an invalid duration."); + } + + const recordingsDir = await getRecordingsDir(); + const finalPath = path.join( + recordingsDir, + `recordly-composite-${Date.now()}-${randomUUID()}.mp4`, + ); + const partialPath = `${finalPath}.partial.mp4`; + try { + const sourceAudio = await resolveAudioInputs(normalizedSourcePath, 0, source, 2); + const clipAudio = await resolveAudioInputs( + normalizedClipPath, + 1, + clip, + 2 + sourceAudio.additionalInputPaths.length, + ); + await runFfmpeg( + ffmpegPath, + buildTimelineClipImportArgs({ + sourcePath: normalizedSourcePath, + clipPath: normalizedClipPath, + outputPath: partialPath, + source, + clip, + additionalInputPaths: [ + ...sourceAudio.additionalInputPaths, + ...clipAudio.additionalInputPaths, + ], + sourceAudioInputs: sourceAudio.inputs, + clipAudioInputs: clipAudio.inputs, + }), + ); + const output = await probeNativeVideoMetadata(ffmpegPath, partialPath); + const expectedDuration = source.duration + clip.duration; + if ( + output.width !== evenDimension(source.width) || + output.height !== evenDimension(source.height) || + Math.abs(output.duration - expectedDuration) > + Math.max(0.05, 2 / safeFrameRate(source.frameRate)) + ) { + throw new Error( + "The imported clip failed output validation; the original project was not changed.", + ); + } + + await syncExistingFile(partialPath); + await fs.rename(partialPath, finalPath); + await syncParentDirectory(recordingsDir); + if (await copyCursorTelemetry(normalizedSourcePath, finalPath)) { + await syncExistingFile(getTelemetryPathForVideo(finalPath)); + await syncParentDirectory(recordingsDir); + } + return { + success: true, + outputPath: finalPath, + sourceDurationMs: Math.round(source.duration * 1000), + importedDurationMs: Math.round(clip.duration * 1000), + totalDurationMs: Math.round(output.duration * 1000), + }; + } catch (error) { + await Promise.all([ + fs.rm(partialPath, { force: true }), + fs.rm(finalPath, { force: true }), + fs.rm(getTelemetryPathForVideo(finalPath), { force: true }), + ]).catch(() => undefined); + throw error; + } +} diff --git a/electron/preload.ts b/electron/preload.ts index 990ee7a8f..cc1b7cfd0 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -701,9 +701,15 @@ contextBridge.exposeInMainWorld("electronAPI", { captionSidecar, ); }, - openVideoFilePicker: (options?: { includeProjects?: boolean }) => { + openVideoFilePicker: (options?: { + includeProjects?: boolean; + preserveProjectPath?: boolean; + }) => { return ipcRenderer.invoke("open-video-file-picker", options); }, + importTimelineClip: (options: { sourcePath: string; clipPath: string }) => { + return ipcRenderer.invoke("import-timeline-clip", options); + }, openAudioFilePicker: () => { return ipcRenderer.invoke("open-audio-file-picker"); }, diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 6d22666a1..7d9ea168c 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -14,6 +14,7 @@ import { useEditorSettingsPanelProps } from "./layout/useEditorSettingsPanelProp import { useVideoEditorPresets } from "./presets/useVideoEditorPresets"; import { useEditorProjectController } from "./project/useEditorProjectController"; import { useProjectLibraryController } from "./project/useProjectLibraryController"; +import { useTimelineClipImport } from "./project/useTimelineClipImport"; import { getDevOpenRecordingConfig, getSmokeExportConfig } from "./smokeExportConfig"; import { useAppearanceState } from "./state/useAppearanceState"; import { useEditorUiState } from "./state/useEditorUiState"; @@ -365,6 +366,20 @@ export default function VideoEditor() { handleUploadWebcam, handleClearWebcam, }); + const handleImportTimelineClip = useTimelineClipImport({ + project, + appearance, + timeline, + videoPlaybackRef, + nextClipIdRef, + autoFullTrackClipIdRef, + autoFullTrackClipEndMsRef, + setIsPlaying, + setCurrentTime, + setDuration, + setIsPreviewReady, + remountPreview, + }); return ( ); } diff --git a/src/components/video-editor/importedClipPlan.test.ts b/src/components/video-editor/importedClipPlan.test.ts new file mode 100644 index 000000000..712f4883c --- /dev/null +++ b/src/components/video-editor/importedClipPlan.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { buildImportedClipPlan } from "./importedClipPlan"; + +describe("buildImportedClipPlan", () => { + it("appends imported media after existing timeline edits without changing them", () => { + const existing = [ + { id: "clip-1", startMs: 0, endMs: 3000, sourceStartMs: 1000, speed: 1 }, + { id: "clip-2", startMs: 5000, endMs: 7000, sourceStartMs: 6000, speed: 1 }, + ]; + const plan = buildImportedClipPlan({ + clips: existing, + sourceDurationMs: 12_000, + importedDurationMs: 2500, + nextClipId: 3, + }); + + expect(existing).toHaveLength(2); + expect(plan).toEqual({ + timelineStartMs: 7000, + clip: { + id: "clip-3", + startMs: 7000, + endMs: 9500, + sourceStartMs: 12_000, + speed: 1, + showSourceAudio: true, + }, + }); + }); + + it("places the import at zero when all original clips were deleted", () => { + expect( + buildImportedClipPlan({ + clips: [], + sourceDurationMs: 10_000, + importedDurationMs: 1000, + nextClipId: 1, + }), + ).toMatchObject({ + timelineStartMs: 0, + clip: { startMs: 0, endMs: 1000, sourceStartMs: 10_000 }, + }); + }); + + it("rejects invalid timing instead of corrupting the timeline", () => { + expect(() => + buildImportedClipPlan({ + clips: [], + sourceDurationMs: 10_000, + importedDurationMs: 0, + nextClipId: 1, + }), + ).toThrow("Imported clip timing is invalid"); + }); +}); diff --git a/src/components/video-editor/importedClipPlan.ts b/src/components/video-editor/importedClipPlan.ts new file mode 100644 index 000000000..d30d1194e --- /dev/null +++ b/src/components/video-editor/importedClipPlan.ts @@ -0,0 +1,32 @@ +import type { ClipRegion } from "./types"; + +export function buildImportedClipPlan(options: { + clips: ClipRegion[]; + sourceDurationMs: number; + importedDurationMs: number; + nextClipId: number; +}) { + if ( + !Number.isFinite(options.sourceDurationMs) || + options.sourceDurationMs <= 0 || + !Number.isFinite(options.importedDurationMs) || + options.importedDurationMs <= 0 + ) { + throw new Error("Imported clip timing is invalid."); + } + + const timelineStartMs = options.clips.reduce( + (latestEndMs, clip) => Math.max(latestEndMs, clip.endMs), + 0, + ); + const clip: ClipRegion = { + id: `clip-${Math.max(1, Math.round(options.nextClipId))}`, + startMs: timelineStartMs, + endMs: timelineStartMs + Math.round(options.importedDurationMs), + sourceStartMs: Math.round(options.sourceDurationMs), + speed: 1, + showSourceAudio: true, + }; + + return { clip, timelineStartMs }; +} diff --git a/src/components/video-editor/layout/EditorPreviewPanel.tsx b/src/components/video-editor/layout/EditorPreviewPanel.tsx index 1c7ca4869..633d95c7f 100644 --- a/src/components/video-editor/layout/EditorPreviewPanel.tsx +++ b/src/components/video-editor/layout/EditorPreviewPanel.tsx @@ -63,6 +63,7 @@ type Props = { handleOpenCropEditor: () => void; handleSaveAutoCaptionEdit: (target: CaptionEditTarget, text: string) => void; handleSelectAnnotation: (id: string | null) => void; + handleImportTimelineClip: () => Promise; setDuration: Dispatch>; setIsPreviewReady: Dispatch>; setCurrentTime: Dispatch>; @@ -105,6 +106,7 @@ export function EditorPreviewPanel(props: Props) { handleOpenCropEditor, handleSaveAutoCaptionEdit, handleSelectAnnotation, + handleImportTimelineClip, setDuration, setIsPreviewReady, setCurrentTime, @@ -233,6 +235,12 @@ export function EditorPreviewPanel(props: Props) { align="start" className="border-foreground/10 bg-editor-surface-alt" > + void handleImportTimelineClip()} + className="cursor-pointer text-muted-foreground hover:bg-foreground/10 hover:text-foreground" + > + {t("editor.toolbar.importClip", "Import video clip")} + { const nextTrack = diff --git a/src/components/video-editor/layout/EditorShell.tsx b/src/components/video-editor/layout/EditorShell.tsx index e85928280..c85cddc9e 100644 --- a/src/components/video-editor/layout/EditorShell.tsx +++ b/src/components/video-editor/layout/EditorShell.tsx @@ -42,6 +42,7 @@ type Props = { setExperimentalNvidiaCudaExport: (enabled: boolean) => void; effectiveShowCursor: boolean; previewAspectRatioValue: number; + handleImportTimelineClip: () => Promise; }; export function EditorShell(props: Props) { @@ -66,6 +67,7 @@ export function EditorShell(props: Props) { setExperimentalNvidiaCudaExport, effectiveShowCursor, previewAspectRatioValue, + handleImportTimelineClip, } = props; const { snapshot, @@ -214,6 +216,7 @@ export function EditorShell(props: Props) { handleOpenCropEditor={ui.handleOpenCropEditor} handleSaveAutoCaptionEdit={autoCaption.handleSaveAutoCaptionEdit} handleSelectAnnotation={handleSelectAnnotation} + handleImportTimelineClip={handleImportTimelineClip} setDuration={ui.setDuration} setIsPreviewReady={ui.setIsPreviewReady} setCurrentTime={ui.setCurrentTime} diff --git a/src/components/video-editor/project/useTimelineClipImport.ts b/src/components/video-editor/project/useTimelineClipImport.ts new file mode 100644 index 000000000..6fa1f91b6 --- /dev/null +++ b/src/components/video-editor/project/useTimelineClipImport.ts @@ -0,0 +1,145 @@ +import type { Dispatch, MutableRefObject, RefObject, SetStateAction } from "react"; +import { useCallback, useRef } from "react"; +import { toast } from "sonner"; +import { buildImportedClipPlan } from "../importedClipPlan"; +import { fromFileUrl, resolveVideoUrl } from "../projectPersistence"; +import type { useAppearanceState } from "../state/useAppearanceState"; +import type { useProjectState } from "../state/useProjectState"; +import type { useTimelineState } from "../state/useTimelineState"; +import type { VideoPlaybackRef } from "../VideoPlayback"; + +type Input = { + project: ReturnType; + appearance: ReturnType; + timeline: ReturnType; + videoPlaybackRef: RefObject; + nextClipIdRef: MutableRefObject; + autoFullTrackClipIdRef: MutableRefObject; + autoFullTrackClipEndMsRef: MutableRefObject; + setIsPlaying: Dispatch>; + setCurrentTime: Dispatch>; + setDuration: Dispatch>; + setIsPreviewReady: Dispatch>; + remountPreview: () => void; +}; + +export function useTimelineClipImport(input: Input) { + const inputRef = useRef(input); + inputRef.current = input; + const importingRef = useRef(false); + + return useCallback(async () => { + if (importingRef.current) { + toast.info("A clip is already being imported."); + return; + } + + const current = inputRef.current; + const sourcePath = current.project.videoSourcePath; + const projectPath = current.project.currentProjectPath; + if (!sourcePath) { + toast.error("Open a recording before importing a clip."); + return; + } + + const selection = await window.electronAPI.openVideoFilePicker({ + preserveProjectPath: true, + }); + if (selection.canceled) return; + if (!selection.success || !selection.path) { + toast.error(selection.message || "Unable to select the clip."); + return; + } + + const clipPath = fromFileUrl(selection.path); + importingRef.current = true; + const toastId = toast.loading( + "Preparing clip… Your original recording will not be modified.", + ); + try { + const result = await window.electronAPI.importTimelineClip({ + sourcePath, + clipPath, + }); + if ( + !result.success || + !result.outputPath || + !result.sourceDurationMs || + !result.importedDurationMs + ) { + throw new Error(result.message || "Unable to import clip."); + } + + const latest = inputRef.current; + if ( + latest.project.videoSourcePath !== sourcePath || + latest.project.currentProjectPath !== projectPath + ) { + throw new Error( + "The active project changed while the clip was importing. No editor changes were made.", + ); + } + + const plan = buildImportedClipPlan({ + clips: latest.timeline.clipRegions, + sourceDurationMs: result.sourceDurationMs, + importedDurationMs: result.importedDurationMs, + nextClipId: latest.nextClipIdRef.current, + }); + const outputPath = result.outputPath; + const outputUrl = await resolveVideoUrl(outputPath); + + try { + latest.videoPlaybackRef.current?.pause(); + } catch { + // The preview may already be remounting. + } + const preserveProjectPath = Boolean(latest.project.currentProjectPath); + if (latest.appearance.webcam.sourcePath) { + await window.electronAPI.setCurrentRecordingSession( + { + videoPath: outputPath, + webcamPath: latest.appearance.webcam.sourcePath, + timeOffsetMs: latest.appearance.webcam.timeOffsetMs, + }, + { preserveProjectPath }, + ); + } else { + await window.electronAPI.setCurrentVideoPath(outputPath, { preserveProjectPath }); + } + + latest.autoFullTrackClipIdRef.current = null; + latest.autoFullTrackClipEndMsRef.current = null; + latest.nextClipIdRef.current += 1; + latest.timeline.setClipRegions((clips) => [...clips, plan.clip]); + latest.timeline.setSelectedClipId(plan.clip.id); + latest.timeline.setSelectedZoomId(null); + latest.timeline.setSelectedAnnotationId(null); + latest.timeline.setSelectedAudioId(null); + latest.timeline.setSelectedCaptionId(null); + latest.timeline.setSourceAudioTrackSettingsByClip({}); + latest.timeline.setDefaultSourceAudioTrackSettings({}); + latest.timeline.setSourceAudioFallbackRefreshKey((value) => value + 1); + latest.project.setVideoSourcePath(outputPath); + latest.project.setVideoPath(outputUrl); + latest.setIsPlaying(false); + latest.setDuration(0); + latest.setCurrentTime(plan.timelineStartMs / 1000); + latest.setIsPreviewReady(false); + latest.remountPreview(); + toast.success( + "Clip imported at the end of the timeline. Save the project to keep it.", + { + id: toastId, + }, + ); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Unable to import clip.", { + id: toastId, + duration: 10_000, + }); + } finally { + importingRef.current = false; + } + }, []); +}