From 9c1c10026f63209f6454864c341a71deabb542d0 Mon Sep 17 00:00:00 2001 From: QuentinCrane <1369394426@qq.com> Date: Sat, 27 Jun 2026 18:12:35 +0800 Subject: [PATCH 1/4] fix: resolve Whisper runtime directories for captions --- electron/electron-env.d.ts | 23 +- electron/ipc/captions/generate.test.ts | 111 ++++++ electron/ipc/captions/generate.ts | 113 +++++- electron/ipc/captions/whisper.ts | 15 +- electron/ipc/register/captions.ts | 193 +++++++-- electron/preload.ts | 20 +- src/components/video-editor/SettingsPanel.tsx | 365 ++++++++++++++++-- src/components/video-editor/VideoEditor.tsx | 109 +++++- 8 files changed, 858 insertions(+), 91 deletions(-) create mode 100644 electron/ipc/captions/generate.test.ts diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 8a4eedd63..8925268cf 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -661,22 +661,41 @@ interface Window { error?: string; }>; openAudioFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>; - openWhisperExecutablePicker: () => Promise<{ + openWhisperExecutablePicker: (options?: { + currentPath?: string | null; + selectionMode?: "file" | "directory"; + }) => Promise<{ success: boolean; path?: string; canceled?: boolean; error?: string; }>; - openWhisperModelPicker: () => Promise<{ + openWhisperModelPicker: (options?: { currentPath?: string | null }) => Promise<{ success: boolean; path?: string; canceled?: boolean; error?: string; }>; + getWhisperRuntimeStatus: (options?: { currentPath?: string | null }) => Promise<{ + success: boolean; + exists: boolean; + path?: string | null; + error?: string; + }>; + getCaptionFfmpegStatus: () => Promise<{ + success: boolean; + exists: boolean; + path?: string | null; + error?: string; + }>; + showCaptionPathInFolder: ( + path?: string | null, + ) => Promise<{ success: boolean; error?: string }>; getWhisperSmallModelStatus: () => Promise<{ success: boolean; exists: boolean; path?: string | null; + expectedPath?: string; error?: string; }>; downloadWhisperSmallModel: () => Promise<{ diff --git a/electron/ipc/captions/generate.test.ts b/electron/ipc/captions/generate.test.ts new file mode 100644 index 000000000..7943f3a96 --- /dev/null +++ b/electron/ipc/captions/generate.test.ts @@ -0,0 +1,111 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +function getWhisperCliName() { + return process.platform === "win32" ? "whisper-cli.exe" : "whisper-cli"; +} + +async function writeExecutable(filePath: string) { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, ""); + if (process.platform !== "win32") { + await fs.chmod(filePath, 0o755); + } +} + +describe("Whisper executable resolution", () => { + let tempRoot: string; + let appPath: string; + + beforeEach(async () => { + tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-whisper-paths-")); + appPath = path.join(tempRoot, "App"); + + vi.resetModules(); + vi.doMock("electron", () => ({ + app: { + isPackaged: false, + getAppPath: () => appPath, + getPath: () => tempRoot, + }, + })); + }); + + afterEach(async () => { + vi.resetModules(); + vi.doUnmock("electron"); + vi.unstubAllEnvs(); + await fs.rm(tempRoot, { recursive: true, force: true }); + }); + + it("does not treat a directory as an executable", async () => { + const { isExecutableFile } = await import("./generate"); + + expect(await isExecutableFile(tempRoot)).toBe(false); + }); + + it("reports a labeled missing file clearly", async () => { + const { ensureReadableFile } = await import("./generate"); + const missingModelPath = path.join(tempRoot, "missing", "ggml-small.bin"); + + await expect( + ensureReadableFile(missingModelPath, { label: "Whisper model file" }), + ).rejects.toThrow(`Whisper model file was not found at ${missingModelPath}.`); + }); + + it("resolves WHISPER_CPP_PATH when it points to a runtime directory", async () => { + const runtimeDir = path.join(tempRoot, "whisper-runtime"); + const executablePath = path.join(runtimeDir, getWhisperCliName()); + await writeExecutable(executablePath); + vi.stubEnv("WHISPER_CPP_PATH", runtimeDir); + + const { resolveWhisperExecutablePath } = await import("./generate"); + + expect(await resolveWhisperExecutablePath()).toBe(executablePath); + }); + + it("resolves a selected runtime directory", async () => { + const runtimeDir = path.join(tempRoot, "selected-runtime"); + const executablePath = path.join(runtimeDir, getWhisperCliName()); + await writeExecutable(executablePath); + + const { resolveWhisperExecutablePath } = await import("./generate"); + + expect(await resolveWhisperExecutablePath(runtimeDir)).toBe(executablePath); + }); + + it("resolves WHISPER_CPP_PATH when it points to a whisper.cpp checkout", async () => { + const checkoutDir = path.join(tempRoot, "whisper.cpp"); + const executablePath = path.join( + checkoutDir, + "build", + "bin", + "Release", + getWhisperCliName(), + ); + await writeExecutable(executablePath); + vi.stubEnv("WHISPER_CPP_PATH", checkoutDir); + + const { resolveWhisperExecutablePath } = await import("./generate"); + + expect(await resolveWhisperExecutablePath()).toBe(executablePath); + }); + + it("adds companion audio sidecars as caption fallback candidates", async () => { + const videoPath = path.join(tempRoot, "recording-1.mp4"); + const micPath = path.join(tempRoot, "recording-1.mic.wav"); + const systemPath = path.join(tempRoot, "recording-1.system.wav"); + await fs.writeFile(micPath, "mic audio"); + await fs.writeFile(systemPath, "system audio"); + + const { resolveCaptionAudioCandidates } = await import("./generate"); + + expect(await resolveCaptionAudioCandidates(videoPath)).toEqual([ + { path: videoPath, label: "recording" }, + { path: systemPath, label: "source system audio" }, + { path: micPath, label: "source microphone audio" }, + ]); + }); +}); diff --git a/electron/ipc/captions/generate.ts b/electron/ipc/captions/generate.ts index 2a3f49cd7..e9176d921 100644 --- a/electron/ipc/captions/generate.ts +++ b/electron/ipc/captions/generate.ts @@ -4,6 +4,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; import { app } from "electron"; +import { COMPANION_AUDIO_LAYOUTS } from "../constants"; import { getFfmpegBinaryPath } from "../ffmpeg/binary"; import { getBundledWhisperExecutableCandidates } from "../paths/binaries"; import { resolveRecordingSession } from "../project/session"; @@ -19,8 +20,68 @@ import { const execFileAsync = promisify(execFile); -export async function ensureReadableFile(filePath: string, options?: { executable?: boolean }) { - await fs.access(filePath, fsConstants.R_OK); +function getWhisperBinaryNames() { + return process.platform === "win32" + ? ["whisper-cli.exe", "whisper-cpp.exe", "whisper.exe", "main.exe"] + : ["whisper-cli", "whisper-cpp", "whisper", "main"]; +} + +export function getWhisperExecutableCandidatesFromPath(candidatePath?: string | null) { + const trimmedPath = candidatePath?.trim(); + if (!trimmedPath) { + return []; + } + + const resolvedPath = path.resolve(trimmedPath); + const directorySearchPaths = [ + [], + ["bin"], + ["bin", "Release"], + ["build", "bin"], + ["build", "bin", "Release"], + ]; + const candidates = [ + resolvedPath, + ...directorySearchPaths.flatMap((segments) => + getWhisperBinaryNames().map((binaryName) => + path.join(resolvedPath, ...segments, binaryName), + ), + ), + ]; + + return [...new Set(candidates)]; +} + +export async function ensureReadableFile( + filePath: string, + options?: { executable?: boolean; label?: string }, +) { + let stats: Awaited>; + try { + stats = await fs.stat(filePath); + } catch (error) { + if (!options?.label) { + throw error; + } + throw new Error(`${options.label} was not found at ${filePath}.`); + } + + if (!stats.isFile()) { + throw new Error( + options?.executable + ? "The selected Whisper executable is not a file." + : `${options?.label ?? "The selected file"} is not a file.`, + ); + } + + try { + await fs.access(filePath, fsConstants.R_OK); + } catch (error) { + if (!options?.label) { + throw error; + } + throw new Error(`${options.label} is not readable at ${filePath}.`); + } if (options?.executable) { try { await fs.access(filePath, fsConstants.X_OK); @@ -32,6 +93,10 @@ export async function ensureReadableFile(filePath: string, options?: { executabl export async function isExecutableFile(filePath: string) { try { + const stats = await fs.stat(filePath); + if (!stats.isFile()) { + return false; + } await fs.access(filePath, fsConstants.R_OK | fsConstants.X_OK); return true; } catch { @@ -41,9 +106,15 @@ export async function isExecutableFile(filePath: string) { export async function resolveWhisperExecutablePath(preferredPath?: string | null) { const candidatePaths = [ - preferredPath?.trim() || null, + ...getWhisperExecutableCandidatesFromPath(preferredPath), + ...getWhisperExecutableCandidatesFromPath(process.env["WHISPER_CPP_PATH"]), + ...getWhisperExecutableCandidatesFromPath( + process.platform === "win32" ? "C:\\Tools\\whisper" : null, + ), + ...getWhisperExecutableCandidatesFromPath( + process.platform === "win32" ? "C:\\whisper" : null, + ), ...getBundledWhisperExecutableCandidates(), - process.env["WHISPER_CPP_PATH"]?.trim() || null, process.platform === "darwin" ? "/opt/homebrew/bin/whisper-cli" : null, process.platform === "darwin" ? "/usr/local/bin/whisper-cli" : null, process.platform === "darwin" ? "/opt/homebrew/bin/whisper-cpp" : null, @@ -58,12 +129,8 @@ export async function resolveWhisperExecutablePath(preferredPath?: string | null } const pathCommand = process.platform === "win32" ? "where" : "which"; - const binaryNames = - process.platform === "win32" - ? ["whisper-cli.exe", "whisper.exe", "main.exe"] - : ["whisper-cli", "whisper-cpp", "whisper", "main"]; - for (const binaryName of binaryNames) { + for (const binaryName of getWhisperBinaryNames()) { const result = spawnSync(pathCommand, [binaryName], { encoding: "utf-8" }); if (result.status === 0) { const resolvedPath = result.stdout @@ -78,7 +145,7 @@ export async function resolveWhisperExecutablePath(preferredPath?: string | null } throw new Error( - "No Whisper runtime was found. Recordly looked for a bundled binary first, then checked common system install locations.", + "Whisper engine was not found. In Captions, choose the folder that contains whisper-cli, choose the whisper-cli executable, or set WHISPER_CPP_PATH to that location.", ); } @@ -101,6 +168,25 @@ export async function resolveCaptionAudioCandidates(videoPath: string) { const requestedRecordingSession = await resolveRecordingSession(videoPath); pushCandidate(requestedRecordingSession?.webcamPath, "linked webcam recording"); + const basePath = videoPath.replace(/\.[^.]+$/u, ""); + for (const layout of COMPANION_AUDIO_LAYOUTS) { + const companionPaths = [ + { path: `${basePath}${layout.systemSuffix}`, label: "source system audio" }, + { path: `${basePath}${layout.micSuffix}`, label: "source microphone audio" }, + ]; + + for (const companion of companionPaths) { + try { + const stats = await fs.stat(companion.path); + if (stats.isFile() && stats.size > 0) { + pushCandidate(companion.path, companion.label); + } + } catch { + // Companion audio is optional and only used as a fallback. + } + } + } + return candidates; } @@ -200,8 +286,11 @@ export async function generateAutoCaptionsFromVideo(options: { const whisperExecutablePath = await resolveWhisperExecutablePath(options.whisperExecutablePath); const whisperModelPath = path.resolve(options.whisperModelPath); - await ensureReadableFile(whisperExecutablePath, { executable: true }); - await ensureReadableFile(whisperModelPath); + await ensureReadableFile(whisperExecutablePath, { + executable: true, + label: "Whisper runtime", + }); + await ensureReadableFile(whisperModelPath, { label: "Whisper model file" }); const tempBase = path.join( app.getPath("temp"), diff --git a/electron/ipc/captions/whisper.ts b/electron/ipc/captions/whisper.ts index c8e774c62..f22f78497 100644 --- a/electron/ipc/captions/whisper.ts +++ b/electron/ipc/captions/whisper.ts @@ -1,9 +1,12 @@ -import { createWriteStream } from "node:fs"; -import { constants as fsConstants } from "node:fs"; +import { createWriteStream, constants as fsConstants } from "node:fs"; import fs from "node:fs/promises"; import { get as httpsGet } from "node:https"; import type Electron from "electron"; -import { WHISPER_MODEL_DIR, WHISPER_MODEL_DOWNLOAD_URL, WHISPER_SMALL_MODEL_PATH } from "../constants"; +import { + WHISPER_MODEL_DIR, + WHISPER_MODEL_DOWNLOAD_URL, + WHISPER_SMALL_MODEL_PATH, +} from "../constants"; export function sendWhisperModelDownloadProgress( webContents: Electron.WebContents, @@ -24,12 +27,14 @@ export async function getWhisperSmallModelStatus() { success: true, exists: true, path: WHISPER_SMALL_MODEL_PATH, + expectedPath: WHISPER_SMALL_MODEL_PATH, }; } catch { return { success: true, exists: false, path: null, + expectedPath: WHISPER_SMALL_MODEL_PATH, }; } } @@ -106,7 +111,9 @@ export function downloadFileWithProgress( return request(url); } -export async function downloadWhisperSmallModel(webContents: Electron.WebContents): Promise { +export async function downloadWhisperSmallModel( + webContents: Electron.WebContents, +): Promise { await fs.mkdir(WHISPER_MODEL_DIR, { recursive: true }); const tempPath = `${WHISPER_SMALL_MODEL_PATH}.download`; diff --git a/electron/ipc/register/captions.ts b/electron/ipc/register/captions.ts index fe93afd70..942c5ec4e 100644 --- a/electron/ipc/register/captions.ts +++ b/electron/ipc/register/captions.ts @@ -1,6 +1,7 @@ +import fs from "node:fs/promises"; import path from "node:path"; -import { dialog, ipcMain } from "electron"; -import { generateAutoCaptionsFromVideo } from "../captions/generate"; +import { dialog, ipcMain, shell } from "electron"; +import { generateAutoCaptionsFromVideo, resolveWhisperExecutablePath } from "../captions/generate"; import { deleteWhisperSmallModel, downloadWhisperSmallModel, @@ -8,6 +9,7 @@ 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"; @@ -15,10 +17,69 @@ 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]; +function getErrorMessage(error: unknown) { + if (error instanceof Error) { + return error.message; + } + + if (typeof error === "string") { + return error.replace(/^Error:\s*/i, ""); + } + + return "Something went wrong"; +} + type OpenVideoFilePickerOptions = { includeProjects?: boolean; }; +type WhisperFilePickerOptions = { + currentPath?: string | null; + selectionMode?: "file" | "directory"; +}; + +async function resolveExistingDialogPath(candidatePath?: string | null) { + const trimmedPath = candidatePath?.trim(); + if (!trimmedPath) { + return null; + } + + try { + const stats = await fs.stat(trimmedPath); + return stats.isDirectory() ? trimmedPath : path.dirname(trimmedPath); + } catch { + const parentPath = path.dirname(trimmedPath); + try { + const stats = await fs.stat(parentPath); + return stats.isDirectory() ? parentPath : null; + } catch { + return null; + } + } +} + +async function resolveDialogDefaultPath(candidates: Array) { + for (const candidatePath of candidates) { + const dialogPath = await resolveExistingDialogPath(candidatePath); + if (dialogPath) { + return dialogPath; + } + } + + return undefined; +} + +function getWhisperRuntimeDefaultPathCandidates(currentPath?: string | null) { + return [ + currentPath, + process.env["WHISPER_CPP_PATH"], + process.platform === "win32" ? "C:\\Tools\\whisper" : null, + process.platform === "win32" ? "C:\\whisper" : null, + process.platform === "darwin" ? "/opt/homebrew/bin" : null, + process.platform === "darwin" ? "/usr/local/bin" : null, + ]; +} + export function registerCaptionHandlers() { ipcMain.handle("open-video-file-picker", async (_, options?: OpenVideoFilePickerOptions) => { try { @@ -112,15 +173,65 @@ export function registerCaptionHandlers() { } }); - ipcMain.handle("open-whisper-executable-picker", async () => { + ipcMain.handle( + "open-whisper-executable-picker", + async (_, options?: WhisperFilePickerOptions) => { + try { + const selectionMode = options?.selectionMode ?? "directory"; + const defaultPath = await resolveDialogDefaultPath( + getWhisperRuntimeDefaultPathCandidates(options?.currentPath), + ); + const result = await dialog.showOpenDialog({ + title: + selectionMode === "file" + ? "Choose whisper-cli" + : "Choose Whisper Engine Folder", + defaultPath, + buttonLabel: + selectionMode === "file" ? "Use This Executable" : "Use This Folder", + filters: + selectionMode === "file" + ? [ + { + name: "Whisper Engine", + extensions: + process.platform === "win32" + ? ["exe", "cmd", "bat"] + : ["*"], + }, + { name: "All Files", extensions: ["*"] }, + ] + : undefined, + properties: [selectionMode === "file" ? "openFile" : "openDirectory"], + }); + + if (result.canceled || result.filePaths.length === 0) { + return { success: false, canceled: true }; + } + + approveUserPath(result.filePaths[0]); + return { success: true, path: result.filePaths[0] }; + } catch (error) { + console.error("Failed to open Whisper executable picker:", error); + return { success: false, error: String(error) }; + } + }, + ); + + ipcMain.handle("open-whisper-model-picker", async (_, options?: WhisperFilePickerOptions) => { try { + const modelStatus = await getWhisperSmallModelStatus(); + const defaultPath = await resolveDialogDefaultPath([ + options?.currentPath, + modelStatus.path, + modelStatus.expectedPath, + ]); const result = await dialog.showOpenDialog({ - title: "Select Whisper Executable", + title: "Choose Whisper Model", + defaultPath, + buttonLabel: "Use This Model", filters: [ - { - name: "Executables", - extensions: process.platform === "win32" ? ["exe", "cmd", "bat"] : ["*"], - }, + { name: "Whisper Models", extensions: ["bin"] }, { name: "All Files", extensions: ["*"] }, ], properties: ["openFile"], @@ -133,31 +244,60 @@ export function registerCaptionHandlers() { approveUserPath(result.filePaths[0]); return { success: true, path: result.filePaths[0] }; } catch (error) { - console.error("Failed to open Whisper executable picker:", error); + console.error("Failed to open Whisper model picker:", error); return { success: false, error: String(error) }; } }); - ipcMain.handle("open-whisper-model-picker", async () => { + ipcMain.handle("get-whisper-runtime-status", async (_, options?: WhisperFilePickerOptions) => { try { - const result = await dialog.showOpenDialog({ - title: "Select Whisper Model", - filters: [ - { name: "Whisper Models", extensions: ["bin"] }, - { name: "All Files", extensions: ["*"] }, - ], - properties: ["openFile"], - }); + const runtimePath = await resolveWhisperExecutablePath(options?.currentPath); + return { success: true, exists: true, path: runtimePath }; + } catch (error) { + return { + success: true, + exists: false, + path: null, + error: getErrorMessage(error), + }; + } + }); - if (result.canceled || result.filePaths.length === 0) { - return { success: false, canceled: true }; + ipcMain.handle("get-caption-ffmpeg-status", async () => { + try { + const ffmpegPath = getFfmpegBinaryPath(); + return { success: true, exists: true, path: ffmpegPath }; + } catch (error) { + return { + success: true, + exists: false, + path: null, + error: getErrorMessage(error), + }; + } + }); + + ipcMain.handle("show-caption-path-in-folder", async (_, targetPath?: string | null) => { + const trimmedPath = targetPath?.trim(); + if (!trimmedPath) { + return { success: false, error: "No path is selected." }; + } + + try { + const stats = await fs.stat(trimmedPath); + if (stats.isFile()) { + shell.showItemInFolder(trimmedPath); + return { success: true }; } - approveUserPath(result.filePaths[0]); - return { success: true, path: result.filePaths[0] }; + const openError = await shell.openPath(trimmedPath); + if (openError) { + return { success: false, error: openError }; + } + + return { success: true }; } catch (error) { - console.error("Failed to open Whisper model picker:", error); - return { success: false, error: String(error) }; + return { success: false, error: getErrorMessage(error) }; } }); @@ -244,10 +384,11 @@ export function registerCaptionHandlers() { }; } catch (error) { console.error("Failed to generate auto captions:", error); + const errorMessage = getErrorMessage(error); return { success: false, - error: String(error), - message: "Failed to generate auto captions", + error: errorMessage, + message: `Failed to generate auto captions: ${errorMessage}`, }; } }, diff --git a/electron/preload.ts b/electron/preload.ts index e55d42cbd..cd981f59a 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -680,11 +680,23 @@ contextBridge.exposeInMainWorld("electronAPI", { openAudioFilePicker: () => { return ipcRenderer.invoke("open-audio-file-picker"); }, - openWhisperExecutablePicker: () => { - return ipcRenderer.invoke("open-whisper-executable-picker"); + openWhisperExecutablePicker: (options?: { + currentPath?: string | null; + selectionMode?: "file" | "directory"; + }) => { + return ipcRenderer.invoke("open-whisper-executable-picker", options); + }, + openWhisperModelPicker: (options?: { currentPath?: string | null }) => { + return ipcRenderer.invoke("open-whisper-model-picker", options); + }, + getWhisperRuntimeStatus: (options?: { currentPath?: string | null }) => { + return ipcRenderer.invoke("get-whisper-runtime-status", options); + }, + getCaptionFfmpegStatus: () => { + return ipcRenderer.invoke("get-caption-ffmpeg-status"); }, - openWhisperModelPicker: () => { - return ipcRenderer.invoke("open-whisper-model-picker"); + showCaptionPathInFolder: (path?: string | null) => { + return ipcRenderer.invoke("show-caption-path-in-folder", path); }, getWhisperSmallModelStatus: () => { return ipcRenderer.invoke("get-whisper-small-model-status"); diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index a90028e2b..4f3c53c0d 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -832,10 +832,14 @@ interface SettingsPanelProps { whisperModelPath?: string | null; whisperModelDownloadStatus?: "idle" | "downloading" | "downloaded" | "error"; whisperModelDownloadProgress?: number; + captionGenerationError?: string | null; + captionFfmpegPath?: string | null; + captionFfmpegError?: string | null; isGeneratingCaptions?: boolean; onAutoCaptionSettingsChange?: (settings: AutoCaptionSettings) => void; - onPickWhisperExecutable?: () => void; + onPickWhisperExecutable?: (selectionMode?: "file" | "directory") => void; onPickWhisperModel?: () => void; + onShowCaptionPathInFolder?: (path?: string | null) => void; onGenerateAutoCaptions?: () => void; onClearAutoCaptions?: () => void; onDownloadWhisperSmallModel?: () => void; @@ -852,6 +856,15 @@ interface SettingsPanelProps { onOpenNativeCaptureUnavailableModal?: () => void; } +function getPathDisplayName(filePath?: string | null) { + const trimmedPath = filePath?.trim(); + if (!trimmedPath) { + return ""; + } + + return trimmedPath.split(/[\\/]/).filter(Boolean).pop() ?? trimmedPath; +} + const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [ { depth: 1, label: "1.25×" }, { depth: 2, label: "1.5×" }, @@ -1272,12 +1285,18 @@ export function SettingsPanel({ onAnnotationDelete, autoCaptions = [], autoCaptionSettings = DEFAULT_AUTO_CAPTION_SETTINGS, + whisperExecutablePath, whisperModelPath, whisperModelDownloadStatus = "idle", whisperModelDownloadProgress = 0, + captionGenerationError = null, + captionFfmpegPath, + captionFfmpegError, isGeneratingCaptions = false, onAutoCaptionSettingsChange, + onPickWhisperExecutable, onPickWhisperModel, + onShowCaptionPathInFolder, onGenerateAutoCaptions, onClearAutoCaptions, onDownloadWhisperSmallModel, @@ -1324,6 +1343,136 @@ export function SettingsPanel({ [extensionWallpapers], ); const captionCueCount = autoCaptions.length; + const isWhisperModelReady = Boolean(whisperModelPath); + const isWhisperEngineReady = Boolean(whisperExecutablePath); + const isCaptionFfmpegChecking = !captionFfmpegPath && !captionFfmpegError; + const isCaptionFfmpegReady = Boolean(captionFfmpegPath) && !captionFfmpegError; + const isCaptionSetupReady = + isWhisperModelReady && isWhisperEngineReady && isCaptionFfmpegReady; + const whisperModelDisplayName = whisperModelPath + ? getPathDisplayName(whisperModelPath) + : tSettings("captions.noModelSelected", "No model selected"); + const whisperEngineDisplayName = whisperExecutablePath + ? getPathDisplayName(whisperExecutablePath) + : tSettings("captions.noEngineSelected", "No engine selected"); + const whisperModelHelpText = whisperModelPath + ? whisperModelPath + : tSettings( + "captions.modelHelp", + "Download the small model or choose a .bin model file.", + ); + const whisperEngineHelpText = whisperExecutablePath + ? whisperExecutablePath + : tSettings( + "captions.engineHelp", + "Choose the folder that contains whisper-cli, such as C:\\Tools\\whisper on Windows or /opt/homebrew/bin on macOS.", + ); + const captionFfmpegDisplayName = captionFfmpegPath + ? getPathDisplayName(captionFfmpegPath) + : isCaptionFfmpegChecking + ? tSettings("captions.ffmpegChecking", "Checking FFmpeg") + : tSettings("captions.ffmpegMissing", "FFmpeg not found"); + const captionFfmpegHelpText = captionFfmpegPath + ? captionFfmpegPath + : isCaptionFfmpegChecking + ? tSettings( + "captions.ffmpegCheckingHelp", + "Looking for Recordly's bundled FFmpeg or an ffmpeg command on PATH.", + ) + : captionFfmpegError || + tSettings( + "captions.ffmpegHelp", + "Recordly needs FFmpeg to extract audio before Whisper can transcribe it.", + ); + const captionErrorIsMissingEngine = Boolean( + captionGenerationError?.includes("Whisper engine"), + ); + const captionErrorIsMissingAudio = Boolean( + captionGenerationError?.includes("No audio was found"), + ); + const captionFfmpegIsBlocking = + !isCaptionFfmpegReady && isWhisperModelReady && isWhisperEngineReady; + const captionErrorIsMissingFfmpeg = Boolean( + captionGenerationError?.includes("FFmpeg") || + (captionFfmpegIsBlocking && captionFfmpegError), + ); + const captionStatusTitle = isGeneratingCaptions + ? tSettings("captions.statusGenerating", "Generating captions") + : captionErrorIsMissingAudio + ? tSettings("captions.noAudioTitle", "No audio to transcribe") + : !isWhisperModelReady + ? tSettings("captions.modelNeedsSetup", "Choose a caption model") + : !isWhisperEngineReady + ? tSettings("captions.engineNeedsSetup", "Choose Whisper engine") + : isCaptionFfmpegChecking + ? tSettings("captions.ffmpegChecking", "Checking FFmpeg") + : !isCaptionFfmpegReady + ? tSettings("captions.ffmpegNeedsSetup", "FFmpeg is unavailable") + : captionCueCount > 0 + ? tSettings("captions.statusReadyWithCaptions", "Captions are ready") + : tSettings("captions.statusReadyToGenerate", "Ready to generate"); + const captionStatusText = isGeneratingCaptions + ? tSettings("captions.generatingStatus", "Generating captions. This can take a moment.") + : captionErrorIsMissingAudio + ? tSettings( + "captions.noAudioHelp", + "This video does not contain an audio track Recordly can transcribe. Open a video with audio, or record again with microphone or system audio enabled.", + ) + : !isWhisperModelReady + ? tSettings( + "captions.guideModelDescription", + "Download the small model, or choose an existing .bin model file.", + ) + : !isWhisperEngineReady + ? tSettings( + "captions.guideEngineDescription", + "Choose the folder that contains whisper-cli. On Windows, C:\\Tools\\whisper is a good place to keep it.", + ) + : isCaptionFfmpegChecking + ? captionFfmpegHelpText + : !isCaptionFfmpegReady + ? captionFfmpegHelpText + : captionCueCount > 0 + ? tSettings( + "captions.guideReadyDescription", + "Generated captions are available. You can regenerate them after changing language, model, or engine.", + ) + : tSettings( + "captions.guideGenerateDescription", + "Model, engine, and audio extraction are ready.", + ); + const captionStatusToneClassName = + captionErrorIsMissingAudio || captionErrorIsMissingFfmpeg + ? "border-red-500/20 bg-red-500/5" + : isCaptionSetupReady + ? "border-[#2563EB]/15 bg-[#2563EB]/5" + : "border-amber-500/20 bg-amber-500/5"; + const captionStatusDotClassName = + captionErrorIsMissingAudio || captionErrorIsMissingFfmpeg + ? "bg-red-500" + : isCaptionSetupReady + ? "bg-[#2563EB]" + : "bg-amber-500"; + const captionGenerationHelpTitle = captionErrorIsMissingEngine + ? tSettings("captions.engineNeedsSetup", "Whisper engine needs setup") + : captionErrorIsMissingAudio + ? tSettings("captions.noAudioTitle", "No audio to transcribe") + : captionErrorIsMissingFfmpeg + ? tSettings("captions.ffmpegNeedsSetup", "FFmpeg is unavailable") + : tSettings("captions.needsSetup", "Captions need setup"); + const captionGenerationHelpText = captionErrorIsMissingEngine + ? tSettings( + "captions.engineErrorHelp", + "Whisper engine was not found. Choose the folder that contains whisper-cli, such as C:\\Tools\\whisper on Windows or /opt/homebrew/bin on macOS.", + ) + : captionErrorIsMissingAudio + ? tSettings( + "captions.noAudioHelp", + "This video does not contain an audio track Recordly can transcribe. Open a video with audio, or record again with microphone or system audio enabled.", + ) + : captionErrorIsMissingFfmpeg + ? captionFfmpegHelpText + : captionGenerationError; const updateAutoCaptionSettings = (partial: Partial) => { onAutoCaptionSettingsChange?.({ ...autoCaptionSettings, @@ -2648,15 +2797,152 @@ export function SettingsPanel({
-
- +
+
+ +
+ {captionStatusTitle} +
+
+
+ {captionStatusText} +
+
+ +
+
+
+
+
+ {tSettings("captions.modelLabel", "Model")} +
+
+ {whisperModelDisplayName} +
+
+
+ {whisperModelPath ? ( + + ) : ( + + )} + +
+
+
+ {whisperModelHelpText} +
+
+ +
+
+
+
+ {tSettings("captions.engineLabel", "Whisper engine")} +
+
+ {whisperEngineDisplayName} +
+
+
+ {whisperExecutablePath ? ( + + ) : null} + + +
+
+
+ {whisperEngineHelpText} +
+
+ +
+
+
+
+ {tSettings("captions.audioExtractorLabel", "Audio extractor")} +
+
+ {captionFfmpegDisplayName} +
+
+ {captionFfmpegPath ? ( + + ) : null} +
+
+ {captionFfmpegHelpText} +
+
@@ -2680,16 +2966,7 @@ export function SettingsPanel({
- {whisperModelDownloadStatus === "downloading" ? ( - - ) : whisperModelPath ? ( + {whisperModelPath ? ( )}
+ {captionGenerationHelpText ? ( +
+
+ {captionGenerationHelpTitle} +
+
+ {captionGenerationHelpText} +
+ {captionErrorIsMissingEngine || + (!whisperModelPath && !captionErrorIsMissingAudio) ? ( +
+ {captionErrorIsMissingEngine ? ( + + ) : null} + {!whisperModelPath && !captionErrorIsMissingAudio ? ( + + ) : null} +
+ ) : null} +
+ ) : null}
-
+
{whisperModelHelpText}
@@ -2879,7 +2904,9 @@ export function SettingsPanel({
-
+
{whisperEngineHelpText}
@@ -2931,7 +2961,9 @@ export function SettingsPanel({ ) : null} -
+
{captionFfmpegHelpText}
@@ -3039,7 +3074,7 @@ export function SettingsPanel({ {isGeneratingCaptions ? (
-
- {tSettings( - "captions.generatingStatus", - "Generating captions. This can take a moment.", - )} +
{captionStatusText}
+
+
-
) : null}
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index c0840889f..de46b852e 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -585,6 +585,7 @@ export default function VideoEditor() { >(initialEditorPreferences.whisperModelPath ? "downloaded" : "idle"); const [whisperModelDownloadProgress, setWhisperModelDownloadProgress] = useState(0); const [isGeneratingCaptions, setIsGeneratingCaptions] = useState(false); + const [captionGenerationProgress, setCaptionGenerationProgress] = useState(null); const [autoCaptionError, setAutoCaptionError] = useState(null); const [captionFfmpegPath, setCaptionFfmpegPath] = useState(null); const [captionFfmpegError, setCaptionFfmpegError] = useState(null); @@ -2753,6 +2754,11 @@ export default function VideoEditor() { toast.error(state.error); } }); + const unsubscribeCaptionProgress = window.electronAPI.onCaptionGenerationProgress( + (state) => { + setCaptionGenerationProgress(state.progress); + }, + ); void (async () => { const result = await window.electronAPI.getWhisperRuntimeStatus({ @@ -2797,7 +2803,10 @@ export default function VideoEditor() { setWhisperModelDownloadProgress(0); })(); - return () => unsubscribe?.(); + return () => { + unsubscribe?.(); + unsubscribeCaptionProgress?.(); + }; }, [initialWhisperExecutablePath]); const handlePickWhisperExecutable = useCallback( @@ -2958,6 +2967,7 @@ export default function VideoEditor() { } setAutoCaptionError(null); + setCaptionGenerationProgress(0); setIsGeneratingCaptions(true); try { const result = await window.electronAPI.generateAutoCaptions({ @@ -2994,6 +3004,7 @@ export default function VideoEditor() { toast.error(message); } finally { setIsGeneratingCaptions(false); + setCaptionGenerationProgress(null); } }, [ autoCaptionSettings.language, @@ -6637,6 +6648,7 @@ export default function VideoEditor() { captionGenerationError={autoCaptionError} captionFfmpegPath={captionFfmpegPath} captionFfmpegError={captionFfmpegError} + captionGenerationProgress={captionGenerationProgress} isGeneratingCaptions={isGeneratingCaptions} onAutoCaptionSettingsChange={setAutoCaptionSettings} onPickWhisperExecutable={handlePickWhisperExecutable} From f6d38b9176aa18904b897e33709f8c2be5fc4b4c Mon Sep 17 00:00:00 2001 From: QuentinCrane <1369394426@qq.com> Date: Sat, 18 Jul 2026 11:34:27 +0800 Subject: [PATCH 4/4] fix: address caption setup review feedback --- electron/ipc/captions/generate.ts | 30 +++++++-- electron/ipc/register/captions.ts | 16 ++--- src/components/video-editor/SettingsPanel.tsx | 17 ++++- src/components/video-editor/VideoEditor.tsx | 64 ++++++++++++------ .../video-editor/editorPreferences.test.ts | 27 ++++++++ .../video-editor/editorPreferences.ts | 10 ++- src/i18n/locales/en/settings.json | 1 + src/i18n/locales/es/settings.json | 47 +++++++------- src/i18n/locales/fr/settings.json | 65 ++++++++++--------- src/i18n/locales/it/settings.json | 7 +- src/i18n/locales/ko/settings.json | 1 + src/i18n/locales/nl/settings.json | 1 + src/i18n/locales/pt-BR/settings.json | 45 ++++++------- src/i18n/locales/ru/settings.json | 1 + src/i18n/locales/zh-CN/settings.json | 1 + src/i18n/locales/zh-TW/settings.json | 1 + 16 files changed, 216 insertions(+), 118 deletions(-) diff --git a/electron/ipc/captions/generate.ts b/electron/ipc/captions/generate.ts index 73295d9bd..26086c219 100644 --- a/electron/ipc/captions/generate.ts +++ b/electron/ipc/captions/generate.ts @@ -54,15 +54,28 @@ function runWhisperCommand( return new Promise((resolve, reject) => { const child = spawn(whisperExecutablePath, args, { windowsHide: true }); let recentOutput = ""; + let timedOut = false; + let processError: Error | null = null; + let forceKillTimeout: ReturnType | null = null; const timeout = setTimeout( () => { + timedOut = true; child.kill(); - reject(new Error("Whisper timed out after 30 minutes.")); + forceKillTimeout = setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) { + child.kill("SIGKILL"); + } + }, 5_000); }, 30 * 60 * 1000, ); - const clearWhisperTimeout = () => clearTimeout(timeout); + const clearWhisperTimeouts = () => { + clearTimeout(timeout); + if (forceKillTimeout) { + clearTimeout(forceKillTimeout); + } + }; const handleOutput = (chunk: Buffer) => { const output = chunk.toString("utf-8"); @@ -79,11 +92,18 @@ function runWhisperCommand( child.stdout?.on("data", handleOutput); child.stderr?.on("data", handleOutput); child.on("error", (error) => { - clearWhisperTimeout(); - reject(error); + processError = error; }); child.on("close", (code) => { - clearWhisperTimeout(); + clearWhisperTimeouts(); + if (timedOut) { + reject(new Error("Whisper timed out after 30 minutes.")); + return; + } + if (processError) { + reject(processError); + return; + } if (code === 0) { resolve(); return; diff --git a/electron/ipc/register/captions.ts b/electron/ipc/register/captions.ts index be724f072..54f397bb3 100644 --- a/electron/ipc/register/captions.ts +++ b/electron/ipc/register/captions.ts @@ -206,16 +206,12 @@ export function registerCaptionHandlers() { selectionMode === "file" ? "Use This Executable" : "Use This Folder", filters: selectionMode === "file" - ? [ - { - name: "Whisper Engine", - extensions: - process.platform === "win32" - ? ["exe", "cmd", "bat"] - : ["*"], - }, - { name: "All Files", extensions: ["*"] }, - ] + ? process.platform === "win32" + ? [{ name: "Whisper Engine", extensions: ["exe"] }] + : [ + { name: "Whisper Engine", extensions: ["*"] }, + { name: "All Files", extensions: ["*"] }, + ] : undefined, properties: [selectionMode === "file" ? "openFile" : "openDirectory"], }); diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index bcbce6976..e0db80817 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -831,6 +831,7 @@ interface SettingsPanelProps { autoCaptionSettings?: AutoCaptionSettings; whisperExecutablePath?: string | null; whisperModelPath?: string | null; + isDownloadedWhisperModelSelected?: boolean; whisperModelDownloadStatus?: "idle" | "downloading" | "downloaded" | "error"; whisperModelDownloadProgress?: number; captionGenerationError?: string | null; @@ -846,6 +847,7 @@ interface SettingsPanelProps { onClearAutoCaptions?: () => void; onDownloadWhisperSmallModel?: () => void; onDeleteWhisperSmallModel?: () => void; + onClearWhisperModelSelection?: () => void; captionCurrentTimeMs?: number; selectedCaptionId?: string | null; onBeginCaptionEdit?: (id: string) => void; @@ -1289,6 +1291,7 @@ export function SettingsPanel({ autoCaptionSettings = DEFAULT_AUTO_CAPTION_SETTINGS, whisperExecutablePath, whisperModelPath, + isDownloadedWhisperModelSelected = false, whisperModelDownloadStatus = "idle", whisperModelDownloadProgress = 0, captionGenerationError = null, @@ -1304,6 +1307,7 @@ export function SettingsPanel({ onClearAutoCaptions, onDownloadWhisperSmallModel, onDeleteWhisperSmallModel, + onClearWhisperModelSelection, captionCurrentTimeMs = 0, selectedCaptionId = null, onBeginCaptionEdit, @@ -3013,15 +3017,26 @@ export function SettingsPanel({
- {whisperModelPath ? ( + {whisperModelPath && isDownloadedWhisperModelSelected ? ( + ) : whisperModelPath ? ( + ) : (