diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index fdc19ccb..78cc31f6 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -220,6 +220,7 @@ interface Window { getAssetBasePath: () => Promise; getSources: (opts: Electron.SourcesOptions) => Promise; switchToEditor: () => Promise; + openRecordingHud: () => Promise<{ success: boolean }>; openSourceSelector: () => Promise; selectSource: (source: ProcessedDesktopSource) => Promise; showSourceHighlight: (source: ProcessedDesktopSource) => Promise<{ success: boolean }>; @@ -593,6 +594,7 @@ interface Window { onRecordingSessionChanged: ( callback: (session: RendererRecordingSessionData | null) => void, ) => () => void; + onRecordingHudClosed: (callback: () => void) => () => void; onRecordingInterrupted: ( callback: (state: { reason: string; message: string }) => void, ) => () => void; @@ -659,6 +661,12 @@ interface Window { canceled?: boolean; error?: string; }>; + stitchVideoSources: (options: { basePath: string; appendPath: string }) => Promise<{ + success: boolean; + path?: string; + message?: string; + error?: string; + }>; openAudioFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>; openWhisperExecutablePicker: () => Promise<{ success: boolean; diff --git a/electron/ipc/register/captions.ts b/electron/ipc/register/captions.ts index fe93afd7..ddcab1d4 100644 --- a/electron/ipc/register/captions.ts +++ b/electron/ipc/register/captions.ts @@ -1,4 +1,7 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; import path from "node:path"; +import { promisify } from "node:util"; import { dialog, ipcMain } from "electron"; import { generateAutoCaptionsFromVideo } from "../captions/generate"; import { @@ -8,17 +11,30 @@ import { sendWhisperModelDownloadProgress, } from "../captions/whisper"; import { LEGACY_PROJECT_FILE_EXTENSIONS, PROJECT_FILE_EXTENSION } from "../constants"; +import { getFfmpegBinaryPath } from "../ffmpeg/binary"; import { hasProjectFileExtension, loadProjectFromPath } from "../project/manager"; import { setCurrentProjectPath } from "../state"; import { approveUserPath, getRecordingsDir } from "../utils"; const VIDEO_FILE_EXTENSIONS = ["webm", "mp4", "mov", "avi", "mkv"]; const PROJECT_FILE_EXTENSIONS = [PROJECT_FILE_EXTENSION, ...LEGACY_PROJECT_FILE_EXTENSIONS]; +const execFileAsync = promisify(execFile); type OpenVideoFilePickerOptions = { includeProjects?: boolean; }; +function getConcatListLine(filePath: string) { + return `file '${filePath.replace(/'/g, "'\\''")}'`; +} + +function isInsideDirectory(candidatePath: string, directoryPath: string) { + const relativePath = path.relative(directoryPath, candidatePath); + return Boolean( + relativePath && !relativePath.startsWith("..") && !path.isAbsolute(relativePath), + ); +} + export function registerCaptionHandlers() { ipcMain.handle("open-video-file-picker", async (_, options?: OpenVideoFilePickerOptions) => { try { @@ -79,6 +95,105 @@ export function registerCaptionHandlers() { } }); + ipcMain.handle( + "stitch-video-sources", + async (_, options: { basePath?: string; appendPath?: string }) => { + try { + const basePath = + typeof options?.basePath === "string" ? path.resolve(options.basePath) : ""; + const appendPath = + typeof options?.appendPath === "string" ? path.resolve(options.appendPath) : ""; + + if (!basePath || !appendPath) { + return { success: false, message: "Choose two recordings to stitch." }; + } + + const recordingsDir = await getRecordingsDir(); + await fs.mkdir(recordingsDir, { recursive: true }); + const realRecordingsDir = await fs.realpath(recordingsDir); + const realBasePath = await fs.realpath(basePath); + const realAppendPath = await fs.realpath(appendPath); + + if ( + !isInsideDirectory(realBasePath, realRecordingsDir) || + !isInsideDirectory(realAppendPath, realRecordingsDir) + ) { + return { + success: false, + message: "Only Recordly-managed recordings can be stitched.", + }; + } + + const baseExtension = path.extname(realBasePath).toLowerCase(); + const appendExtension = path.extname(realAppendPath).toLowerCase(); + const supportedConcatExtensions = new Set([".mp4", ".mov", ".webm"]); + if ( + !supportedConcatExtensions.has(baseExtension) || + !supportedConcatExtensions.has(appendExtension) + ) { + return { + success: false, + message: "Only MP4, MOV, and WebM recordings can be stitched.", + }; + } + + if (baseExtension !== appendExtension) { + return { + success: false, + message: + "Recordings must use the same file format before they can be stitched.", + }; + } + + const timestamp = Date.now(); + const outputPath = path.join( + recordingsDir, + `stitched-recording-${timestamp}${baseExtension}`, + ); + const listPath = path.join(recordingsDir, `stitched-recording-${timestamp}.txt`); + const listContent = `${getConcatListLine(realBasePath)}\n${getConcatListLine(realAppendPath)}\n`; + + await fs.writeFile(listPath, listContent, "utf8"); + + try { + await execFileAsync( + getFfmpegBinaryPath(), + [ + "-y", + "-hide_banner", + "-f", + "concat", + "-safe", + "0", + "-i", + listPath, + "-c", + "copy", + outputPath, + ], + { timeout: 15 * 60 * 1000, maxBuffer: 1024 * 1024 * 16 }, + ); + } finally { + await fs.rm(listPath, { force: true }).catch(() => undefined); + } + + approveUserPath(outputPath); + return { + success: true, + path: outputPath, + }; + } catch (error) { + console.error("Failed to stitch video sources:", error); + return { + success: false, + message: + "Failed to stitch recordings. Make sure both clips are Recordly recordings with the same format and compatible encoding settings.", + error: String(error), + }; + } + }, + ); + ipcMain.handle("open-audio-file-picker", async () => { try { const result = await dialog.showOpenDialog({ diff --git a/electron/main.ts b/electron/main.ts index ed05ffeb..5582bedd 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -245,6 +245,33 @@ ipcMain.on("set-has-unsaved-changes", (_event, hasChanges: boolean) => { editorHasUnsavedChanges = hasChanges; }); +function showOrCreateHudOverlayWindow() { + if (!app.isReady()) { + void app.whenReady().then(() => { + showOrCreateHudOverlayWindow(); + }); + return null; + } + + const existingHudWindow = getHudOverlayWindow(); + if (existingHudWindow) { + restoreWindowSafely(existingHudWindow); + return existingHudWindow; + } + + const createdHudWindow = createHudOverlayWindow(); + if (!mainWindow || mainWindow.isDestroyed() || !isEditorWindow(mainWindow)) { + mainWindow = createdHudWindow; + createdHudWindow.once("closed", () => { + if (mainWindow === createdHudWindow) { + mainWindow = null; + } + }); + } + + return createdHudWindow; +} + function createWindow() { if (!app.isReady()) { void app.whenReady().then(() => { @@ -272,13 +299,7 @@ function createWindow() { } isCreatingMainWindow = true; - const createdHudWindow = createHudOverlayWindow(); - mainWindow = createdHudWindow; - createdHudWindow.once("closed", () => { - if (mainWindow === createdHudWindow) { - mainWindow = null; - } - }); + showOrCreateHudOverlayWindow(); isCreatingMainWindow = false; } @@ -923,6 +944,12 @@ app.whenReady().then(async () => { hud.close(); } + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) { + window.webContents.send("recording-hud-closed"); + } + } + // If this was the last window (or we are in a state where we should quit), do it. // We use a small delay to allow window.close() to propagate. setTimeout(() => { @@ -933,6 +960,12 @@ app.whenReady().then(async () => { } }, 100); }); + + ipcMain.handle("open-recording-hud", () => { + const hud = showOrCreateHudOverlayWindow(); + return { success: Boolean(hud && !hud.isDestroyed()) }; + }); + syncDockIcon(); createTray(); updateTrayMenu(); diff --git a/electron/preload.ts b/electron/preload.ts index c339ad3c..aa1ad8db 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -479,6 +479,9 @@ contextBridge.exposeInMainWorld("electronAPI", { switchToEditor: () => { return ipcRenderer.invoke("switch-to-editor"); }, + openRecordingHud: () => { + return ipcRenderer.invoke("open-recording-hud"); + }, openSourceSelector: () => { return ipcRenderer.invoke("open-source-selector"); }, @@ -674,6 +677,9 @@ contextBridge.exposeInMainWorld("electronAPI", { openVideoFilePicker: (options?: { includeProjects?: boolean }) => { return ipcRenderer.invoke("open-video-file-picker", options); }, + stitchVideoSources: (options: { basePath: string; appendPath: string }) => { + return ipcRenderer.invoke("stitch-video-sources", options); + }, openAudioFilePicker: () => { return ipcRenderer.invoke("open-audio-file-picker"); }, @@ -748,6 +754,11 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.on("recording-session-changed", listener); return () => ipcRenderer.removeListener("recording-session-changed", listener); }, + onRecordingHudClosed: (callback: () => void) => { + const listener = () => callback(); + ipcRenderer.on("recording-hud-closed", listener); + return () => ipcRenderer.removeListener("recording-hud-closed", listener); + }, getCurrentRecordingSession: () => { return ipcRenderer.invoke("get-current-recording-session"); }, diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index e26fe423..87f7f9ea 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -649,6 +649,7 @@ export default function VideoEditor() { const [showCropModal, setShowCropModal] = useState(false); const [previewVersion, setPreviewVersion] = useState(0); const [isPreviewReady, setIsPreviewReady] = useState(false); + const [isStitchingRecording, setIsStitchingRecording] = useState(false); const [autoSuggestZoomsTrigger, setAutoSuggestZoomsTrigger] = useState(0); const headerLeftControlsPaddingClass = appPlatform === "darwin" ? "pl-[76px]" : ""; @@ -673,6 +674,8 @@ export default function VideoEditor() { const pendingTelemetryRetryTimeoutRef = useRef(null); const pendingFreshRecordingAutoSuggestTimeoutRef = useRef(null); const pendingFreshRecordingAutoSuggestTelemetryCountRef = useRef(0); + const pendingAppendedClipStartMsRef = useRef(null); + const pendingInlineAppendRef = useRef<{ baseSourcePath: string } | null>(null); const cropSnapshotRef = useRef(null); const mp4SupportRequestRef = useRef(0); const smokeExportStartedRef = useRef(false); @@ -2486,6 +2489,72 @@ export default function VideoEditor() { smokeExportConfig.webcamSize, ]); + const appendRecordingSource = useCallback( + async (appendSourcePath: string) => { + if (!videoSourcePath) { + toast.error("No current recording is loaded"); + return false; + } + + const previousDurationMs = Math.max(0, Math.round(duration * 1000)); + const isAutoFullTrackClip = + clipRegions.length === 1 && + clipRegions[0]?.id === autoFullTrackClipIdRef.current && + clipRegions[0]?.startMs === 0 && + clipRegions[0]?.endMs === autoFullTrackClipEndMsRef.current && + clipRegions[0]?.speed === 1; + + setIsStitchingRecording(true); + const stitchToastId = toast.loading("Stitching recordings..."); + try { + videoPlaybackRef.current?.pause(); + setIsPlaying(false); + + const stitchResult = await window.electronAPI.stitchVideoSources({ + basePath: videoSourcePath, + appendPath: appendSourcePath, + }); + + if (!stitchResult.success || !stitchResult.path) { + toast.error(stitchResult.message || "Failed to stitch recordings", { + id: stitchToastId, + }); + return false; + } + + const stitchedSourcePath = fromFileUrl(stitchResult.path); + const stitchedVideoUrl = await resolveVideoUrl(stitchedSourcePath); + if (!isAutoFullTrackClip && previousDurationMs > 0) { + pendingAppendedClipStartMsRef.current = previousDurationMs; + } + + setCurrentTime(0); + setDuration(0); + setVideoSourcePath(stitchedSourcePath); + setVideoPath(stitchedVideoUrl); + setLastSavedSnapshot(null); + setWebcam((prev) => ({ + ...prev, + enabled: false, + sourcePath: null, + timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS, + })); + applySessionPresentation(null); + await window.electronAPI.setCurrentVideoPath(stitchedSourcePath, { + preserveProjectPath: Boolean(currentProjectPath), + }); + toast.success("Recording appended", { id: stitchToastId }); + return true; + } catch (error) { + toast.error(getErrorMessage(error), { id: stitchToastId }); + return false; + } finally { + setIsStitchingRecording(false); + } + }, + [applySessionPresentation, clipRegions, currentProjectPath, duration, videoSourcePath], + ); + useEffect(() => { if (!window.electronAPI.onRecordingSessionChanged) { return; @@ -2502,6 +2571,21 @@ export default function VideoEditor() { hasWebcamPath: Boolean(sessionWebcamPath), }); + const pendingInlineAppend = pendingInlineAppendRef.current; + if (pendingInlineAppend && pendingInlineAppend.baseSourcePath !== videoSourcePath) { + pendingInlineAppendRef.current = null; + } else if ( + pendingInlineAppend && + session && + sessionSourcePath && + videoSourcePath && + sessionSourcePath !== pendingInlineAppend.baseSourcePath + ) { + pendingInlineAppendRef.current = null; + void appendRecordingSource(sessionSourcePath); + return; + } + if (!session || sessionSourcePath !== videoSourcePath) { return; } @@ -2516,7 +2600,17 @@ export default function VideoEditor() { })); setSourceAudioFallbackRefreshKey((key) => key + 1); }); - }, [videoSourcePath]); + }, [appendRecordingSource, videoSourcePath]); + + useEffect(() => { + if (!window.electronAPI.onRecordingHudClosed) { + return; + } + + return window.electronAPI.onRecordingHudClosed(() => { + pendingInlineAppendRef.current = null; + }); + }, []); useEffect(() => { let cancelled = false; @@ -3241,6 +3335,33 @@ export default function VideoEditor() { resetSourceScopedEditorState, ]); + const handleRecordInlineAppend = useCallback(async () => { + if (!videoSourcePath) { + toast.error("No current recording is loaded"); + return; + } + if (isExporting) { + toast.error("Wait for the current export to finish before appending a recording."); + return; + } + + pendingInlineAppendRef.current = { baseSourcePath: videoSourcePath }; + try { + const result = await window.electronAPI.openRecordingHud(); + if (!result.success) { + pendingInlineAppendRef.current = null; + toast.error("Could not open recording controls"); + return; + } + } catch { + pendingInlineAppendRef.current = null; + toast.error("Could not open recording controls"); + return; + } + + toast.info("Record another segment. It will append when you stop."); + }, [isExporting, videoSourcePath]); + const handleOpenProjectBrowser = useCallback(async () => { if (projectBrowserOpen) { setProjectBrowserOpen(false); @@ -3424,6 +3545,38 @@ export default function VideoEditor() { setClipRegions(extendedClipRegions); }, [duration, clipRegions, trimRegions, speedRegions]); + useEffect(() => { + const appendedClipStartMs = pendingAppendedClipStartMsRef.current; + const totalMs = Math.round(duration * 1000); + if (appendedClipStartMs === null || totalMs <= appendedClipStartMs) { + return; + } + + pendingAppendedClipStartMsRef.current = null; + setClipRegions((prev) => { + if ( + prev.some( + (clip) => + clip.startMs >= appendedClipStartMs && + clip.endMs <= totalMs && + clip.endMs > clip.startMs, + ) + ) { + return prev; + } + + return [ + ...prev, + { + id: `clip-${nextClipIdRef.current++}`, + startMs: appendedClipStartMs, + endMs: totalMs, + speed: 1, + }, + ]; + }); + }, [duration]); + // Derive trimRegions from clipRegions so export/playback pipelines stay unchanged useEffect(() => { const totalMs = Math.round(duration * 1000); @@ -5544,6 +5697,22 @@ export default function VideoEditor() { > +