diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 8a4eedd63..790022ee4 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<{ @@ -694,6 +713,12 @@ interface Window { error?: string; }) => void, ) => () => void; + onCaptionGenerationProgress: ( + callback: (state: { + stage: "preparing" | "extracting-audio" | "transcribing" | "finalizing"; + progress: number; + }) => void, + ) => () => void; generateAutoCaptions: (options: { videoPath: string; whisperExecutablePath?: string; diff --git a/electron/ipc/captions/generate.test.ts b/electron/ipc/captions/generate.test.ts new file mode 100644 index 000000000..d9e6b995f --- /dev/null +++ b/electron/ipc/captions/generate.test.ts @@ -0,0 +1,182 @@ +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.doUnmock("node:child_process"); + 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("parses Whisper progress output", async () => { + const { parseWhisperProgressPercent } = await import("./generate"); + + expect( + parseWhisperProgressPercent("whisper_print_progress_callback: progress = 42%"), + ).toBe(42); + expect(parseWhisperProgressPercent("progress = 5%\nprogress = 100%")).toBe(100); + expect(parseWhisperProgressPercent("no progress here")).toBeNull(); + }); + + 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 WHISPER_CPP_PATH when it points directly to an executable", async () => { + const executablePath = path.join(tempRoot, "direct", getWhisperCliName()); + await writeExecutable(executablePath); + vi.stubEnv("WHISPER_CPP_PATH", executablePath); + + 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 a selected executable file", async () => { + const executablePath = path.join(tempRoot, "selected-file", getWhisperCliName()); + await writeExecutable(executablePath); + + const { resolveWhisperExecutablePath } = await import("./generate"); + + expect(await resolveWhisperExecutablePath(executablePath)).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" }, + ]); + }); + + it("falls back to companion audio when the recording audio cannot be extracted", async () => { + const videoPath = path.join(tempRoot, "recording-2.mp4"); + const systemPath = path.join(tempRoot, "recording-2.system.wav"); + const wavPath = path.join(tempRoot, "captions.wav"); + await fs.writeFile(videoPath, "video without extractable audio"); + await fs.writeFile(systemPath, "system audio"); + + const execFileMock = vi.fn( + ( + _: string, + args: string[], + __: unknown, + callback: (error: Error | null, stdout?: string, stderr?: string) => void, + ) => { + const inputPath = args[args.indexOf("-i") + 1]; + if (inputPath === videoPath) { + callback(new Error("Stream map '0:a:0' matches no streams.")); + return; + } + + callback(null, "", ""); + }, + ); + vi.doMock("node:child_process", () => ({ + execFile: execFileMock, + spawn: vi.fn(), + spawnSync: vi.fn(() => ({ status: 1, stdout: "" })), + })); + + const { extractCaptionAudioSource } = await import("./generate"); + + await expect( + extractCaptionAudioSource({ + videoPath, + ffmpegPath: "ffmpeg", + wavPath, + }), + ).resolves.toEqual({ path: systemPath, label: "source system audio" }); + expect(execFileMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/electron/ipc/captions/generate.ts b/electron/ipc/captions/generate.ts index 2a3f49cd7..26086c219 100644 --- a/electron/ipc/captions/generate.ts +++ b/electron/ipc/captions/generate.ts @@ -1,9 +1,10 @@ -import { execFile, spawnSync } from "node:child_process"; +import { execFile, spawn, spawnSync } from "node:child_process"; import { constants as fsConstants } from "node:fs"; 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,162 @@ import { const execFileAsync = promisify(execFile); -export async function ensureReadableFile(filePath: string, options?: { executable?: boolean }) { - await fs.access(filePath, fsConstants.R_OK); +export type CaptionGenerationProgress = { + stage: "preparing" | "extracting-audio" | "transcribing" | "finalizing"; + progress: number; +}; + +function clampProgress(progress: number) { + return Math.min(100, Math.max(0, Math.round(progress))); +} + +function emitProgress( + onProgress: ((progress: CaptionGenerationProgress) => void) | undefined, + progress: CaptionGenerationProgress, +) { + onProgress?.({ ...progress, progress: clampProgress(progress.progress) }); +} + +export function parseWhisperProgressPercent(output: string) { + const matches = [...output.matchAll(/progress\s*=\s*(\d{1,3}(?:\.\d+)?)%/gi)]; + const match = matches[matches.length - 1]; + if (!match) { + return null; + } + + return clampProgress(Number(match[1])); +} + +function runWhisperCommand( + whisperExecutablePath: string, + args: string[], + onProgress?: (progress: CaptionGenerationProgress) => void, +) { + 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(); + forceKillTimeout = setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) { + child.kill("SIGKILL"); + } + }, 5_000); + }, + 30 * 60 * 1000, + ); + + const clearWhisperTimeouts = () => { + clearTimeout(timeout); + if (forceKillTimeout) { + clearTimeout(forceKillTimeout); + } + }; + + const handleOutput = (chunk: Buffer) => { + const output = chunk.toString("utf-8"); + recentOutput = `${recentOutput}${output}`.slice(-20_000); + const whisperProgress = parseWhisperProgressPercent(recentOutput); + if (whisperProgress !== null) { + emitProgress(onProgress, { + stage: "transcribing", + progress: 15 + whisperProgress * 0.8, + }); + } + }; + + child.stdout?.on("data", handleOutput); + child.stderr?.on("data", handleOutput); + child.on("error", (error) => { + processError = error; + }); + child.on("close", (code) => { + clearWhisperTimeouts(); + if (timedOut) { + reject(new Error("Whisper timed out after 30 minutes.")); + return; + } + if (processError) { + reject(processError); + return; + } + if (code === 0) { + resolve(); + return; + } + + reject(new Error(`Whisper exited with code ${code}. ${recentOutput.trim()}`.trim())); + }); + }); +} + +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 +187,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 +200,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 +223,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 +239,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 +262,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; } @@ -191,7 +371,9 @@ export async function generateAutoCaptionsFromVideo(options: { whisperExecutablePath?: string; whisperModelPath: string; language?: string; + onProgress?: (progress: CaptionGenerationProgress) => void; }) { + emitProgress(options.onProgress, { stage: "preparing", progress: 0 }); const ffmpegPath = getFfmpegBinaryPath(); const normalizedVideoPath = normalizeVideoSourcePath(options.videoPath); if (!normalizedVideoPath) { @@ -200,8 +382,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"), @@ -213,11 +398,13 @@ export async function generateAutoCaptionsFromVideo(options: { const jsonPath = `${outputBase}.json`; try { + emitProgress(options.onProgress, { stage: "extracting-audio", progress: 5 }); const audioSource = await extractCaptionAudioSource({ videoPath: normalizedVideoPath, ffmpegPath, wavPath, }); + emitProgress(options.onProgress, { stage: "transcribing", progress: 15 }); const language = options.language && options.language.trim() ? options.language.trim() : "auto"; @@ -231,15 +418,16 @@ export async function generateAutoCaptionsFromVideo(options: { outputBase, "-l", language, - "-np", + "-pp", ]; let jsonEnabled = true; try { - await execFileAsync(whisperExecutablePath, [...whisperBaseArgs, "-ojf"], { - timeout: 30 * 60 * 1000, - maxBuffer: 20 * 1024 * 1024, - }); + await runWhisperCommand( + whisperExecutablePath, + [...whisperBaseArgs, "-ojf"], + options.onProgress, + ); } catch (error) { if (!shouldRetryWhisperWithoutJson(error)) { throw error; @@ -250,12 +438,11 @@ export async function generateAutoCaptionsFromVideo(options: { "[auto-captions] Whisper runtime does not support JSON full output, retrying with SRT only:", error, ); - await execFileAsync(whisperExecutablePath, whisperBaseArgs, { - timeout: 30 * 60 * 1000, - maxBuffer: 20 * 1024 * 1024, - }); + emitProgress(options.onProgress, { stage: "transcribing", progress: 15 }); + await runWhisperCommand(whisperExecutablePath, whisperBaseArgs, options.onProgress); } + emitProgress(options.onProgress, { stage: "finalizing", progress: 96 }); const timedCues = jsonEnabled ? parseWhisperJsonCues(await fs.readFile(jsonPath, "utf-8")) : []; @@ -291,6 +478,7 @@ export async function generateAutoCaptionsFromVideo(options: { ); } + emitProgress(options.onProgress, { stage: "finalizing", progress: 100 }); return { cues: cuesToReturn, audioSourceLabel: audioSource.label, 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..54f397bb3 100644 --- a/electron/ipc/register/captions.ts +++ b/electron/ipc/register/captions.ts @@ -1,6 +1,11 @@ +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 { + type CaptionGenerationProgress, + generateAutoCaptionsFromVideo, + resolveWhisperExecutablePath, +} from "../captions/generate"; import { deleteWhisperSmallModel, downloadWhisperSmallModel, @@ -8,6 +13,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 +21,80 @@ 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, + ]; +} + +function sendCaptionGenerationProgress( + webContents: Electron.WebContents, + payload: CaptionGenerationProgress, +) { + if (webContents.isDestroyed()) { + return; + } + + webContents.send("caption-generation-progress", payload); +} + export function registerCaptionHandlers() { ipcMain.handle("open-video-file-picker", async (_, options?: OpenVideoFilePickerOptions) => { try { @@ -112,15 +188,61 @@ 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" + ? process.platform === "win32" + ? [{ name: "Whisper Engine", extensions: ["exe"] }] + : [ + { name: "Whisper Engine", extensions: ["*"] }, + { 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 +255,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) }; } }); @@ -224,7 +375,7 @@ export function registerCaptionHandlers() { ipcMain.handle( "generate-auto-captions", async ( - _, + event, options: { videoPath: string; whisperExecutablePath: string; @@ -233,7 +384,10 @@ export function registerCaptionHandlers() { }, ) => { try { - const result = await generateAutoCaptionsFromVideo(options); + const result = await generateAutoCaptionsFromVideo({ + ...options, + onProgress: (progress) => sendCaptionGenerationProgress(event.sender, progress), + }); return { success: true, cues: result.cues, @@ -244,10 +398,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..5120fb62f 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"); @@ -715,6 +727,22 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.on("whisper-small-model-download-progress", listener); return () => ipcRenderer.removeListener("whisper-small-model-download-progress", listener); }, + onCaptionGenerationProgress: ( + callback: (state: { + stage: "preparing" | "extracting-audio" | "transcribing" | "finalizing"; + progress: number; + }) => void, + ) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: { + stage: "preparing" | "extracting-audio" | "transcribing" | "finalizing"; + progress: number; + }, + ) => callback(payload); + ipcRenderer.on("caption-generation-progress", listener); + return () => ipcRenderer.removeListener("caption-generation-progress", listener); + }, generateAutoCaptions: (options: { videoPath: string; whisperExecutablePath?: string; diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index a90028e2b..e0db80817 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -47,6 +47,7 @@ import { SUPPORTED_LOCALES } from "../../i18n/config"; import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel"; import CaptionListPanel from "./CaptionListPanel"; import type { CaptionRetimeSpan } from "./captionOps"; +import { getCaptionSetupStatus } from "./captionSetupStatus"; import { CURSOR_MOTION_PRESETS, type CursorMotionPresetId, @@ -830,16 +831,23 @@ interface SettingsPanelProps { autoCaptionSettings?: AutoCaptionSettings; whisperExecutablePath?: string | null; whisperModelPath?: string | null; + isDownloadedWhisperModelSelected?: boolean; whisperModelDownloadStatus?: "idle" | "downloading" | "downloaded" | "error"; whisperModelDownloadProgress?: number; + captionGenerationError?: string | null; + captionFfmpegPath?: string | null; + captionFfmpegError?: string | null; + captionGenerationProgress?: number | 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; onDeleteWhisperSmallModel?: () => void; + onClearWhisperModelSelection?: () => void; captionCurrentTimeMs?: number; selectedCaptionId?: string | null; onBeginCaptionEdit?: (id: string) => void; @@ -852,6 +860,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,16 +1289,25 @@ export function SettingsPanel({ onAnnotationDelete, autoCaptions = [], autoCaptionSettings = DEFAULT_AUTO_CAPTION_SETTINGS, + whisperExecutablePath, whisperModelPath, + isDownloadedWhisperModelSelected = false, whisperModelDownloadStatus = "idle", whisperModelDownloadProgress = 0, + captionGenerationError = null, + captionFfmpegPath, + captionFfmpegError, + captionGenerationProgress = null, isGeneratingCaptions = false, onAutoCaptionSettingsChange, + onPickWhisperExecutable, onPickWhisperModel, + onShowCaptionPathInFolder, onGenerateAutoCaptions, onClearAutoCaptions, onDownloadWhisperSmallModel, onDeleteWhisperSmallModel, + onClearWhisperModelSelection, captionCurrentTimeMs = 0, selectedCaptionId = null, onBeginCaptionEdit, @@ -1324,6 +1350,167 @@ export function SettingsPanel({ [extensionWallpapers], ); const captionCueCount = autoCaptions.length; + const roundedCaptionGenerationProgress = + typeof captionGenerationProgress === "number" + ? Math.min(100, Math.max(0, Math.round(captionGenerationProgress))) + : null; + const captionSetupStatus = getCaptionSetupStatus({ + captionCueCount, + captionsEnabled: autoCaptionSettings.enabled, + whisperModelPath, + whisperExecutablePath, + captionFfmpegPath, + captionFfmpegError, + captionGenerationError, + isGeneratingCaptions, + }); + const { + isCaptionFfmpegChecking, + isMissingAudioError: captionErrorIsMissingAudio, + isMissingEngineError: captionErrorIsMissingEngine, + isMissingFfmpegError: captionErrorIsMissingFfmpeg, + } = captionSetupStatus; + 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 captionStatusTitle = + captionSetupStatus.id === "generating" + ? tSettings("captions.statusGenerating", "Generating captions") + : captionSetupStatus.id === "missing-audio" + ? tSettings("captions.noAudioTitle", "No audio to transcribe") + : captionSetupStatus.id === "missing-model" + ? tSettings("captions.modelNeedsSetup", "Choose a caption model") + : captionSetupStatus.id === "missing-engine" + ? tSettings("captions.engineNeedsSetup", "Choose Whisper engine") + : captionSetupStatus.id === "checking-ffmpeg" + ? tSettings("captions.ffmpegChecking", "Checking FFmpeg") + : captionSetupStatus.id === "missing-ffmpeg" + ? tSettings("captions.ffmpegNeedsSetup", "FFmpeg is unavailable") + : captionSetupStatus.id === "captions-hidden" + ? tSettings( + "captions.statusHiddenWithCaptions", + "Captions are hidden", + ) + : captionSetupStatus.id === "ready-with-captions" + ? tSettings( + "captions.statusReadyWithCaptions", + "Captions are ready", + ) + : captionSetupStatus.id === "error" + ? tSettings( + "captions.needsSetup", + "Captions need setup", + ) + : tSettings( + "captions.statusReadyToGenerate", + "Ready to generate", + ); + const captionStatusText = + captionSetupStatus.id === "generating" + ? roundedCaptionGenerationProgress !== null + ? `${tSettings( + "captions.generatingStatus", + "Generating captions. This can take a moment.", + )} ${roundedCaptionGenerationProgress}%` + : tSettings( + "captions.generatingStatus", + "Generating captions. This can take a moment.", + ) + : captionSetupStatus.id === "missing-audio" + ? 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.", + ) + : captionSetupStatus.id === "missing-model" + ? tSettings( + "captions.guideModelDescription", + "Download the small model, or choose an existing .bin model file.", + ) + : captionSetupStatus.id === "missing-engine" + ? tSettings( + "captions.guideEngineDescription", + "Choose the folder that contains whisper-cli. On Windows, C:\\Tools\\whisper is a good place to keep it.", + ) + : captionSetupStatus.id === "checking-ffmpeg" || + captionSetupStatus.id === "missing-ffmpeg" + ? captionFfmpegHelpText + : captionSetupStatus.id === "captions-hidden" + ? tSettings( + "captions.guideHiddenDescription", + "Generated captions are currently hidden. Turn on Show to display them in the preview and export.", + ) + : captionSetupStatus.id === "ready-with-captions" + ? tSettings( + "captions.guideReadyDescription", + "Generated captions are visible in the preview. Press Play to review timing before export.", + ) + : captionSetupStatus.id === "error" + ? (captionGenerationError ?? "") + : tSettings( + "captions.guideGenerateDescription", + "Model, engine, and audio extraction are ready.", + ); + const captionStatusToneClassName = + captionSetupStatus.tone === "error" + ? "border-red-500/20 bg-red-500/5" + : captionSetupStatus.tone === "ready" + ? "border-[#2563EB]/15 bg-[#2563EB]/5" + : "border-amber-500/20 bg-amber-500/5"; + const captionStatusDotClassName = + captionSetupStatus.tone === "error" + ? "bg-red-500" + : captionSetupStatus.tone === "ready" + ? "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 +2835,165 @@ 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,31 +3017,34 @@ export function SettingsPanel({
- {whisperModelDownloadStatus === "downloading" ? ( + {whisperModelPath && isDownloadedWhisperModelSelected ? ( ) : whisperModelPath ? ( ) : ( )}
+ {captionGenerationHelpText ? ( +
+
+ {captionGenerationHelpTitle} +
+
+ {captionGenerationHelpText} +
+ {captionErrorIsMissingEngine || + (!whisperModelPath && !captionErrorIsMissingAudio) ? ( +
+ {captionErrorIsMissingEngine ? ( + + ) : null} + {!whisperModelPath && !captionErrorIsMissingAudio ? ( + + ) : null} +
+ ) : null} +
+ ) : null}
{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 c2e16ed60..a213d3545 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -407,6 +407,7 @@ export default function VideoEditor() { typeof navigator !== "undefined" && /Mac/i.test(navigator.platform) ? "darwin" : "", ); const initialEditorPreferences = useMemo(() => loadEditorPreferences(), []); + const initialWhisperExecutablePath = initialEditorPreferences.whisperExecutablePath; const [videoPath, setVideoPath] = useState(null); const [videoSourcePath, setVideoSourcePath] = useState(null); const [currentProjectPath, setCurrentProjectPath] = useState(null); @@ -584,6 +585,10 @@ 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); const [isExporting, setIsExporting] = useState(false); const [exportProgress, setExportProgress] = useState(null); const [exportError, setExportError] = useState(null); @@ -2749,6 +2754,46 @@ export default function VideoEditor() { toast.error(state.error); } }); + const unsubscribeCaptionProgress = window.electronAPI.onCaptionGenerationProgress( + (state) => { + setCaptionGenerationProgress(state.progress); + }, + ); + + void (async () => { + try { + const result = await window.electronAPI.getWhisperRuntimeStatus({ + currentPath: initialWhisperExecutablePath, + }); + if (result.success && result.exists && result.path) { + setWhisperExecutablePath(result.path); + return; + } + + setWhisperExecutablePath(null); + setAutoCaptionError(result.error || "Whisper engine is unavailable"); + } catch (error) { + setWhisperExecutablePath(null); + setAutoCaptionError(getErrorMessage(error)); + } + })(); + + void (async () => { + try { + const result = await window.electronAPI.getCaptionFfmpegStatus(); + if (result.success && result.exists && result.path) { + setCaptionFfmpegPath(result.path); + setCaptionFfmpegError(null); + return; + } + + setCaptionFfmpegPath(null); + setCaptionFfmpegError(result.error || "FFmpeg binary is unavailable"); + } catch (error) { + setCaptionFfmpegPath(null); + setCaptionFfmpegError(getErrorMessage(error)); + } + })(); void (async () => { const result = await window.electronAPI.getWhisperSmallModelStatus(); @@ -2765,34 +2810,68 @@ export default function VideoEditor() { } setDownloadedWhisperModelPath(null); + setWhisperModelPath((currentPath) => + currentPath && currentPath === result.expectedPath ? null : currentPath, + ); setWhisperModelDownloadStatus("idle"); setWhisperModelDownloadProgress(0); })(); - return () => unsubscribe?.(); - }, []); + return () => { + unsubscribe?.(); + unsubscribeCaptionProgress?.(); + }; + }, [initialWhisperExecutablePath]); - const handlePickWhisperExecutable = useCallback(async () => { - const result = await window.electronAPI.openWhisperExecutablePicker(); - if (!result.success || !result.path) { - return; - } + const handlePickWhisperExecutable = useCallback( + async (selectionMode: "file" | "directory" = "directory") => { + const result = await window.electronAPI.openWhisperExecutablePicker({ + currentPath: whisperExecutablePath, + selectionMode, + }); + if (!result.success || !result.path) { + return; + } - setWhisperExecutablePath(result.path); - toast.success("Whisper executable selected"); - }, []); + setAutoCaptionError(null); + setWhisperExecutablePath(result.path); + toast.success(t("settings.captions.runtimeSelected", "Whisper runtime selected")); + }, + [t, whisperExecutablePath], + ); + + const handleShowCaptionPathInFolder = useCallback( + async (path?: string | null) => { + const result = await window.electronAPI.showCaptionPathInFolder(path); + if (!result.success) { + toast.error( + result.error || + t("settings.captions.openLocationFailed", "Could not open this location"), + ); + } + }, + [t], + ); const handleDownloadWhisperSmallModel = useCallback(async () => { if (whisperModelDownloadStatus === "downloading") { return; } + setAutoCaptionError(null); setWhisperModelDownloadStatus("downloading"); setWhisperModelDownloadProgress(0); const result = await window.electronAPI.downloadWhisperSmallModel(); if (!result.success) { + const message = + result.error || + t( + "settings.captions.downloadModelFailed", + "Failed to download Whisper small model", + ); + setAutoCaptionError(message); setWhisperModelDownloadStatus("error"); - toast.error(result.error || "Failed to download Whisper small model"); + toast.error(message); return; } @@ -2800,22 +2879,31 @@ export default function VideoEditor() { setDownloadedWhisperModelPath(result.path); setWhisperModelPath(result.path); } - }, [whisperModelDownloadStatus]); + }, [t, whisperModelDownloadStatus]); const handlePickWhisperModel = useCallback(async () => { - const result = await window.electronAPI.openWhisperModelPicker(); + const result = await window.electronAPI.openWhisperModelPicker({ + currentPath: whisperModelPath, + }); if (!result.success || !result.path) { return; } + setAutoCaptionError(null); setWhisperModelPath(result.path); - toast.success("Whisper model selected"); - }, []); + toast.success(t("settings.captions.modelSelected", "Whisper model selected")); + }, [t, whisperModelPath]); const handleDeleteWhisperSmallModel = useCallback(async () => { const result = await window.electronAPI.deleteWhisperSmallModel(); if (!result.success) { - toast.error(result.error || "Failed to delete Whisper small model"); + toast.error( + result.error || + t( + "settings.captions.deleteModelFailed", + "Failed to delete Whisper small model", + ), + ); // Reset download state so re-download is not blocked setWhisperModelDownloadStatus("idle"); setWhisperModelDownloadProgress(0); @@ -2828,7 +2916,13 @@ export default function VideoEditor() { setDownloadedWhisperModelPath(null); setWhisperModelDownloadStatus("idle"); setWhisperModelDownloadProgress(0); - toast.success("Whisper small model deleted"); + setAutoCaptionError(null); + toast.success(t("settings.captions.modelDeleted", "Whisper small model deleted")); + }, [downloadedWhisperModelPath, t]); + + const handleClearWhisperModelSelection = useCallback(() => { + setWhisperModelPath(downloadedWhisperModelPath); + setAutoCaptionError(null); }, [downloadedWhisperModelPath]); const handleGenerateAutoCaptions = useCallback(async () => { @@ -2856,7 +2950,9 @@ export default function VideoEditor() { } if (!sourcePath) { - toast.error("No source video is loaded"); + const message = t("settings.captions.noSourceVideo", "No source video is loaded"); + setAutoCaptionError(message); + toast.error(message); return; } @@ -2867,11 +2963,30 @@ export default function VideoEditor() { await syncActiveVideoSource(sourcePath, webcam.sourcePath ?? null); + if (!captionFfmpegPath) { + const message = + captionFfmpegError || + t( + "settings.captions.ffmpegStillChecking", + "Recordly is still checking FFmpeg. Try generating captions again in a moment.", + ); + setAutoCaptionError(message); + toast.error(message); + return; + } + if (!whisperModelPath) { - toast.error("Select a Whisper model or download the small model first"); + const message = t( + "settings.captions.selectModelFirst", + "Select a Whisper model or download the small model first", + ); + setAutoCaptionError(message); + toast.error(message); return; } + setAutoCaptionError(null); + setCaptionGenerationProgress(0); setIsGeneratingCaptions(true); try { const result = await window.electronAPI.generateAutoCaptions({ @@ -2882,27 +2997,40 @@ export default function VideoEditor() { }); if (!result.success || !result.cues) { - toast.error( - result.message || - getErrorMessage(result.error) || - "Failed to generate captions", - ); + const message = + (result.error ? getErrorMessage(result.error) : result.message) || + t("settings.captions.generateFailed", "Failed to generate captions"); + setAutoCaptionError(message); + toast.error(message); return; } setAutoCaptions(result.cues); if (result.cues.length > 0) { setAutoCaptionSettings((prev) => ({ ...prev, enabled: true })); + toast.success( + t( + "settings.captions.generatedToast", + "Generated {{count}} captions and turned captions on", + { count: result.cues.length }, + ), + ); } - toast.success(result.message || `Generated ${result.cues.length} captions`); + setAutoCaptionError(null); } catch (error) { - toast.error(getErrorMessage(error)); + const message = getErrorMessage(error); + setAutoCaptionError(message); + toast.error(message); } finally { setIsGeneratingCaptions(false); + setCaptionGenerationProgress(null); } }, [ autoCaptionSettings.language, + captionFfmpegError, + captionFfmpegPath, isGeneratingCaptions, + t, webcam.sourcePath, syncActiveVideoSource, videoPath, @@ -6534,12 +6662,21 @@ export default function VideoEditor() { autoCaptionSettings={autoCaptionSettings} whisperExecutablePath={whisperExecutablePath} whisperModelPath={whisperModelPath} + isDownloadedWhisperModelSelected={Boolean( + downloadedWhisperModelPath && + whisperModelPath === downloadedWhisperModelPath, + )} whisperModelDownloadStatus={whisperModelDownloadStatus} whisperModelDownloadProgress={whisperModelDownloadProgress} + captionGenerationError={autoCaptionError} + captionFfmpegPath={captionFfmpegPath} + captionFfmpegError={captionFfmpegError} + captionGenerationProgress={captionGenerationProgress} isGeneratingCaptions={isGeneratingCaptions} onAutoCaptionSettingsChange={setAutoCaptionSettings} onPickWhisperExecutable={handlePickWhisperExecutable} onPickWhisperModel={handlePickWhisperModel} + onShowCaptionPathInFolder={handleShowCaptionPathInFolder} onGenerateAutoCaptions={handleGenerateAutoCaptions} onClearAutoCaptions={handleClearAutoCaptions} captionCurrentTimeMs={Math.round(currentTime * 1000)} @@ -6552,6 +6689,7 @@ export default function VideoEditor() { onCaptionDelete={handleCaptionDelete} onDownloadWhisperSmallModel={handleDownloadWhisperSmallModel} onDeleteWhisperSmallModel={handleDeleteWhisperSmallModel} + onClearWhisperModelSelection={handleClearWhisperModelSelection} nativeCaptureUnavailableSession={sessionNativeCaptureUnavailable} onOpenNativeCaptureUnavailableModal={() => setNativeCaptureUnavailableModalOpen(true) diff --git a/src/components/video-editor/captionSetupStatus.test.ts b/src/components/video-editor/captionSetupStatus.test.ts new file mode 100644 index 000000000..9271fc8c2 --- /dev/null +++ b/src/components/video-editor/captionSetupStatus.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; + +import { getCaptionSetupStatus } from "./captionSetupStatus"; + +const READY_INPUT = { + captionCueCount: 0, + captionsEnabled: true, + whisperModelPath: "/models/ggml-small.bin", + whisperExecutablePath: "/tools/whisper/whisper-cli", + captionFfmpegPath: "/tools/ffmpeg", + captionFfmpegError: null, + captionGenerationError: null, + isGeneratingCaptions: false, +}; + +describe("getCaptionSetupStatus", () => { + it("guides the user to choose or download a model first", () => { + const status = getCaptionSetupStatus({ + ...READY_INPUT, + whisperModelPath: null, + }); + + expect(status.id).toBe("missing-model"); + expect(status.canGenerate).toBe(false); + expect(status.tone).toBe("setup"); + }); + + it("treats an invalid selected runtime as an engine setup issue", () => { + const status = getCaptionSetupStatus({ + ...READY_INPUT, + captionGenerationError: "Whisper runtime is not marked as executable.", + }); + + expect(status.id).toBe("missing-engine"); + expect(status.isMissingEngineError).toBe(true); + expect(status.canGenerate).toBe(false); + }); + + it("surfaces FFmpeg failures after model and engine are ready", () => { + const status = getCaptionSetupStatus({ + ...READY_INPUT, + captionFfmpegPath: null, + captionFfmpegError: "FFmpeg binary is unavailable.", + }); + + expect(status.id).toBe("missing-ffmpeg"); + expect(status.isMissingFfmpegError).toBe(true); + expect(status.tone).toBe("error"); + }); + + it("keeps generation available for no-audio errors so a changed source can be retried", () => { + const status = getCaptionSetupStatus({ + ...READY_INPUT, + captionGenerationError: "No audio was found to transcribe.", + }); + + expect(status.id).toBe("missing-audio"); + expect(status.isMissingAudioError).toBe(true); + expect(status.canGenerate).toBe(true); + }); + + it("explains when generated captions exist but are hidden", () => { + const status = getCaptionSetupStatus({ + ...READY_INPUT, + captionCueCount: 4, + captionsEnabled: false, + }); + + expect(status.id).toBe("captions-hidden"); + expect(status.canGenerate).toBe(true); + expect(status.tone).toBe("setup"); + }); + + it("marks captions as ready when cues exist and the preview toggle is enabled", () => { + const status = getCaptionSetupStatus({ + ...READY_INPUT, + captionCueCount: 4, + captionsEnabled: true, + }); + + expect(status.id).toBe("ready-with-captions"); + expect(status.canGenerate).toBe(true); + expect(status.tone).toBe("ready"); + }); +}); diff --git a/src/components/video-editor/captionSetupStatus.ts b/src/components/video-editor/captionSetupStatus.ts new file mode 100644 index 000000000..4ea11ae2b --- /dev/null +++ b/src/components/video-editor/captionSetupStatus.ts @@ -0,0 +1,109 @@ +export type CaptionSetupStatusId = + | "generating" + | "missing-audio" + | "missing-model" + | "missing-engine" + | "checking-ffmpeg" + | "missing-ffmpeg" + | "captions-hidden" + | "ready-with-captions" + | "ready-to-generate" + | "error"; + +export type CaptionSetupStatusTone = "error" | "ready" | "setup"; + +export interface CaptionSetupStatusInput { + captionCueCount: number; + captionsEnabled: boolean; + whisperModelPath?: string | null; + whisperExecutablePath?: string | null; + captionFfmpegPath?: string | null; + captionFfmpegError?: string | null; + captionGenerationError?: string | null; + isGeneratingCaptions: boolean; +} + +export interface CaptionSetupStatus { + id: CaptionSetupStatusId; + tone: CaptionSetupStatusTone; + canGenerate: boolean; + isWhisperModelReady: boolean; + isWhisperEngineReady: boolean; + isCaptionFfmpegChecking: boolean; + isCaptionFfmpegReady: boolean; + isMissingAudioError: boolean; + isMissingEngineError: boolean; + isMissingFfmpegError: boolean; +} + +function includesAny(value: string | null | undefined, needles: string[]) { + const normalizedValue = value?.toLowerCase() ?? ""; + return needles.some((needle) => normalizedValue.includes(needle)); +} + +export function getCaptionSetupStatus(input: CaptionSetupStatusInput): CaptionSetupStatus { + const isWhisperModelReady = Boolean(input.whisperModelPath); + const isWhisperEngineReady = Boolean(input.whisperExecutablePath); + const isCaptionFfmpegChecking = !input.captionFfmpegPath && !input.captionFfmpegError; + const isCaptionFfmpegReady = Boolean(input.captionFfmpegPath) && !input.captionFfmpegError; + const isMissingAudioError = includesAny(input.captionGenerationError, [ + "no audio was found", + "no audio to transcribe", + ]); + const isMissingEngineError = includesAny(input.captionGenerationError, [ + "whisper engine", + "whisper runtime", + "whisper executable", + ]); + const isFfmpegBlocking = !isCaptionFfmpegReady && isWhisperModelReady && isWhisperEngineReady; + const isMissingFfmpegError = + includesAny(input.captionGenerationError, ["ffmpeg"]) || + (isFfmpegBlocking && Boolean(input.captionFfmpegError)); + const canGenerate = + isWhisperModelReady && + isWhisperEngineReady && + isCaptionFfmpegReady && + !isMissingEngineError && + !isMissingFfmpegError; + + let id: CaptionSetupStatusId = "ready-to-generate"; + if (input.isGeneratingCaptions) { + id = "generating"; + } else if (isMissingAudioError) { + id = "missing-audio"; + } else if (!isWhisperModelReady) { + id = "missing-model"; + } else if (!isWhisperEngineReady || isMissingEngineError) { + id = "missing-engine"; + } else if (isCaptionFfmpegChecking) { + id = "checking-ffmpeg"; + } else if (!isCaptionFfmpegReady || isMissingFfmpegError) { + id = "missing-ffmpeg"; + } else if (input.captionGenerationError) { + id = "error"; + } else if (input.captionCueCount > 0 && !input.captionsEnabled) { + id = "captions-hidden"; + } else if (input.captionCueCount > 0) { + id = "ready-with-captions"; + } + + const tone: CaptionSetupStatusTone = + id === "missing-audio" || id === "missing-ffmpeg" || id === "error" + ? "error" + : id === "ready-with-captions" || id === "ready-to-generate" + ? "ready" + : "setup"; + + return { + id, + tone, + canGenerate, + isWhisperModelReady, + isWhisperEngineReady, + isCaptionFfmpegChecking, + isCaptionFfmpegReady, + isMissingAudioError, + isMissingEngineError, + isMissingFfmpegError, + }; +} diff --git a/src/components/video-editor/editorPreferences.test.ts b/src/components/video-editor/editorPreferences.test.ts index 59566e62d..7f09bfe15 100644 --- a/src/components/video-editor/editorPreferences.test.ts +++ b/src/components/video-editor/editorPreferences.test.ts @@ -150,6 +150,33 @@ describe("editorPreferences", () => { expect(stored).not.toHaveProperty("zoomMotionBlurTuning"); }); + it("persists cleared Whisper runtime and model selections", () => { + const storedPreferences = { + ...DEFAULT_EDITOR_PREFERENCES, + whisperExecutablePath: "C:\\Tools\\whisper\\whisper-cli.exe", + whisperModelPath: "C:\\Models\\ggml-small.bin", + }; + const settingsStore = stubElectronSettings({ + [EDITOR_PREFERENCES_STORAGE_KEY]: storedPreferences, + }); + vi.stubGlobal( + "localStorage", + createStorageMock({ + [EDITOR_PREFERENCES_STORAGE_KEY]: JSON.stringify(storedPreferences), + }), + ); + + saveEditorPreferences({ + whisperExecutablePath: null, + whisperModelPath: null, + }); + + expect(settingsStore.get(EDITOR_PREFERENCES_STORAGE_KEY)).toMatchObject({ + whisperExecutablePath: null, + whisperModelPath: null, + }); + }); + it("loads stored editor control preferences", () => { vi.stubGlobal( "localStorage", diff --git a/src/components/video-editor/editorPreferences.ts b/src/components/video-editor/editorPreferences.ts index fbf354ab7..4a8440613 100644 --- a/src/components/video-editor/editorPreferences.ts +++ b/src/components/video-editor/editorPreferences.ts @@ -442,8 +442,14 @@ export function normalizeEditorPreferences( fallback.autoApplyFreshRecordingAutoZooms, ), whisperExecutablePath: - normalizeNullablePath(raw.whisperExecutablePath) ?? fallback.whisperExecutablePath, - whisperModelPath: normalizeNullablePath(raw.whisperModelPath) ?? fallback.whisperModelPath, + raw.whisperExecutablePath === null + ? null + : (normalizeNullablePath(raw.whisperExecutablePath) ?? + fallback.whisperExecutablePath), + whisperModelPath: + raw.whisperModelPath === null + ? null + : (normalizeNullablePath(raw.whisperModelPath) ?? fallback.whisperModelPath), }; } diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 60aa86221..627dc2306 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -173,6 +173,7 @@ "downloading": "Downloading...", "deleteModel": "Delete Model", "clearModel": "Clear Model", + "clearModelSelection": "Clear selection", "downloadModel": "Download Model", "generating": "Generating...", "generateFull": "Generate Captions", @@ -193,6 +194,57 @@ "boxRadius": "Box Radius", "backgroundOpacity": "Background Opacity", "textColor": "Text Color", + "noModelSelected": "No model selected", + "noEngineSelected": "No engine selected", + "modelHelp": "Download the small model or choose a .bin model file.", + "engineHelp": "Choose the folder that contains whisper-cli, such as C:\\Tools\\whisper on Windows or /opt/homebrew/bin on macOS.", + "ffmpegChecking": "Checking FFmpeg", + "ffmpegMissing": "FFmpeg not found", + "ffmpegCheckingHelp": "Looking for Recordly's bundled FFmpeg or an ffmpeg command on PATH.", + "ffmpegHelp": "Recordly needs FFmpeg to extract audio before Whisper can transcribe it.", + "statusGenerating": "Generating captions", + "noAudioTitle": "No audio to transcribe", + "modelNeedsSetup": "Choose a caption model", + "engineNeedsSetup": "Choose Whisper engine", + "ffmpegNeedsSetup": "FFmpeg is unavailable", + "statusReadyWithCaptions": "Captions are ready", + "statusReadyToGenerate": "Ready to generate", + "statusHiddenWithCaptions": "Captions are hidden", + "generatingStatus": "Generating captions. This can take a moment.", + "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.", + "guideModelDescription": "Download the small model, or choose an existing .bin model file.", + "guideEngineDescription": "Choose the folder that contains whisper-cli. On Windows, C:\\Tools\\whisper is a good place to keep it.", + "guideReadyDescription": "Generated captions are visible in the preview. Press Play to review timing before export.", + "guideHiddenDescription": "Generated captions are currently hidden. Turn on Show to display them in the preview and export.", + "guideGenerateDescription": "Model, engine, and audio extraction are ready.", + "needsSetup": "Captions need setup", + "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.", + "modelLabel": "Model", + "showInFolder": "Show", + "downloadModelShort": "Download", + "changeModel": "Change", + "chooseModel": "Choose", + "engineLabel": "Whisper engine", + "changeEngineFolder": "Folder", + "chooseEngineFolder": "Choose folder", + "chooseEngineFile": "File", + "audioExtractorLabel": "Audio extractor", + "modelNeeded": "Model needed", + "chooseEngine": "Choose engine", + "downloadSmallModel": "Download small model", + "runtimeSelected": "Whisper runtime selected", + "openLocationFailed": "Could not open this location", + "downloadModelFailed": "Failed to download Whisper small model", + "modelSelected": "Whisper model selected", + "deleteModelFailed": "Failed to delete Whisper small model", + "modelDeleted": "Whisper small model deleted", + "noSourceVideo": "No source video is loaded", + "ffmpegStillChecking": "Recordly is still checking FFmpeg. Try generating captions again in a moment.", + "selectModelFirst": "Select a Whisper model or download the small model first", + "generateFailed": "Failed to generate captions", + "generatedToast": "Generated {{count}} captions and turned captions on", + "editSaved": "Caption updated", + "editCurrent": "Edit current caption", "editor": { "text": "Text", "start": "Start", diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index a901f80d7..4fce30813 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -125,6 +125,7 @@ "downloading": "Descargando...", "deleteModel": "Eliminar modelo", "clearModel": "Borrar modelo", + "clearModelSelection": "Borrar selección", "downloadModel": "Descargar modelo", "generating": "Generando...", "generateFull": "Generar subtítulos", @@ -146,7 +147,56 @@ "maxWidth": "Ancho máximo", "boxRadius": "Radio del contenedor", "backgroundOpacity": "Opacidad del fondo", - "textColor": "Color del texto" + "textColor": "Color del texto", + "noModelSelected": "Ningún modelo seleccionado", + "noEngineSelected": "Ningún motor seleccionado", + "modelHelp": "Descarga el modelo small o elige un archivo de modelo .bin.", + "engineHelp": "Elige la carpeta que contiene whisper-cli, como C:\\Tools\\whisper en Windows o /opt/homebrew/bin en macOS.", + "ffmpegChecking": "Comprobando FFmpeg", + "ffmpegMissing": "No se encontró FFmpeg", + "ffmpegCheckingHelp": "Buscando el FFmpeg incluido en Recordly o un comando ffmpeg en PATH.", + "ffmpegHelp": "Recordly necesita FFmpeg para extraer el audio antes de que Whisper pueda transcribirlo.", + "statusGenerating": "Generando subtítulos", + "noAudioTitle": "No hay audio para transcribir", + "modelNeedsSetup": "Elige un modelo de subtítulos", + "engineNeedsSetup": "Elige el motor Whisper", + "ffmpegNeedsSetup": "FFmpeg no está disponible", + "statusReadyWithCaptions": "Los subtítulos están listos", + "statusReadyToGenerate": "Listo para generar", + "statusHiddenWithCaptions": "Los subtítulos están ocultos", + "generatingStatus": "Generando subtítulos. Puede tardar un momento.", + "noAudioHelp": "Este vídeo no contiene una pista de audio que Recordly pueda transcribir. Abre un vídeo con audio o vuelve a grabar con el micrófono o el audio del sistema activado.", + "guideModelDescription": "Descarga el modelo small o elige un archivo .bin existente.", + "guideEngineDescription": "Elige la carpeta que contiene whisper-cli. En Windows, C:\\Tools\\whisper es una buena ubicación.", + "guideReadyDescription": "Los subtítulos generados se ven en la vista previa. Pulsa Reproducir para revisar el tiempo antes de exportar.", + "guideHiddenDescription": "Los subtítulos generados están ocultos. Activa Mostrar para verlos en la vista previa y en la exportación.", + "guideGenerateDescription": "El modelo, el motor y la extracción de audio están listos.", + "needsSetup": "Los subtítulos necesitan configuración", + "engineErrorHelp": "No se encontró el motor Whisper. Elige la carpeta que contiene whisper-cli, como C:\\Tools\\whisper en Windows o /opt/homebrew/bin en macOS.", + "modelLabel": "Modelo", + "showInFolder": "Mostrar", + "downloadModelShort": "Descargar", + "changeModel": "Cambiar", + "chooseModel": "Elegir", + "engineLabel": "Motor Whisper", + "changeEngineFolder": "Carpeta", + "chooseEngineFolder": "Elegir carpeta", + "chooseEngineFile": "Archivo", + "audioExtractorLabel": "Extractor de audio", + "modelNeeded": "Falta modelo", + "chooseEngine": "Elegir motor", + "downloadSmallModel": "Descargar modelo small", + "runtimeSelected": "Runtime de Whisper seleccionado", + "openLocationFailed": "No se pudo abrir esta ubicación", + "downloadModelFailed": "No se pudo descargar el modelo small de Whisper", + "modelSelected": "Modelo Whisper seleccionado", + "deleteModelFailed": "No se pudo eliminar el modelo small de Whisper", + "modelDeleted": "Modelo small de Whisper eliminado", + "noSourceVideo": "No hay ningún vídeo fuente cargado", + "ffmpegStillChecking": "Recordly aún está comprobando FFmpeg. Intenta generar subtítulos de nuevo en un momento.", + "selectModelFirst": "Selecciona un modelo Whisper o descarga primero el modelo small", + "generateFailed": "No se pudieron generar los subtítulos", + "generatedToast": "Se generaron {{count}} subtítulos y se activó su visualización" }, "crop": { "title": "Recortar video", diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index d18c990c2..42fbb75a3 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -125,6 +125,7 @@ "downloading": "Téléchargement...", "deleteModel": "Supprimer le modèle", "clearModel": "Effacer le modèle", + "clearModelSelection": "Effacer la sélection", "downloadModel": "Télécharger le modèle", "generating": "Génération...", "generateFull": "Générer les sous-titres", @@ -146,7 +147,56 @@ "maxWidth": "Largeur max", "boxRadius": "Rayon de la boîte", "backgroundOpacity": "Opacité de l’arrière-plan", - "textColor": "Couleur du texte" + "textColor": "Couleur du texte", + "noModelSelected": "Aucun modèle sélectionné", + "noEngineSelected": "Aucun moteur sélectionné", + "modelHelp": "Téléchargez le modèle small ou choisissez un fichier de modèle .bin.", + "engineHelp": "Choisissez le dossier contenant whisper-cli, par exemple C:\\Tools\\whisper sous Windows ou /opt/homebrew/bin sous macOS.", + "ffmpegChecking": "Vérification de FFmpeg", + "ffmpegMissing": "FFmpeg introuvable", + "ffmpegCheckingHelp": "Recherche du FFmpeg intégré à Recordly ou d'une commande ffmpeg dans PATH.", + "ffmpegHelp": "Recordly a besoin de FFmpeg pour extraire l'audio avant la transcription par Whisper.", + "statusGenerating": "Génération des sous-titres", + "noAudioTitle": "Aucun audio à transcrire", + "modelNeedsSetup": "Choisissez un modèle de sous-titres", + "engineNeedsSetup": "Choisissez le moteur Whisper", + "ffmpegNeedsSetup": "FFmpeg est indisponible", + "statusReadyWithCaptions": "Les sous-titres sont prêts", + "statusReadyToGenerate": "Prêt à générer", + "statusHiddenWithCaptions": "Les sous-titres sont masqués", + "generatingStatus": "Génération des sous-titres. Cela peut prendre un moment.", + "noAudioHelp": "Cette vidéo ne contient pas de piste audio que Recordly peut transcrire. Ouvrez une vidéo avec audio ou enregistrez à nouveau avec le micro ou l'audio système activé.", + "guideModelDescription": "Téléchargez le modèle small ou choisissez un fichier .bin existant.", + "guideEngineDescription": "Choisissez le dossier contenant whisper-cli. Sous Windows, C:\\Tools\\whisper est un bon emplacement.", + "guideReadyDescription": "Les sous-titres générés sont visibles dans l'aperçu. Lancez la lecture pour vérifier le minutage avant l'export.", + "guideHiddenDescription": "Les sous-titres générés sont actuellement masqués. Activez Afficher pour les voir dans l'aperçu et l'export.", + "guideGenerateDescription": "Le modèle, le moteur et l'extraction audio sont prêts.", + "needsSetup": "Les sous-titres doivent être configurés", + "engineErrorHelp": "Le moteur Whisper est introuvable. Choisissez le dossier contenant whisper-cli, par exemple C:\\Tools\\whisper sous Windows ou /opt/homebrew/bin sous macOS.", + "modelLabel": "Modèle", + "showInFolder": "Afficher", + "downloadModelShort": "Télécharger", + "changeModel": "Changer", + "chooseModel": "Choisir", + "engineLabel": "Moteur Whisper", + "changeEngineFolder": "Dossier", + "chooseEngineFolder": "Choisir dossier", + "chooseEngineFile": "Fichier", + "audioExtractorLabel": "Extracteur audio", + "modelNeeded": "Modele requis", + "chooseEngine": "Choisir moteur", + "downloadSmallModel": "Télécharger le modèle small", + "runtimeSelected": "Runtime Whisper sélectionné", + "openLocationFailed": "Impossible d'ouvrir cet emplacement", + "downloadModelFailed": "Échec du téléchargement du modèle small Whisper", + "modelSelected": "Modèle Whisper sélectionné", + "deleteModelFailed": "Échec de la suppression du modèle small Whisper", + "modelDeleted": "Modèle small Whisper supprimé", + "noSourceVideo": "Aucune vidéo source n'est chargée", + "ffmpegStillChecking": "Recordly vérifie encore FFmpeg. Réessayez de générer les sous-titres dans un instant.", + "selectModelFirst": "Sélectionnez un modèle Whisper ou téléchargez d'abord le modèle small", + "generateFailed": "Échec de la génération des sous-titres", + "generatedToast": "{{count}} sous-titres générés et affichage activé" }, "crop": { "title": "Recadrer la vidéo", diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index b2bbb9dd8..3aac86b12 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -147,6 +147,7 @@ "downloading": "Download in corso...", "deleteModel": "Elimina modello", "clearModel": "Cancella modello", + "clearModelSelection": "Cancella selezione", "downloadModel": "Scarica modello", "generating": "Generazione in corso...", "generateFull": "Genera sottotitoli", @@ -166,7 +167,58 @@ "maxWidth": "Larghezza massima", "boxRadius": "Raggio riquadro", "backgroundOpacity": "Opacità sfondo", - "textColor": "Colore testo" + "textColor": "Colore testo", + "noModelSelected": "Nessun modello selezionato", + "noEngineSelected": "Nessun motore selezionato", + "modelHelp": "Scarica il modello small o scegli un file modello .bin.", + "engineHelp": "Scegli la cartella che contiene whisper-cli, ad esempio C:\\Tools\\whisper su Windows o /opt/homebrew/bin su macOS.", + "ffmpegChecking": "Controllo di FFmpeg", + "ffmpegMissing": "FFmpeg non trovato", + "ffmpegCheckingHelp": "Ricerca di FFmpeg incluso in Recordly o di un comando ffmpeg nel PATH.", + "ffmpegHelp": "Recordly richiede FFmpeg per estrarre l'audio prima della trascrizione con Whisper.", + "statusGenerating": "Generazione sottotitoli", + "noAudioTitle": "Nessun audio da trascrivere", + "modelNeedsSetup": "Scegli un modello per i sottotitoli", + "engineNeedsSetup": "Scegli il motore Whisper", + "ffmpegNeedsSetup": "FFmpeg non è disponibile", + "statusReadyWithCaptions": "I sottotitoli sono pronti", + "statusReadyToGenerate": "Pronto per generare", + "statusHiddenWithCaptions": "I sottotitoli sono nascosti", + "generatingStatus": "Generazione dei sottotitoli. Potrebbe richiedere un momento.", + "noAudioHelp": "Questo video non contiene una traccia audio che Recordly può trascrivere. Apri un video con audio o registra di nuovo con microfono o audio di sistema attivo.", + "guideModelDescription": "Scarica il modello small o scegli un file .bin esistente.", + "guideEngineDescription": "Scegli la cartella che contiene whisper-cli. Su Windows, C:\\Tools\\whisper è una buona posizione.", + "guideReadyDescription": "I sottotitoli generati sono visibili nell'anteprima. Premi Play per verificare i tempi prima dell'esportazione.", + "guideHiddenDescription": "I sottotitoli generati sono nascosti. Attiva Mostra per visualizzarli nell'anteprima e nell'esportazione.", + "guideGenerateDescription": "Modello, motore ed estrazione audio sono pronti.", + "needsSetup": "I sottotitoli richiedono configurazione", + "engineErrorHelp": "Motore Whisper non trovato. Scegli la cartella che contiene whisper-cli, ad esempio C:\\Tools\\whisper su Windows o /opt/homebrew/bin su macOS.", + "modelLabel": "Modello", + "showInFolder": "Mostra", + "downloadModelShort": "Scarica", + "changeModel": "Cambia", + "chooseModel": "Scegli", + "engineLabel": "Motore Whisper", + "changeEngineFolder": "Cartella", + "chooseEngineFolder": "Scegli cartella", + "chooseEngineFile": "File", + "audioExtractorLabel": "Estrattore audio", + "modelNeeded": "Modello richiesto", + "chooseEngine": "Scegli motore", + "downloadSmallModel": "Scarica modello small", + "runtimeSelected": "Runtime Whisper selezionato", + "openLocationFailed": "Impossibile aprire questa posizione", + "downloadModelFailed": "Download del modello small Whisper non riuscito", + "modelSelected": "Modello Whisper selezionato", + "deleteModelFailed": "Eliminazione del modello small Whisper non riuscita", + "modelDeleted": "Modello small Whisper eliminato", + "noSourceVideo": "Nessun video sorgente caricato", + "ffmpegStillChecking": "Recordly sta ancora controllando FFmpeg. Riprova a generare i sottotitoli tra poco.", + "selectModelFirst": "Seleziona un modello Whisper o scarica prima il modello small", + "generateFailed": "Generazione sottotitoli non riuscita", + "generatedToast": "Generati {{count}} sottotitoli e visualizzazione attivata", + "editSaved": "Sottotitolo aggiornato", + "editCurrent": "Modifica sottotitolo corrente" }, "crop": { "title": "Ritaglia video", diff --git a/src/i18n/locales/ko/settings.json b/src/i18n/locales/ko/settings.json index c83f2a0f9..fd6335cb2 100644 --- a/src/i18n/locales/ko/settings.json +++ b/src/i18n/locales/ko/settings.json @@ -125,6 +125,7 @@ "downloading": "다운로드 중...", "deleteModel": "모델 삭제", "clearModel": "모델 초기화", + "clearModelSelection": "선택 해제", "downloadModel": "모델 다운로드", "generating": "생성 중...", "generateFull": "자막 생성", @@ -146,7 +147,56 @@ "maxWidth": "최대 너비", "boxRadius": "박스 반경", "backgroundOpacity": "배경 투명도", - "textColor": "텍스트 색상" + "textColor": "텍스트 색상", + "noModelSelected": "선택한 모델 없음", + "noEngineSelected": "선택한 엔진 없음", + "modelHelp": "small 모델을 다운로드하거나 .bin 모델 파일을 선택하세요.", + "engineHelp": "whisper-cli가 들어 있는 폴더를 선택하세요. Windows에서는 C:\\Tools\\whisper, macOS에서는 /opt/homebrew/bin 등이 좋습니다.", + "ffmpegChecking": "FFmpeg 확인 중", + "ffmpegMissing": "FFmpeg를 찾을 수 없음", + "ffmpegCheckingHelp": "Recordly에 포함된 FFmpeg 또는 PATH의 ffmpeg 명령을 찾는 중입니다.", + "ffmpegHelp": "Whisper가 전사하기 전에 오디오를 추출하려면 FFmpeg가 필요합니다.", + "statusGenerating": "자막 생성 중", + "noAudioTitle": "전사할 오디오 없음", + "modelNeedsSetup": "자막 모델 선택", + "engineNeedsSetup": "Whisper 엔진 선택", + "ffmpegNeedsSetup": "FFmpeg를 사용할 수 없음", + "statusReadyWithCaptions": "자막 준비됨", + "statusReadyToGenerate": "생성 준비됨", + "statusHiddenWithCaptions": "자막 숨김", + "generatingStatus": "자막을 생성 중입니다. 잠시 걸릴 수 있습니다.", + "noAudioHelp": "이 비디오에는 Recordly가 전사할 수 있는 오디오 트랙이 없습니다. 오디오가 있는 비디오를 열거나 마이크 또는 시스템 오디오를 켜고 다시 녹화하세요.", + "guideModelDescription": "small 모델을 다운로드하거나 기존 .bin 파일을 선택하세요.", + "guideEngineDescription": "whisper-cli가 들어 있는 폴더를 선택하세요. Windows에서는 C:\\Tools\\whisper를 권장합니다.", + "guideReadyDescription": "생성된 자막이 미리보기에 표시됩니다. 내보내기 전에 재생해서 타이밍을 확인하세요.", + "guideHiddenDescription": "생성된 자막이 현재 숨겨져 있습니다. 표시를 켜면 미리보기와 내보내기에 표시됩니다.", + "guideGenerateDescription": "모델, 엔진, 오디오 추출이 준비되었습니다.", + "needsSetup": "자막 설정 필요", + "engineErrorHelp": "Whisper 엔진을 찾을 수 없습니다. Windows의 C:\\Tools\\whisper 또는 macOS의 /opt/homebrew/bin처럼 whisper-cli가 들어 있는 폴더를 선택하세요.", + "modelLabel": "모델", + "showInFolder": "표시", + "downloadModelShort": "다운로드", + "changeModel": "변경", + "chooseModel": "선택", + "engineLabel": "Whisper 엔진", + "changeEngineFolder": "폴더", + "chooseEngineFolder": "폴더 선택", + "chooseEngineFile": "파일", + "audioExtractorLabel": "오디오 추출기", + "modelNeeded": "모델 필요", + "chooseEngine": "엔진 선택", + "downloadSmallModel": "small 모델 다운로드", + "runtimeSelected": "Whisper 런타임 선택됨", + "openLocationFailed": "이 위치를 열 수 없습니다", + "downloadModelFailed": "Whisper small 모델 다운로드 실패", + "modelSelected": "Whisper 모델 선택됨", + "deleteModelFailed": "Whisper small 모델 삭제 실패", + "modelDeleted": "Whisper small 모델 삭제됨", + "noSourceVideo": "로드된 원본 비디오가 없습니다", + "ffmpegStillChecking": "Recordly가 아직 FFmpeg를 확인 중입니다. 잠시 후 자막 생성을 다시 시도하세요.", + "selectModelFirst": "Whisper 모델을 선택하거나 small 모델을 먼저 다운로드하세요", + "generateFailed": "자막 생성 실패", + "generatedToast": "자막 {{count}}개를 생성하고 표시를 켰습니다" }, "crop": { "title": "비디오 자르기", diff --git a/src/i18n/locales/nl/settings.json b/src/i18n/locales/nl/settings.json index d829a6cde..3f18f181b 100644 --- a/src/i18n/locales/nl/settings.json +++ b/src/i18n/locales/nl/settings.json @@ -125,13 +125,14 @@ "downloading": "Downloaden...", "deleteModel": "Model verwijderen", "clearModel": "Model wissen", + "clearModelSelection": "Selectie wissen", "downloadModel": "Model downloaden", "generating": "Genereren...", "generateFull": "Ondertiteling genereren", "regenerateFull": "Ondertiteling opnieuw genereren", "clearFull": "Ondertiteling wissen", "editCurrent": "Huidige ondertiteling bewerken", - "editSaved": "Ondertiteling bijgewerkt", + "editSaved": "Ondertitel bijgewerkt", "fontSettings": "Lettertype-instellingen", "defaultFont": "Standaard", "fontFamily": "Lettertype", @@ -146,7 +147,56 @@ "maxWidth": "Maximale breedte", "boxRadius": "Vakstraal", "backgroundOpacity": "Achtergrond-dekking", - "textColor": "Tekstkleur" + "textColor": "Tekstkleur", + "noModelSelected": "Geen model geselecteerd", + "noEngineSelected": "Geen engine geselecteerd", + "modelHelp": "Download het small-model of kies een .bin-modelbestand.", + "engineHelp": "Kies de map met whisper-cli, zoals C:\\Tools\\whisper op Windows of /opt/homebrew/bin op macOS.", + "ffmpegChecking": "FFmpeg controleren", + "ffmpegMissing": "FFmpeg niet gevonden", + "ffmpegCheckingHelp": "Zoeken naar Recordly's meegeleverde FFmpeg of een ffmpeg-opdracht op PATH.", + "ffmpegHelp": "Recordly heeft FFmpeg nodig om audio te extraheren voordat Whisper kan transcriberen.", + "statusGenerating": "Ondertitels genereren", + "noAudioTitle": "Geen audio om te transcriberen", + "modelNeedsSetup": "Kies een ondertitelmodel", + "engineNeedsSetup": "Kies Whisper-engine", + "ffmpegNeedsSetup": "FFmpeg is niet beschikbaar", + "statusReadyWithCaptions": "Ondertitels zijn klaar", + "statusReadyToGenerate": "Klaar om te genereren", + "statusHiddenWithCaptions": "Ondertitels zijn verborgen", + "generatingStatus": "Ondertitels worden gegenereerd. Dit kan even duren.", + "noAudioHelp": "Deze video bevat geen audiotrack die Recordly kan transcriberen. Open een video met audio of neem opnieuw op met microfoon of systeemaudio ingeschakeld.", + "guideModelDescription": "Download het small-model of kies een bestaand .bin-bestand.", + "guideEngineDescription": "Kies de map met whisper-cli. Op Windows is C:\\Tools\\whisper een goede plek.", + "guideReadyDescription": "De gegenereerde ondertitels zijn zichtbaar in de preview. Druk op Afspelen om de timing voor export te controleren.", + "guideHiddenDescription": "De gegenereerde ondertitels zijn momenteel verborgen. Zet Tonen aan om ze in de preview en export te tonen.", + "guideGenerateDescription": "Model, engine en audio-extractie zijn klaar.", + "needsSetup": "Ondertitels moeten worden ingesteld", + "engineErrorHelp": "Whisper-engine is niet gevonden. Kies de map met whisper-cli, zoals C:\\Tools\\whisper op Windows of /opt/homebrew/bin op macOS.", + "modelLabel": "Model", + "showInFolder": "Tonen", + "downloadModelShort": "Downloaden", + "changeModel": "Wijzigen", + "chooseModel": "Kiezen", + "engineLabel": "Whisper-engine", + "changeEngineFolder": "Map", + "chooseEngineFolder": "Kies map", + "chooseEngineFile": "Bestand", + "audioExtractorLabel": "Audio-extractor", + "modelNeeded": "Model nodig", + "chooseEngine": "Kies engine", + "downloadSmallModel": "Download small-model", + "runtimeSelected": "Whisper-runtime geselecteerd", + "openLocationFailed": "Deze locatie kan niet worden geopend", + "downloadModelFailed": "Whisper small-model downloaden mislukt", + "modelSelected": "Whisper-model geselecteerd", + "deleteModelFailed": "Whisper small-model verwijderen mislukt", + "modelDeleted": "Whisper small-model verwijderd", + "noSourceVideo": "Er is geen bronvideo geladen", + "ffmpegStillChecking": "Recordly controleert FFmpeg nog. Probeer zo meteen opnieuw ondertitels te genereren.", + "selectModelFirst": "Selecteer een Whisper-model of download eerst het small-model", + "generateFailed": "Ondertitels genereren mislukt", + "generatedToast": "{{count}} ondertitels gegenereerd en weergave ingeschakeld" }, "crop": { "title": "Video bijsnijden", diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json index d6d0a48ab..461f7aba7 100644 --- a/src/i18n/locales/pt-BR/settings.json +++ b/src/i18n/locales/pt-BR/settings.json @@ -125,6 +125,7 @@ "downloading": "Baixando...", "deleteModel": "Excluir modelo", "clearModel": "Limpar modelo", + "clearModelSelection": "Limpar seleção", "downloadModel": "Baixar modelo", "generating": "Gerando...", "generateFull": "Gerar legendas", @@ -146,7 +147,56 @@ "maxWidth": "Largura máxima", "boxRadius": "Raio da caixa", "backgroundOpacity": "Opacidade do fundo", - "textColor": "Cor do texto" + "textColor": "Cor do texto", + "noModelSelected": "Nenhum modelo selecionado", + "noEngineSelected": "Nenhum mecanismo selecionado", + "modelHelp": "Baixe o modelo small ou escolha um arquivo de modelo .bin.", + "engineHelp": "Escolha a pasta que contém o whisper-cli, como C:\\Tools\\whisper no Windows ou /opt/homebrew/bin no macOS.", + "ffmpegChecking": "Verificando FFmpeg", + "ffmpegMissing": "FFmpeg não encontrado", + "ffmpegCheckingHelp": "Procurando o FFmpeg incluído no Recordly ou um comando ffmpeg no PATH.", + "ffmpegHelp": "O Recordly precisa do FFmpeg para extrair o áudio antes que o Whisper possa transcrever.", + "statusGenerating": "Gerando legendas", + "noAudioTitle": "Nenhum áudio para transcrever", + "modelNeedsSetup": "Escolha um modelo de legendas", + "engineNeedsSetup": "Escolha o mecanismo Whisper", + "ffmpegNeedsSetup": "FFmpeg indisponível", + "statusReadyWithCaptions": "As legendas estão prontas", + "statusReadyToGenerate": "Pronto para gerar", + "statusHiddenWithCaptions": "As legendas estão ocultas", + "generatingStatus": "Gerando legendas. Isso pode levar um momento.", + "noAudioHelp": "Este vídeo não contém uma faixa de áudio que o Recordly consiga transcrever. Abra um vídeo com áudio ou grave novamente com microfone ou áudio do sistema ativado.", + "guideModelDescription": "Baixe o modelo small ou escolha um arquivo .bin existente.", + "guideEngineDescription": "Escolha a pasta que contém o whisper-cli. No Windows, C:\\Tools\\whisper é um bom local.", + "guideReadyDescription": "As legendas geradas aparecem na pré-visualização. Aperte Reproduzir para revisar o tempo antes de exportar.", + "guideHiddenDescription": "As legendas geradas estão ocultas. Ative Mostrar para exibi-las na pré-visualização e na exportação.", + "guideGenerateDescription": "Modelo, mecanismo e extração de áudio estão prontos.", + "needsSetup": "As legendas precisam de configuração", + "engineErrorHelp": "O mecanismo Whisper não foi encontrado. Escolha a pasta que contém o whisper-cli, como C:\\Tools\\whisper no Windows ou /opt/homebrew/bin no macOS.", + "modelLabel": "Modelo", + "showInFolder": "Mostrar", + "downloadModelShort": "Baixar", + "changeModel": "Alterar", + "chooseModel": "Escolher", + "engineLabel": "Mecanismo Whisper", + "changeEngineFolder": "Pasta", + "chooseEngineFolder": "Escolher pasta", + "chooseEngineFile": "Arquivo", + "audioExtractorLabel": "Extrator de áudio", + "modelNeeded": "Modelo necessário", + "chooseEngine": "Escolher mecanismo", + "downloadSmallModel": "Baixar modelo small", + "runtimeSelected": "Runtime do Whisper selecionado", + "openLocationFailed": "Não foi possível abrir este local", + "downloadModelFailed": "Falha ao baixar o modelo small do Whisper", + "modelSelected": "Modelo Whisper selecionado", + "deleteModelFailed": "Falha ao excluir o modelo small do Whisper", + "modelDeleted": "Modelo small do Whisper excluído", + "noSourceVideo": "Nenhum vídeo de origem carregado", + "ffmpegStillChecking": "O Recordly ainda está verificando o FFmpeg. Tente gerar legendas novamente em instantes.", + "selectModelFirst": "Selecione um modelo Whisper ou baixe primeiro o modelo small", + "generateFailed": "Falha ao gerar legendas", + "generatedToast": "{{count}} legendas geradas e exibição ativada" }, "crop": { "title": "Cortar vídeo", diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index b879899a2..295710a12 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -147,6 +147,7 @@ "downloading": "Загрузка...", "deleteModel": "Удалить модель", "clearModel": "Очистить модель", + "clearModelSelection": "Сбросить выбор", "downloadModel": "Загрузить модель", "generating": "Создание...", "generateFull": "Создать субтитры", @@ -166,7 +167,58 @@ "maxWidth": "Максимальная ширина", "boxRadius": "Скругление", "backgroundOpacity": "Прозрачность фона", - "textColor": "Цвет текста" + "textColor": "Цвет текста", + "noModelSelected": "Модель не выбрана", + "noEngineSelected": "Движок не выбран", + "modelHelp": "Скачайте модель small или выберите файл модели .bin.", + "engineHelp": "Выберите папку с whisper-cli, например C:\\Tools\\whisper в Windows или /opt/homebrew/bin в macOS.", + "ffmpegChecking": "Проверка FFmpeg", + "ffmpegMissing": "FFmpeg не найден", + "ffmpegCheckingHelp": "Поиск встроенного FFmpeg Recordly или команды ffmpeg в PATH.", + "ffmpegHelp": "Recordly нужен FFmpeg, чтобы извлечь аудио перед транскрипцией Whisper.", + "statusGenerating": "Создание субтитров", + "noAudioTitle": "Нет аудио для транскрипции", + "modelNeedsSetup": "Выберите модель субтитров", + "engineNeedsSetup": "Выберите движок Whisper", + "ffmpegNeedsSetup": "FFmpeg недоступен", + "statusReadyWithCaptions": "Субтитры готовы", + "statusReadyToGenerate": "Готово к созданию", + "statusHiddenWithCaptions": "Субтитры скрыты", + "generatingStatus": "Создание субтитров. Это может занять немного времени.", + "noAudioHelp": "В этом видео нет аудиодорожки, которую Recordly может транскрибировать. Откройте видео со звуком или запишите снова с включенным микрофоном или системным аудио.", + "guideModelDescription": "Скачайте модель small или выберите существующий файл .bin.", + "guideEngineDescription": "Выберите папку с whisper-cli. В Windows хорошее место — C:\\Tools\\whisper.", + "guideReadyDescription": "Созданные субтитры видны в предпросмотре. Нажмите воспроизведение, чтобы проверить тайминг перед экспортом.", + "guideHiddenDescription": "Созданные субтитры сейчас скрыты. Включите Показать, чтобы видеть их в предпросмотре и экспорте.", + "guideGenerateDescription": "Модель, движок и извлечение аудио готовы.", + "needsSetup": "Нужно настроить субтитры", + "engineErrorHelp": "Движок Whisper не найден. Выберите папку с whisper-cli, например C:\\Tools\\whisper в Windows или /opt/homebrew/bin в macOS.", + "modelLabel": "Модель", + "showInFolder": "Показать", + "downloadModelShort": "Скачать", + "changeModel": "Изменить", + "chooseModel": "Выбрать", + "engineLabel": "Движок Whisper", + "changeEngineFolder": "Папка", + "chooseEngineFolder": "Выбрать папку", + "chooseEngineFile": "Файл", + "audioExtractorLabel": "Извлечение аудио", + "modelNeeded": "Нужна модель", + "chooseEngine": "Выбрать движок", + "downloadSmallModel": "Скачать модель small", + "runtimeSelected": "Runtime Whisper выбран", + "openLocationFailed": "Не удалось открыть это расположение", + "downloadModelFailed": "Не удалось скачать модель small Whisper", + "modelSelected": "Модель Whisper выбрана", + "deleteModelFailed": "Не удалось удалить модель small Whisper", + "modelDeleted": "Модель small Whisper удалена", + "noSourceVideo": "Исходное видео не загружено", + "ffmpegStillChecking": "Recordly еще проверяет FFmpeg. Попробуйте создать субтитры еще раз через несколько секунд.", + "selectModelFirst": "Выберите модель Whisper или сначала скачайте модель small", + "generateFailed": "Не удалось создать субтитры", + "generatedToast": "Создано субтитров: {{count}}. Отображение включено", + "editSaved": "Субтитр обновлен", + "editCurrent": "Редактировать текущий субтитр" }, "crop": { "title": "Обрезка видео", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 1b34c58b6..7b447a910 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -142,6 +142,7 @@ "downloading": "下载中...", "deleteModel": "删除模型", "clearModel": "清除模型", + "clearModelSelection": "清除选择", "downloadModel": "下载模型", "generating": "生成中...", "generateFull": "生成字幕", @@ -161,7 +162,58 @@ "maxWidth": "最大宽度", "boxRadius": "字幕框圆角", "backgroundOpacity": "背景透明度", - "textColor": "文字颜色" + "textColor": "文字颜色", + "noModelSelected": "未选择模型", + "noEngineSelected": "未选择引擎", + "modelHelp": "下载 small 模型,或选择已有的 .bin 模型文件。", + "engineHelp": "选择包含 whisper-cli 的文件夹,例如 Windows 上的 C:\\Tools\\whisper,或 macOS 上的 /opt/homebrew/bin。", + "ffmpegChecking": "正在检查 FFmpeg", + "ffmpegMissing": "未找到 FFmpeg", + "ffmpegCheckingHelp": "正在查找 Recordly 自带的 FFmpeg,或 PATH 中的 ffmpeg 命令。", + "ffmpegHelp": "Recordly 需要先用 FFmpeg 提取音频,然后才能交给 Whisper 转写。", + "statusGenerating": "正在生成字幕", + "noAudioTitle": "没有可转写的音频", + "modelNeedsSetup": "选择字幕模型", + "engineNeedsSetup": "选择 Whisper 引擎", + "ffmpegNeedsSetup": "FFmpeg 不可用", + "statusReadyWithCaptions": "字幕已就绪", + "statusReadyToGenerate": "可以生成字幕", + "statusHiddenWithCaptions": "字幕已隐藏", + "generatingStatus": "正在生成字幕,可能需要一点时间。", + "noAudioHelp": "这个视频没有 Recordly 可转写的音轨。请打开包含音频的视频,或重新录制并启用麦克风或系统音频。", + "guideModelDescription": "下载 small 模型,或选择已有的 .bin 模型文件。", + "guideEngineDescription": "选择包含 whisper-cli 的文件夹。Windows 上建议放在 C:\\Tools\\whisper。", + "guideReadyDescription": "生成的字幕会显示在预览画面中。导出前请点击播放检查时间轴。", + "guideHiddenDescription": "生成的字幕目前被隐藏。打开“显示”后,它们会出现在预览和导出视频中。", + "guideGenerateDescription": "模型、引擎和音频提取都已准备好。", + "needsSetup": "字幕需要配置", + "engineErrorHelp": "未找到 Whisper 引擎。请选择包含 whisper-cli 的文件夹,例如 Windows 上的 C:\\Tools\\whisper,或 macOS 上的 /opt/homebrew/bin。", + "modelLabel": "模型", + "showInFolder": "显示", + "downloadModelShort": "下载", + "changeModel": "更改", + "chooseModel": "选择", + "engineLabel": "Whisper 引擎", + "changeEngineFolder": "文件夹", + "chooseEngineFolder": "选择文件夹", + "chooseEngineFile": "文件", + "audioExtractorLabel": "音频提取器", + "modelNeeded": "需要模型", + "chooseEngine": "选择引擎", + "downloadSmallModel": "下载 small 模型", + "runtimeSelected": "已选择 Whisper 运行时", + "openLocationFailed": "无法打开此位置", + "downloadModelFailed": "下载 Whisper small 模型失败", + "modelSelected": "已选择 Whisper 模型", + "deleteModelFailed": "删除 Whisper small 模型失败", + "modelDeleted": "已删除 Whisper small 模型", + "noSourceVideo": "没有加载源视频", + "ffmpegStillChecking": "Recordly 仍在检查 FFmpeg。请稍后再生成字幕。", + "selectModelFirst": "请先选择 Whisper 模型,或下载 small 模型", + "generateFailed": "生成字幕失败", + "generatedToast": "已生成 {{count}} 条字幕,并已打开字幕显示", + "editSaved": "字幕已更新", + "editCurrent": "编辑当前字幕" }, "crop": { "title": "裁剪视频", diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 9adea246d..1856187f9 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -125,6 +125,7 @@ "downloading": "下載中...", "deleteModel": "刪除模型", "clearModel": "清除模型", + "clearModelSelection": "清除選擇", "downloadModel": "下載模型", "generating": "產生中...", "generateFull": "產生字幕", @@ -146,7 +147,56 @@ "maxWidth": "最大寬度", "boxRadius": "方框圓角", "backgroundOpacity": "背景不透明度", - "textColor": "文字顏色" + "textColor": "文字顏色", + "noModelSelected": "未選擇模型", + "noEngineSelected": "未選擇引擎", + "modelHelp": "下載 small 模型,或選擇既有的 .bin 模型檔。", + "engineHelp": "選擇包含 whisper-cli 的資料夾,例如 Windows 上的 C:\\Tools\\whisper,或 macOS 上的 /opt/homebrew/bin。", + "ffmpegChecking": "正在檢查 FFmpeg", + "ffmpegMissing": "找不到 FFmpeg", + "ffmpegCheckingHelp": "正在尋找 Recordly 內建的 FFmpeg,或 PATH 中的 ffmpeg 指令。", + "ffmpegHelp": "Recordly 需要先用 FFmpeg 擷取音訊,才能交給 Whisper 轉寫。", + "statusGenerating": "正在產生字幕", + "noAudioTitle": "沒有可轉寫的音訊", + "modelNeedsSetup": "選擇字幕模型", + "engineNeedsSetup": "選擇 Whisper 引擎", + "ffmpegNeedsSetup": "FFmpeg 無法使用", + "statusReadyWithCaptions": "字幕已就緒", + "statusReadyToGenerate": "可以產生字幕", + "statusHiddenWithCaptions": "字幕已隱藏", + "generatingStatus": "正在產生字幕,可能需要一點時間。", + "noAudioHelp": "這個影片沒有 Recordly 可轉寫的音軌。請開啟包含音訊的影片,或重新錄製並啟用麥克風或系統音訊。", + "guideModelDescription": "下載 small 模型,或選擇既有的 .bin 模型檔。", + "guideEngineDescription": "選擇包含 whisper-cli 的資料夾。Windows 上建議放在 C:\\Tools\\whisper。", + "guideReadyDescription": "產生的字幕會顯示在預覽畫面中。匯出前請按下播放檢查時間軸。", + "guideHiddenDescription": "產生的字幕目前被隱藏。開啟「顯示」後,它們會出現在預覽與匯出影片中。", + "guideGenerateDescription": "模型、引擎和音訊擷取都已準備好。", + "needsSetup": "字幕需要設定", + "engineErrorHelp": "找不到 Whisper 引擎。請選擇包含 whisper-cli 的資料夾,例如 Windows 上的 C:\\Tools\\whisper,或 macOS 上的 /opt/homebrew/bin。", + "modelLabel": "模型", + "showInFolder": "顯示", + "downloadModelShort": "下載", + "changeModel": "更改", + "chooseModel": "選擇", + "engineLabel": "Whisper 引擎", + "changeEngineFolder": "資料夾", + "chooseEngineFolder": "選擇資料夾", + "chooseEngineFile": "檔案", + "audioExtractorLabel": "音訊擷取器", + "modelNeeded": "需要模型", + "chooseEngine": "選擇引擎", + "downloadSmallModel": "下載 small 模型", + "runtimeSelected": "已選擇 Whisper 執行環境", + "openLocationFailed": "無法開啟此位置", + "downloadModelFailed": "下載 Whisper small 模型失敗", + "modelSelected": "已選擇 Whisper 模型", + "deleteModelFailed": "刪除 Whisper small 模型失敗", + "modelDeleted": "已刪除 Whisper small 模型", + "noSourceVideo": "沒有載入來源影片", + "ffmpegStillChecking": "Recordly 仍在檢查 FFmpeg。請稍後再產生字幕。", + "selectModelFirst": "請先選擇 Whisper 模型,或下載 small 模型", + "generateFailed": "產生字幕失敗", + "generatedToast": "已產生 {{count}} 條字幕,並已開啟字幕顯示" }, "crop": { "title": "裁切影片",