From 712f8df2feaca88bd1e011707a85e5e852b6bc21 Mon Sep 17 00:00:00 2001 From: QuentinCrane <1369394426@qq.com> Date: Sat, 27 Jun 2026 18:12:35 +0800 Subject: [PATCH 1/3] 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 | 119 +++++- 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 | 110 +++++- 8 files changed, 862 insertions(+), 94 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 2aa748b49..230578c30 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -660,22 +660,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 33c345848..ae20e5890 100644 --- a/electron/ipc/captions/generate.ts +++ b/electron/ipc/captions/generate.ts @@ -1,19 +1,80 @@ +import { execFile, spawnSync } from "node:child_process"; import { constants as fsConstants } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; -import { execFile, spawnSync } from "node:child_process"; 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 { parseWhisperJsonCues, parseSrtCues, shouldRetryWhisperWithoutJson } from "./parser"; -import { normalizeVideoSourcePath } from "../utils"; import { resolveRecordingSession } from "../project/session"; +import { normalizeVideoSourcePath } from "../utils"; +import { parseSrtCues, parseWhisperJsonCues, shouldRetryWhisperWithoutJson } from "./parser"; 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); @@ -25,6 +86,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 { @@ -34,9 +99,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, @@ -51,12 +122,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 @@ -71,7 +138,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.", ); } @@ -94,6 +161,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; } @@ -169,8 +255,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 9a15edb99..a50dcfcaf 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -677,11 +677,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 fbf390b4b..2ed6241dc 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -807,10 +807,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; @@ -819,6 +823,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×" }, @@ -1239,12 +1252,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, @@ -1283,6 +1302,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, @@ -2607,15 +2756,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} +
+
@@ -2639,16 +2925,7 @@ export function SettingsPanel({
- {whisperModelDownloadStatus === "downloading" ? ( - - ) : whisperModelPath ? ( + {whisperModelPath ? ( )}
+ {captionGenerationHelpText ? ( +
+
+ {captionGenerationHelpTitle} +
+
+ {captionGenerationHelpText} +
+ {captionErrorIsMissingEngine || + (!whisperModelPath && !captionErrorIsMissingAudio) ? ( +
+ {captionErrorIsMissingEngine ? ( + + ) : null} + {!whisperModelPath && !captionErrorIsMissingAudio ? ( + + ) : null} +
+ ) : null} +
+ ) : null}
-
+
{whisperModelHelpText}
@@ -2838,7 +2863,9 @@ export function SettingsPanel({
-
+
{whisperEngineHelpText}
@@ -2890,7 +2920,9 @@ export function SettingsPanel({ ) : null} -
+
{captionFfmpegHelpText}
@@ -2998,7 +3033,7 @@ export function SettingsPanel({