From 449618fba845e7e4a5243428a5236ac7803bcf62 Mon Sep 17 00:00:00 2001 From: Manu Pareek Date: Sat, 20 Jun 2026 15:26:14 -0400 Subject: [PATCH 1/2] Add inline recording append workflow --- electron/electron-env.d.ts | 7 + electron/ipc/register/captions.ts | 71 ++++++++++ electron/main.ts | 41 +++++- electron/preload.ts | 6 + src/components/video-editor/VideoEditor.tsx | 148 +++++++++++++++++++- 5 files changed, 265 insertions(+), 8 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index fdc19ccbc..9df1057dd 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 }>; @@ -659,6 +660,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 fe93afd70..ecf3cae5d 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,23 @@ 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, "'\\''")}'`; +} + export function registerCaptionHandlers() { ipcMain.handle("open-video-file-picker", async (_, options?: OpenVideoFilePickerOptions) => { try { @@ -79,6 +88,68 @@ 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 timestamp = Date.now(); + const outputPath = path.join(recordingsDir, `stitched-recording-${timestamp}.mp4`); + const listPath = path.join(recordingsDir, `stitched-recording-${timestamp}.txt`); + const listContent = `${getConcatListLine(basePath)}\n${getConcatListLine(appendPath)}\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 were recorded with compatible 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 ed05ffeb6..e84ce531a 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; } @@ -933,6 +954,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 c339ad3ce..8d062b5b1 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"); }, diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index e26fe4231..6592ba8e6 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(false); 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,18 @@ export default function VideoEditor() { hasWebcamPath: Boolean(sessionWebcamPath), }); + if ( + pendingInlineAppendRef.current && + session && + sessionSourcePath && + videoSourcePath && + sessionSourcePath !== videoSourcePath + ) { + pendingInlineAppendRef.current = false; + void appendRecordingSource(sessionSourcePath); + return; + } + if (!session || sessionSourcePath !== videoSourcePath) { return; } @@ -2516,7 +2597,7 @@ export default function VideoEditor() { })); setSourceAudioFallbackRefreshKey((key) => key + 1); }); - }, [videoSourcePath]); + }, [appendRecordingSource, videoSourcePath]); useEffect(() => { let cancelled = false; @@ -3241,6 +3322,23 @@ export default function VideoEditor() { resetSourceScopedEditorState, ]); + const handleRecordInlineAppend = useCallback(async () => { + if (!videoSourcePath) { + toast.error("No current recording is loaded"); + return; + } + + pendingInlineAppendRef.current = true; + const result = await window.electronAPI.openRecordingHud(); + if (!result.success) { + pendingInlineAppendRef.current = false; + toast.error("Could not open recording controls"); + return; + } + + toast.info("Record another segment. It will append when you stop."); + }, [videoSourcePath]); + const handleOpenProjectBrowser = useCallback(async () => { if (projectBrowserOpen) { setProjectBrowserOpen(false); @@ -3424,6 +3522,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 +5674,22 @@ export default function VideoEditor() { > +
From 98eefdf54888a68cd50de1e9ae338fcde4621100 Mon Sep 17 00:00:00 2001 From: Manu Pareek Date: Sat, 20 Jun 2026 16:03:35 -0400 Subject: [PATCH 2/2] Address inline append review feedback --- electron/electron-env.d.ts | 1 + electron/ipc/register/captions.ts | 50 +++++++++++++++++++-- electron/main.ts | 6 +++ electron/preload.ts | 5 +++ src/components/video-editor/VideoEditor.tsx | 45 ++++++++++++++----- 5 files changed, 93 insertions(+), 14 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 9df1057dd..78cc31f6e 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -594,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; diff --git a/electron/ipc/register/captions.ts b/electron/ipc/register/captions.ts index ecf3cae5d..ddcab1d46 100644 --- a/electron/ipc/register/captions.ts +++ b/electron/ipc/register/captions.ts @@ -28,6 +28,13 @@ 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 { @@ -103,11 +110,48 @@ export function registerCaptionHandlers() { 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}.mp4`); + const outputPath = path.join( + recordingsDir, + `stitched-recording-${timestamp}${baseExtension}`, + ); const listPath = path.join(recordingsDir, `stitched-recording-${timestamp}.txt`); - const listContent = `${getConcatListLine(basePath)}\n${getConcatListLine(appendPath)}\n`; + const listContent = `${getConcatListLine(realBasePath)}\n${getConcatListLine(realAppendPath)}\n`; await fs.writeFile(listPath, listContent, "utf8"); @@ -143,7 +187,7 @@ export function registerCaptionHandlers() { return { success: false, message: - "Failed to stitch recordings. Make sure both clips were recorded with compatible settings.", + "Failed to stitch recordings. Make sure both clips are Recordly recordings with the same format and compatible encoding settings.", error: String(error), }; } diff --git a/electron/main.ts b/electron/main.ts index e84ce531a..5582bedd1 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -944,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(() => { diff --git a/electron/preload.ts b/electron/preload.ts index 8d062b5b1..aa1ad8db0 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -754,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 6592ba8e6..87f7f9ea8 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -675,7 +675,7 @@ export default function VideoEditor() { const pendingFreshRecordingAutoSuggestTimeoutRef = useRef(null); const pendingFreshRecordingAutoSuggestTelemetryCountRef = useRef(0); const pendingAppendedClipStartMsRef = useRef(null); - const pendingInlineAppendRef = useRef(false); + const pendingInlineAppendRef = useRef<{ baseSourcePath: string } | null>(null); const cropSnapshotRef = useRef(null); const mp4SupportRequestRef = useRef(0); const smokeExportStartedRef = useRef(false); @@ -2571,14 +2571,17 @@ export default function VideoEditor() { hasWebcamPath: Boolean(sessionWebcamPath), }); - if ( - pendingInlineAppendRef.current && + const pendingInlineAppend = pendingInlineAppendRef.current; + if (pendingInlineAppend && pendingInlineAppend.baseSourcePath !== videoSourcePath) { + pendingInlineAppendRef.current = null; + } else if ( + pendingInlineAppend && session && sessionSourcePath && videoSourcePath && - sessionSourcePath !== videoSourcePath + sessionSourcePath !== pendingInlineAppend.baseSourcePath ) { - pendingInlineAppendRef.current = false; + pendingInlineAppendRef.current = null; void appendRecordingSource(sessionSourcePath); return; } @@ -2599,6 +2602,16 @@ export default function VideoEditor() { }); }, [appendRecordingSource, videoSourcePath]); + useEffect(() => { + if (!window.electronAPI.onRecordingHudClosed) { + return; + } + + return window.electronAPI.onRecordingHudClosed(() => { + pendingInlineAppendRef.current = null; + }); + }, []); + useEffect(() => { let cancelled = false; if (!webcam.sourcePath) { @@ -3327,17 +3340,27 @@ export default function VideoEditor() { 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 = true; - const result = await window.electronAPI.openRecordingHud(); - if (!result.success) { - pendingInlineAppendRef.current = false; + 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."); - }, [videoSourcePath]); + }, [isExporting, videoSourcePath]); const handleOpenProjectBrowser = useCallback(async () => { if (projectBrowserOpen) { @@ -5679,7 +5702,7 @@ export default function VideoEditor() { variant="ghost" size="sm" onClick={() => void handleRecordInlineAppend()} - disabled={!videoSourcePath || isStitchingRecording} + disabled={!videoSourcePath || isStitchingRecording || isExporting} className={APP_HEADER_ICON_BUTTON_CLASS} title={ isStitchingRecording