diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 73029f6bc1..88fc60074f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -29,14 +29,6 @@ jobs: - name: Install dependencies run: npm ci - # Cache the downloaded caption model so we don't re-fetch from HuggingFace every run - # (and to avoid 429s when the platform matrix builds hit it at once). - - name: Cache caption assets - uses: actions/cache@v4 - with: - path: caption-assets - key: caption-assets-${{ hashFiles('scripts/fetch-caption-model.mjs') }} - - name: Build Windows app run: npm run build:win env: @@ -86,14 +78,6 @@ jobs: env: npm_config_build_from_source: "false" - # ─── Cache caption assets ───────────────────────────────── - # Avoid re-fetching the Whisper model from HuggingFace every run (and 429s under the matrix). - - name: Cache caption assets - uses: actions/cache@v4 - with: - path: caption-assets - key: caption-assets-${{ hashFiles('scripts/fetch-caption-model.mjs') }} - # ─── Import Code Signing Certificate ────────────────────── # This is the KEY step that makes CI signing work. # We create a temporary keychain, import the .p12 cert into it, @@ -272,14 +256,6 @@ jobs: - name: Install pacman build dependencies run: sudo apt-get update && sudo apt-get install -y libarchive-tools - # Cache the downloaded caption model so we don't re-fetch from HuggingFace every run - # (and to avoid 429s when the platform matrix builds hit it at once). - - name: Cache caption assets - uses: actions/cache@v4 - with: - path: caption-assets - key: caption-assets-${{ hashFiles('scripts/fetch-caption-model.mjs') }} - - name: Build Linux app run: npm run build:linux env: diff --git a/.gitignore b/.gitignore index 61dc9d2fcf..f0a75afef4 100644 --- a/.gitignore +++ b/.gitignore @@ -8,9 +8,13 @@ pnpm-debug.log* lerna-debug.log* node_modules +.worktrees/ dist dist-electron dist-ssr +/main.js +/preload.mjs +/index-*.js *.local .env @@ -20,6 +24,7 @@ dist-ssr /electron/native/screencapturekit/.build/ /electron/native/screencapturekit/.swiftpm/ /electron/native/bin/ +/electron/native/captions/*/ # Native macOS generated files DerivedData/ @@ -63,6 +68,3 @@ result-* #others **/*.import - -# Auto-caption model + ORT wasm — regenerated at build by scripts/fetch-caption-model.mjs -/caption-assets/ diff --git a/README.md b/README.md index 867f21634f..26e4928222 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ If you don't want to pay $29/month for Screen Studio but want a version that doe Screen Studio is an awesome product and this is definitely not a 1:1 clone. If you just want something fully free and open source, this project should cover most of your needs. -**100% free** for both **personal** and **commercial** use. Use it, modify it, distribute it. Please respect the License. +**100% free** for both **personal** and **commercial** use. Use it, modify it, distribute it. Please respect the License. > [!NOTE] >Software should be accessible. OpenScreen has no paid tiers, premium features, upsells, or functionality locked behind a paywall. @@ -40,7 +40,11 @@ Screen Studio is an awesome product and this is definitely not a 1:1 clone. If y - Wallpapers, solid colors, gradients, or your own background image. - Motion blur. - Crop, trim, and per-segment speed control on the timeline. +- Blur effects to hide sensitive parts of the screen. +- Cursor and click highlighting. - Text, arrow, and image annotations, with text animation presets. +- Offline auto-captions on macOS release builds, generated locally with bundled whisper.cpp helpers and the default `ggml-small` model. +- Save and reopen projects without re-recording. - Timeline snapping guides and an audio waveform to make trimming easier. - Customizable keyboard shortcuts. - Export to MP4 or GIF in multiple aspect ratios and resolutions. @@ -161,6 +165,8 @@ Everything in the editor and export is the same on macOS, Windows, and Linux: zo - **Windows**: works out of the box. - **Linux**: needs PipeWire (default on Ubuntu 22.04+, Fedora 34+). Older PulseAudio-only setups may not capture system audio (mic should still work). +Offline auto-captions currently depend on packaged caption runtime assets. macOS release builds include the local whisper.cpp CLI, ffmpeg, and `ggml-small` model; other platforms report captions as unavailable until their helper assets are packaged. + --- ## License diff --git a/electron-builder.json5 b/electron-builder.json5 index 16a7f2c605..17d2d2c2f1 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -6,15 +6,13 @@ // .node binaries can't be dlopen'd from inside an asar — must live unpacked. "asarUnpack": [ "**/*.node" - ], - "productName": "Openscreen", - // Fetch the auto-caption model + ORT wasm into caption-assets/ before packaging (idempotent). - "beforePack": "scripts/before-pack.cjs", - "npmRebuild": true, - // sharp ships ABI-stable (napi) prebuilt binaries with bundled libvips. Building it from source - // needs a system libvips we don't provide and breaks on CI/local ("vips-cpp.42 not found"), so we - // let electron-builder use the prebuilt instead of recompiling. - "buildDependenciesFromSource": false, + ], + "productName": "Openscreen", + "npmRebuild": true, + // sharp ships ABI-stable (napi) prebuilt binaries with bundled libvips. Building it from source + // needs a system libvips we don't provide and breaks on CI/local ("vips-cpp.42 not found"), so we + // let electron-builder use the prebuilt instead of recompiling. + "buildDependenciesFromSource": false, "compression": "normal", "directories": { "output": "release/${version}" @@ -29,43 +27,44 @@ "!CONTRIBUTING.md", "!LICENSE" ], - // Asset layout contract: "wallpapers/" and "cursors/" under resourcesPath must - // align with assetBaseDir in electron/preload.ts (packaged branch). + // Asset layout contract: "wallpapers/" and "cursors/" under resourcesPath must + // align with assetBaseDir in electron/preload.ts (packaged branch). "extraResources": [ { "from": "public/wallpapers", "to": "wallpapers" - }, - { - "from": "public/cursors", - "to": "cursors" - }, - { - "from": "caption-assets", - "to": "caption-assets" - } - ], + }, + { + "from": "public/cursors", + "to": "cursors" + } + ], "mac": { "notarize": false, "hardenedRuntime": true, "entitlements": "macos.entitlements", "entitlementsInherit": "macos.entitlements", - "target": [ - { - "target": "dmg", - "arch": ["x64", "arm64"] - } - ], - "icon": "icons/icons/mac/icon.icns", - "artifactName": "${productName}-Mac-${arch}-${version}-Installer.${ext}", - "extraResources": [ - { - "from": "electron/native/bin", - "to": "electron/native/bin", - "filter": ["darwin-*/*"] - } - ], + "target": [ + { + "target": "dmg", + "arch": ["x64", "arm64"] + } + ], + "icon": "icons/icons/mac/icon.icns", + "artifactName": "${productName}-Mac-${arch}-${version}-Installer.${ext}", + "extraResources": [ + { + "from": "electron/native/bin", + "to": "electron/native/bin", + "filter": ["darwin-*/*"] + }, + { + "from": "electron/native/captions", + "to": "electron/native/captions", + "filter": ["darwin-*/*"] + } + ], "extendInfo": { "NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.", "NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.", diff --git a/electron/captions/jobs.test.ts b/electron/captions/jobs.test.ts new file mode 100644 index 0000000000..540a74d0cf --- /dev/null +++ b/electron/captions/jobs.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from "vitest"; +import { CaptionJobRegistry } from "./jobs"; + +describe("CaptionJobRegistry", () => { + it("cleans up jobs after completion", async () => { + const registry = new CaptionJobRegistry({ + generate: vi.fn(async (request) => ({ + jobId: request.jobId, + status: "success", + segments: [], + })), + }); + + await expect(registry.start({ jobId: "job-1", videoPath: "/tmp/video.webm" })).resolves.toEqual( + { + jobId: "job-1", + status: "success", + segments: [], + }, + ); + expect(registry.has("job-1")).toBe(false); + }); + + it("aborts an active job", async () => { + let signal: AbortSignal | undefined; + const registry = new CaptionJobRegistry({ + generate: vi.fn(async (request) => { + signal = request.signal; + return { jobId: request.jobId, status: "cancelled", segments: [] }; + }), + }); + + const promise = registry.start({ + jobId: "job-2", + videoPath: "/tmp/video.webm", + }); + const cancelResult = registry.cancel("job-2"); + const result = await promise; + + expect(cancelResult).toEqual({ success: true, cancelled: true }); + expect(signal?.aborted).toBe(true); + expect(result.status).toBe("cancelled"); + }); + + it("reports missing jobs as not cancelled", () => { + const registry = new CaptionJobRegistry({ + generate: vi.fn(), + }); + + expect(registry.cancel("missing")).toEqual({ + success: true, + cancelled: false, + }); + }); +}); diff --git a/electron/captions/jobs.ts b/electron/captions/jobs.ts new file mode 100644 index 0000000000..c7adbb036d --- /dev/null +++ b/electron/captions/jobs.ts @@ -0,0 +1,53 @@ +import type { CaptionGenerationResult } from "../../src/lib/captions"; + +type CaptionJobStartRequest = { + jobId: string; + videoPath: string; + language?: string; +}; + +type CaptionJobGenerateRequest = CaptionJobStartRequest & { + signal: AbortSignal; +}; + +type CaptionJobRegistryDeps = { + generate: (request: CaptionJobGenerateRequest) => Promise; +}; + +export class CaptionJobRegistry { + private jobs = new Map(); + + constructor(private deps: CaptionJobRegistryDeps) {} + + has(jobId: string) { + return this.jobs.has(jobId); + } + + async start(request: CaptionJobStartRequest): Promise { + this.cancel(request.jobId); + const controller = new AbortController(); + this.jobs.set(request.jobId, controller); + + try { + return await this.deps.generate({ + ...request, + signal: controller.signal, + }); + } finally { + if (this.jobs.get(request.jobId) === controller) { + this.jobs.delete(request.jobId); + } + } + } + + cancel(jobId: string) { + const controller = this.jobs.get(jobId); + if (!controller) { + return { success: true, cancelled: false }; + } + + controller.abort(); + this.jobs.delete(jobId); + return { success: true, cancelled: true }; + } +} diff --git a/electron/captions/whisper.test.ts b/electron/captions/whisper.test.ts new file mode 100644 index 0000000000..27d4b4c5c9 --- /dev/null +++ b/electron/captions/whisper.test.ts @@ -0,0 +1,223 @@ +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { generateCaptionsWithWhisper } from "./whisper"; + +const baseRequest = { + jobId: "job-1", + videoPath: path.join("/recordings", "recording.webm"), + platform: "darwin", + arch: "arm64", + resourcesPath: "/resources", + tempDir: "/tmp", + env: {}, +}; + +describe("generateCaptionsWithWhisper", () => { + it("reports unavailable when helper assets are missing", async () => { + const result = await generateCaptionsWithWhisper(baseRequest, { + exists: vi.fn(async () => false), + readFile: vi.fn(), + rm: vi.fn(), + mkdir: vi.fn(), + runProcess: vi.fn(), + }); + + expect(result).toEqual({ + jobId: "job-1", + status: "unavailable", + segments: [], + message: "Local caption tools are not available.", + }); + }); + + it("uses process env helper overrides when request env is omitted", async () => { + const previousWhisperBin = process.env.OPENSCREEN_WHISPER_CPP_BIN; + const previousModelPath = process.env.OPENSCREEN_WHISPER_MODEL_PATH; + const previousFfmpegPath = process.env.OPENSCREEN_FFMPEG_PATH; + process.env.OPENSCREEN_WHISPER_CPP_BIN = "/env/whisper-cli"; + process.env.OPENSCREEN_WHISPER_MODEL_PATH = "/env/ggml-base.bin"; + process.env.OPENSCREEN_FFMPEG_PATH = "/env/ffmpeg"; + + const request = { + jobId: "job-1", + videoPath: path.join("/recordings", "recording.webm"), + platform: "darwin", + arch: "arm64", + resourcesPath: "/resources", + tempDir: "/tmp", + }; + const runProcess = vi + .fn() + .mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" }) + .mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" }); + + try { + const result = await generateCaptionsWithWhisper(request, { + exists: vi.fn(async (filePath) => filePath.startsWith("/env/")), + readFile: vi.fn(async () => + JSON.stringify({ + transcription: [{ offsets: { from: 0, to: 1000 }, text: "hello" }], + }), + ), + rm: vi.fn(), + mkdir: vi.fn(), + runProcess, + }); + + expect(result.status).toBe("success"); + expect(runProcess.mock.calls[0][0]).toBe("/env/ffmpeg"); + expect(runProcess.mock.calls[1][0]).toBe("/env/whisper-cli"); + expect(runProcess.mock.calls[1][1]).toContain("/env/ggml-base.bin"); + } finally { + if (previousWhisperBin === undefined) { + delete process.env.OPENSCREEN_WHISPER_CPP_BIN; + } else { + process.env.OPENSCREEN_WHISPER_CPP_BIN = previousWhisperBin; + } + if (previousModelPath === undefined) { + delete process.env.OPENSCREEN_WHISPER_MODEL_PATH; + } else { + process.env.OPENSCREEN_WHISPER_MODEL_PATH = previousModelPath; + } + if (previousFfmpegPath === undefined) { + delete process.env.OPENSCREEN_FFMPEG_PATH; + } else { + process.env.OPENSCREEN_FFMPEG_PATH = previousFfmpegPath; + } + } + }); + + it("prefers the bundled small model over the base fallback", async () => { + const runProcess = vi + .fn() + .mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" }) + .mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" }); + const readFile = vi.fn(async () => + JSON.stringify({ + transcription: [{ offsets: { from: 0, to: 1000 }, text: "hello" }], + }), + ); + + await generateCaptionsWithWhisper(baseRequest, { + exists: vi.fn(async () => true), + readFile, + rm: vi.fn(), + mkdir: vi.fn(), + runProcess, + }); + + expect(runProcess.mock.calls[1][1]).toContain( + path.join("/resources", "electron", "native", "captions", "darwin-arm64", "ggml-small.bin"), + ); + }); + + it("skips videos without an audio stream", async () => { + const runProcess = vi.fn().mockResolvedValueOnce({ + code: 1, + stdout: "", + stderr: "Stream map '0:a:0' matches no streams.", + }); + + const result = await generateCaptionsWithWhisper(baseRequest, { + exists: vi.fn(async () => true), + readFile: vi.fn(), + rm: vi.fn(), + mkdir: vi.fn(), + runProcess, + }); + + expect(result.status).toBe("skipped"); + expect(result.message).toBe("No audio track found; captions skipped."); + }); + + it("sanitizes job ids before building cleanup paths", async () => { + const runProcess = vi + .fn() + .mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" }) + .mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" }); + const rm = vi.fn(); + const request = { + ...baseRequest, + jobId: "foo/../../outside", + }; + + const result = await generateCaptionsWithWhisper(request, { + exists: vi.fn(async () => true), + readFile: vi.fn(async () => + JSON.stringify({ + transcription: [{ offsets: { from: 0, to: 1000 }, text: "hello" }], + }), + ), + rm, + mkdir: vi.fn(), + runProcess, + }); + + const cleanupPath = rm.mock.calls[0][0]; + const relativeCleanupPath = path.relative("/tmp", cleanupPath); + expect(result.jobId).toBe("foo/../../outside"); + expect(cleanupPath).toBe(path.join("/tmp", "openscreen-captions-foo-------outside")); + expect(relativeCleanupPath.startsWith("..")).toBe(false); + expect(relativeCleanupPath.split(path.sep)).not.toContain(".."); + }); + + it("extracts audio, runs whisper, and parses generated JSON", async () => { + const runProcess = vi + .fn() + .mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" }) + .mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" }); + const readFile = vi.fn(async () => + JSON.stringify({ + transcription: [{ offsets: { from: 0, to: 1000 }, text: "hello" }], + }), + ); + + const result = await generateCaptionsWithWhisper(baseRequest, { + exists: vi.fn(async () => true), + readFile, + rm: vi.fn(), + mkdir: vi.fn(), + runProcess, + }); + + expect(result).toEqual({ + jobId: "job-1", + status: "success", + segments: [{ id: "caption-1", startMs: 0, endMs: 1000, text: "hello" }], + message: "Generated 1 captions.", + }); + expect(runProcess).toHaveBeenCalledTimes(2); + expect(runProcess.mock.calls[0][1]).toContain("-map"); + expect(runProcess.mock.calls[1][1]).toContain("-oj"); + }); + + it("retries whisper on CPU when macOS GPU initialization fails", async () => { + const runProcess = vi + .fn() + .mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" }) + .mockResolvedValueOnce({ + code: -1, + stdout: "", + stderr: "ggml_metal_buffer_init: error: failed to allocate buffer", + }) + .mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" }); + const readFile = vi.fn(async () => + JSON.stringify({ + transcription: [{ offsets: { from: 0, to: 1000 }, text: "hello" }], + }), + ); + + const result = await generateCaptionsWithWhisper(baseRequest, { + exists: vi.fn(async () => true), + readFile, + rm: vi.fn(), + mkdir: vi.fn(), + runProcess, + }); + + expect(result.status).toBe("success"); + expect(runProcess).toHaveBeenCalledTimes(3); + expect(runProcess.mock.calls[1][1]).not.toContain("--no-gpu"); + expect(runProcess.mock.calls[2][1]).toContain("--no-gpu"); + }); +}); diff --git a/electron/captions/whisper.ts b/electron/captions/whisper.ts new file mode 100644 index 0000000000..b22d39144a --- /dev/null +++ b/electron/captions/whisper.ts @@ -0,0 +1,283 @@ +import { spawn } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { CaptionGenerationResult } from "../../src/lib/captions"; +import { parseWhisperJsonOutput } from "../../src/lib/captions"; + +type RunProcessResult = { + code: number | null; + stdout: string; + stderr: string; +}; + +type RunProcess = ( + command: string, + args: string[], + options?: { signal?: AbortSignal }, +) => Promise; + +export type GenerateCaptionsWithWhisperRequest = { + jobId: string; + videoPath: string; + platform: NodeJS.Platform | string; + arch: string; + resourcesPath: string; + tempDir?: string; + env?: NodeJS.ProcessEnv | Record; + language?: string; + signal?: AbortSignal; +}; + +export type WhisperCaptionDeps = { + exists?: (filePath: string) => Promise; + mkdir?: typeof fs.mkdir; + readFile?: typeof fs.readFile; + rm?: typeof fs.rm; + runProcess?: RunProcess; +}; + +async function exists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +function runProcess(command: string, args: string[], options: { signal?: AbortSignal } = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: ["ignore", "pipe", "pipe"], + signal: options.signal, + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.on("error", (error) => { + if (options.signal?.aborted) { + resolve({ code: null, stdout, stderr: String(error) }); + return; + } + reject(error); + }); + child.on("close", (code) => resolve({ code, stdout, stderr })); + }); +} + +function candidateResourcePaths( + request: GenerateCaptionsWithWhisperRequest, + fileName: string | string[], + envName: string, +) { + const envPath = (request.env ?? process.env)[envName]?.trim(); + const platformArch = `${request.platform}-${request.arch}`; + const fileNames = Array.isArray(fileName) ? fileName : [fileName]; + return [ + envPath || null, + ...fileNames.flatMap((name) => [ + path.join(request.resourcesPath, "electron", "native", "captions", platformArch, name), + path.join(request.resourcesPath, "captions", platformArch, name), + ]), + ].filter((value): value is string => Boolean(value)); +} + +async function firstExistingPath( + paths: string[], + deps: Required>, +) { + for (const candidate of paths) { + if (await deps.exists(candidate)) { + return candidate; + } + } + return null; +} + +async function resolveCaptionTools( + request: GenerateCaptionsWithWhisperRequest, + deps: Required>, +) { + const whisperBinaryName = request.platform === "win32" ? "whisper-cli.exe" : "whisper-cli"; + const ffmpegBinaryName = request.platform === "win32" ? "ffmpeg.exe" : "ffmpeg"; + const whisperPath = await firstExistingPath( + candidateResourcePaths(request, whisperBinaryName, "OPENSCREEN_WHISPER_CPP_BIN"), + deps, + ); + const modelPath = await firstExistingPath( + candidateResourcePaths( + request, + ["ggml-small.bin", "ggml-base.bin"], + "OPENSCREEN_WHISPER_MODEL_PATH", + ), + deps, + ); + const ffmpegPath = await firstExistingPath( + candidateResourcePaths(request, ffmpegBinaryName, "OPENSCREEN_FFMPEG_PATH"), + deps, + ); + + return { whisperPath, modelPath, ffmpegPath }; +} + +function isNoAudioError(stderr: string) { + return ( + stderr.includes("matches no streams") || + stderr.includes("Stream map") || + stderr.toLowerCase().includes("audio:0") + ); +} + +function isMacGpuInitializationError(request: GenerateCaptionsWithWhisperRequest, output: string) { + if (request.platform !== "darwin") { + return false; + } + + const normalizedOutput = output.toLowerCase(); + return ( + normalizedOutput.includes("ggml_metal") || + normalizedOutput.includes("metal") || + normalizedOutput.includes("no gpu found") + ); +} + +export async function generateCaptionsWithWhisper( + request: GenerateCaptionsWithWhisperRequest, + deps: WhisperCaptionDeps = {}, +): Promise { + const resolvedDeps = { + exists: deps.exists ?? exists, + mkdir: deps.mkdir ?? fs.mkdir, + readFile: deps.readFile ?? fs.readFile, + rm: deps.rm ?? fs.rm, + runProcess: deps.runProcess ?? runProcess, + }; + + const tools = await resolveCaptionTools(request, resolvedDeps); + if (!tools.whisperPath || !tools.modelPath || !tools.ffmpegPath) { + return { + jobId: request.jobId, + status: "unavailable", + segments: [], + message: "Local caption tools are not available.", + }; + } + + const tempRoot = request.tempDir ?? os.tmpdir(); + const safeJobId = request.jobId.replace(/[^a-zA-Z0-9_-]/g, "-") || "job"; + const jobDir = path.join(tempRoot, `openscreen-captions-${safeJobId}`); + const wavPath = path.join(jobDir, "audio.wav"); + const outputBase = path.join(jobDir, "transcript"); + const outputJsonPath = `${outputBase}.json`; + + try { + await resolvedDeps.mkdir(jobDir, { recursive: true }); + const extract = await resolvedDeps.runProcess( + tools.ffmpegPath, + [ + "-y", + "-i", + request.videoPath, + "-map", + "0:a:0", + "-vn", + "-ac", + "1", + "-ar", + "16000", + "-f", + "wav", + wavPath, + ], + { signal: request.signal }, + ); + + if (request.signal?.aborted) { + return { jobId: request.jobId, status: "cancelled", segments: [] }; + } + + if (extract.code !== 0) { + if (isNoAudioError(extract.stderr)) { + return { + jobId: request.jobId, + status: "skipped", + segments: [], + message: "No audio track found; captions skipped.", + }; + } + return { + jobId: request.jobId, + status: "error", + segments: [], + message: "Failed to extract audio for captions.", + error: extract.stderr || extract.stdout, + }; + } + + const whisperArgs = [ + "-m", + tools.modelPath, + "-f", + wavPath, + "-oj", + "-of", + outputBase, + "--no-prints", + ]; + if (request.language) { + whisperArgs.push("-l", request.language); + } + + let whisper = await resolvedDeps.runProcess(tools.whisperPath, whisperArgs, { + signal: request.signal, + }); + + if (request.signal?.aborted) { + return { jobId: request.jobId, status: "cancelled", segments: [] }; + } + + if ( + whisper.code !== 0 && + isMacGpuInitializationError(request, `${whisper.stderr}\n${whisper.stdout}`) + ) { + whisper = await resolvedDeps.runProcess(tools.whisperPath, [...whisperArgs, "--no-gpu"], { + signal: request.signal, + }); + + if (request.signal?.aborted) { + return { jobId: request.jobId, status: "cancelled", segments: [] }; + } + } + + if (whisper.code !== 0) { + return { + jobId: request.jobId, + status: "error", + segments: [], + message: "Local caption transcription failed.", + error: whisper.stderr || whisper.stdout, + }; + } + + const jsonOutput = await resolvedDeps.readFile(outputJsonPath, "utf-8"); + const segments = parseWhisperJsonOutput(String(jsonOutput)); + return { + jobId: request.jobId, + status: "success", + segments, + message: `Generated ${segments.length} captions.`, + }; + } finally { + try { + await resolvedDeps.rm(jobDir, { recursive: true, force: true }); + } catch { + // Best-effort cleanup should not mask the caption result. + } + } +} diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 18d44f1ae1..f9dc0aa77c 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -171,6 +171,7 @@ interface Window { error?: string; }>; onStopRecordingFromTray: (callback: () => void) => () => void; + onNativeRecordingStopped: (callback: () => void) => () => void; openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>; pickExportSavePath: ( fileName: string, @@ -217,6 +218,11 @@ interface Window { message?: string; error?: string; }>; + startCaptionGeneration: ( + videoPath: string, + options?: import("../src/lib/captions").CaptionGenerationOptions, + ) => Promise; + cancelCaptionGeneration: (jobId: string) => Promise<{ success: boolean; cancelled: boolean }>; clearCurrentVideoPath: () => Promise<{ success: boolean }>; saveProjectFile: ( projectData: unknown, @@ -279,6 +285,26 @@ interface Window { showCountdownOverlay: (value: number, runId: number) => Promise; setCountdownOverlayValue: (value: number, runId: number) => Promise; hideCountdownOverlay: (runId: number) => Promise; + showRecordingAnnotationOverlay: () => Promise<{ success: boolean; error?: string }>; + hideRecordingAnnotationOverlay: () => Promise<{ success: boolean; error?: string }>; + setRecordingAnnotationTool: ( + tool: import("../src/components/launch/recordingAnnotations").RecordingAnnotationTool | null, + ) => Promise<{ + success: boolean; + tool?: import("../src/components/launch/recordingAnnotations").RecordingAnnotationTool | null; + error?: string; + }>; + clearRecordingAnnotations: () => Promise<{ success: boolean; error?: string }>; + undoRecordingAnnotation: () => Promise<{ success: boolean; error?: string }>; + onRecordingAnnotationToolChange: ( + callback: ( + tool: + | import("../src/components/launch/recordingAnnotations").RecordingAnnotationTool + | null, + ) => void, + ) => () => void; + onRecordingAnnotationClear: (callback: () => void) => () => void; + onRecordingAnnotationUndo: (callback: () => void) => () => void; onCountdownOverlayValue: (callback: (value: number | null) => void) => () => void; setMicrophoneExpanded: (expanded: boolean) => void; setHasUnsavedChanges: (hasChanges: boolean) => void; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 1eb569efeb..db031b2699 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -35,6 +35,8 @@ import type { ProjectFileResult, ProjectPathResult, } from "../../src/native/contracts"; +import { CaptionJobRegistry } from "../captions/jobs"; +import { generateCaptionsWithWhisper } from "../captions/whisper"; import { mainT } from "../i18n"; import { RECORDINGS_DIR } from "../main"; import { createCursorRecordingSession } from "../native-bridge/cursor/recording/factory"; @@ -356,6 +358,20 @@ let selectedDesktopSource: DesktopCapturerSource | null = null; let lastEnumeratedSources = new Map(); let currentProjectPath: string | null = null; let currentRecordingSession: RecordingSession | null = null; +const captionJobRegistry = new CaptionJobRegistry({ + generate: (request) => + generateCaptionsWithWhisper({ + jobId: request.jobId, + videoPath: request.videoPath, + language: request.language, + signal: request.signal, + platform: process.platform, + arch: process.arch, + resourcesPath: process.resourcesPath, + tempDir: app.getPath("temp"), + env: process.env, + }), +}); // Cached source from the user's pick. Used by setDisplayMediaRequestHandler in main.ts for cursor-free capture. export function getSelectedDesktopSource(): DesktopCapturerSource | null { @@ -406,6 +422,10 @@ let nativeWindowsCaptureOutput = ""; let nativeWindowsCaptureTargetPath: string | null = null; let nativeWindowsCaptureWebcamTargetPath: string | null = null; let nativeWindowsCaptureRecordingId: number | null = null; +let nativeWindowsCaptureStarted = false; +let nativeWindowsCaptureClosed = false; +let nativeWindowsCaptureCloseCode: number | null = null; +let nativeWindowsCaptureStopRequested = false; let nativeWindowsCursorOffsetMs = 0; let nativeWindowsCursorCaptureMode: CursorCaptureMode = "editable-overlay"; let nativeWindowsCursorRecordingStartMs = 0; @@ -417,6 +437,10 @@ let nativeMacCaptureProcess: ChildProcessWithoutNullStreams | null = null; let nativeMacCaptureOutput = ""; let nativeMacCaptureTargetPath: string | null = null; let nativeMacCaptureRecordingId: number | null = null; +let nativeMacCaptureStarted = false; +let nativeMacCaptureClosed = false; +let nativeMacCaptureCloseCode: number | null = null; +let nativeMacCaptureStopRequested = false; let nativeMacCursorOffsetMs = 0; let nativeMacCursorCaptureMode: CursorCaptureMode = "editable-overlay"; let nativeMacCursorRecordingStartMs = 0; @@ -920,8 +944,7 @@ function waitForNativeWindowsCaptureStart(proc: ChildProcessWithoutNullStreams) reject(new Error("Timed out waiting for native Windows capture to start")); }, 12000); - const onOutput = (chunk: Buffer) => { - nativeWindowsCaptureOutput += chunk.toString(); + const onOutput = () => { if (nativeWindowsCaptureOutput.includes("Recording started")) { cleanup(); resolve(); @@ -952,6 +975,10 @@ function waitForNativeWindowsCaptureStart(proc: ChildProcessWithoutNullStreams) proc.stderr.on("data", onOutput); proc.once("error", onError); proc.once("exit", onExit); + if (nativeWindowsCaptureOutput.includes("Recording started")) { + cleanup(); + resolve(); + } }); } @@ -970,14 +997,18 @@ function waitForNativeWindowsCaptureStop(proc: ChildProcessWithoutNullStreams) { ), ); }, NATIVE_WINDOWS_CAPTURE_STOP_TIMEOUT_MS); - const onOutput = (chunk: Buffer) => { - nativeWindowsCaptureOutput += chunk.toString(); + const onOutput = () => { + const stoppedPath = getNativeWindowsStoppedPathFromOutput(); + if (stoppedPath) { + cleanup(); + resolve(stoppedPath); + } }; const onClose = (code: number | null) => { cleanup(); - const match = nativeWindowsCaptureOutput.match(/Recording stopped\. Output path: (.+)/); - if (match?.[1]) { - resolve(match[1].trim()); + const stoppedPath = getNativeWindowsStoppedPathFromOutput(); + if (stoppedPath) { + resolve(stoppedPath); return; } if (code === 0 && nativeWindowsCaptureTargetPath) { @@ -1007,9 +1038,60 @@ function waitForNativeWindowsCaptureStop(proc: ChildProcessWithoutNullStreams) { proc.stderr.on("data", onOutput); proc.once("close", onClose); proc.once("error", onError); + + const stoppedPath = getNativeWindowsStoppedPathFromOutput(); + if (stoppedPath) { + cleanup(); + resolve(stoppedPath); + return; + } + if (nativeWindowsCaptureClosed) { + cleanup(); + if (nativeWindowsCaptureCloseCode === 0 && nativeWindowsCaptureTargetPath) { + resolve(nativeWindowsCaptureTargetPath); + return; + } + reject( + new Error( + nativeWindowsCaptureOutput.trim() || + `Native Windows capture exited with code=${nativeWindowsCaptureCloseCode ?? "unknown"}`, + ), + ); + } }); } +function attachNativeWindowsCaptureOutputDrain(proc: ChildProcessWithoutNullStreams) { + const drain = (chunk: Buffer) => { + nativeWindowsCaptureOutput += chunk.toString(); + }; + const cleanup = () => { + proc.stdout.off("data", drain); + proc.stderr.off("data", drain); + proc.off("close", cleanup); + proc.off("error", cleanup); + }; + + proc.stdout.on("data", drain); + proc.stderr.on("data", drain); + proc.once("close", cleanup); + proc.once("error", cleanup); +} + +function getNativeWindowsStoppedPathFromOutput() { + for (const line of nativeWindowsCaptureOutput.split(/\r?\n/).reverse()) { + const event = tryParseNativeHelperEvent(line.trim()); + if (event?.event === "recording-stopped" && typeof event.screenPath === "string") { + return event.screenPath; + } + } + + const matches = [ + ...nativeWindowsCaptureOutput.matchAll(/Recording stopped\. Output path: ([^\r\n]+)/g), + ]; + return matches.at(-1)?.[1]?.trim() ?? null; +} + function readNativeWindowsWebcamFormat(output: string) { const lines = output.split(/\r?\n/).filter((line) => line.includes('"event":"webcam-format"')); const lastLine = lines.at(-1); @@ -1177,9 +1259,40 @@ function waitForNativeMacCaptureStop(proc: ChildProcessWithoutNullStreams) { proc.once("close", onClose); proc.once("error", onError); inspectNativeMacCaptureOutput(); + + const stoppedPath = getNativeMacStoppedPathFromOutput(); + if (stoppedPath) { + cleanup(); + resolve(stoppedPath); + return; + } + if (nativeMacCaptureClosed) { + cleanup(); + if (nativeMacCaptureCloseCode === 0 && nativeMacCaptureTargetPath) { + resolve(nativeMacCaptureTargetPath); + return; + } + reject( + new Error( + nativeMacCaptureOutput.trim() || + `Native macOS capture exited with code=${nativeMacCaptureCloseCode ?? "unknown"}`, + ), + ); + } }); } +function getNativeMacStoppedPathFromOutput() { + for (const line of nativeMacCaptureOutput.split(/\r?\n/).reverse()) { + const event = tryParseNativeHelperEvent(line.trim()); + if (event?.event === "recording-stopped" && typeof event.screenPath === "string") { + return event.screenPath; + } + } + + return null; +} + function setCurrentRecordingSessionState(session: RecordingSession | null) { currentRecordingSession = session; currentVideoPath = session?.screenVideoPath ?? null; @@ -1261,12 +1374,62 @@ export function registerIpcHandlers( createEditorWindow: () => void, createSourceSelectorWindow: () => BrowserWindow, createCountdownOverlayWindow: () => BrowserWindow, + createRecordingAnnotationOverlayWindow: (bounds?: Electron.Rectangle) => BrowserWindow, getMainWindow: () => BrowserWindow | null, getSourceSelectorWindow: () => BrowserWindow | null, getCountdownOverlayWindow?: () => BrowserWindow | null, + getRecordingAnnotationOverlayWindow?: () => BrowserWindow | null, onRecordingStateChange?: (recording: boolean, sourceName: string) => void, _switchToHud?: () => void, ) { + const recordingAnnotationTools = new Set([ + "pen", + "arrow", + "rectangle", + "ellipse", + "highlight", + "text", + ]); + + function notifyRecordingAnnotationTool(tool: string | null) { + const mainWindow = getMainWindow(); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send("recording-annotation-tool", tool); + } + + const overlayWindow = getRecordingAnnotationOverlayWindow?.(); + if (overlayWindow && !overlayWindow.isDestroyed()) { + overlayWindow.webContents.send("recording-annotation-tool", tool); + } + } + + async function ensureRecordingAnnotationOverlayWindow() { + const bounds = getSelectedSourceBounds(); + const overlayWindow = + getRecordingAnnotationOverlayWindow?.() ?? createRecordingAnnotationOverlayWindow(bounds); + + if (overlayWindow.isDestroyed()) { + return null; + } + + overlayWindow.setBounds(bounds, false); + if (!overlayWindow.isVisible()) { + overlayWindow.showInactive(); + } + if (overlayWindow.webContents.isLoading()) { + await new Promise((resolve) => { + overlayWindow.webContents.once("did-finish-load", () => resolve()); + }); + } + + const mainWindow = getMainWindow(); + if (mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible()) { + mainWindow.moveTop(); + } + + return overlayWindow; + } + async function requestScreenAccess() { if (process.platform !== "darwin") { return { success: true, granted: true, status: "granted" }; @@ -1304,6 +1467,15 @@ export function registerIpcHandlers( } } + function notifyNativeRecordingStopped() { + const mainWindow = getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed()) { + return; + } + + mainWindow.webContents.send("native-recording-stopped"); + } + ipcMain.handle("get-sources", async (_, opts) => { const sources = await desktopCapturer.getSources(opts); lastEnumeratedSources = new Map(sources.map((source) => [source.id, source])); @@ -1513,6 +1685,64 @@ export function registerIpcHandlers( overlayWindow.hide(); }); + ipcMain.handle("recording-annotation-overlay-show", async () => { + const overlayWindow = await ensureRecordingAnnotationOverlayWindow(); + if (!overlayWindow) { + return { success: false, error: "Recording annotation overlay is unavailable." }; + } + + overlayWindow.setIgnoreMouseEvents(true, { forward: true }); + overlayWindow.webContents.send("recording-annotation-clear"); + notifyRecordingAnnotationTool(null); + return { success: true }; + }); + + ipcMain.handle("recording-annotation-overlay-hide", () => { + const overlayWindow = getRecordingAnnotationOverlayWindow?.(); + if (!overlayWindow || overlayWindow.isDestroyed()) { + return { success: true }; + } + + notifyRecordingAnnotationTool(null); + overlayWindow.webContents.send("recording-annotation-clear"); + overlayWindow.setIgnoreMouseEvents(true, { forward: true }); + overlayWindow.hide(); + return { success: true }; + }); + + ipcMain.handle("recording-annotation-tool-set", async (_, tool: unknown) => { + const normalizedTool = + typeof tool === "string" && recordingAnnotationTools.has(tool) ? tool : null; + const overlayWindow = await ensureRecordingAnnotationOverlayWindow(); + if (!overlayWindow) { + return { success: false, error: "Recording annotation overlay is unavailable." }; + } + + overlayWindow.setIgnoreMouseEvents(!normalizedTool, { forward: true }); + notifyRecordingAnnotationTool(normalizedTool); + return { success: true, tool: normalizedTool }; + }); + + ipcMain.handle("recording-annotation-clear", () => { + const overlayWindow = getRecordingAnnotationOverlayWindow?.(); + if (!overlayWindow || overlayWindow.isDestroyed()) { + return { success: true }; + } + + overlayWindow.webContents.send("recording-annotation-clear"); + return { success: true }; + }); + + ipcMain.handle("recording-annotation-undo", () => { + const overlayWindow = getRecordingAnnotationOverlayWindow?.(); + if (!overlayWindow || overlayWindow.isDestroyed()) { + return { success: true }; + } + + overlayWindow.webContents.send("recording-annotation-undo"); + return { success: true }; + }); + ipcMain.handle("is-native-windows-capture-available", async () => { if (!isWindowsGraphicsCaptureOsSupported()) { return { success: true, available: false, reason: "unsupported-os" }; @@ -1651,6 +1881,10 @@ export function registerIpcHandlers( nativeWindowsCaptureTargetPath = outputPath; nativeWindowsCaptureWebcamTargetPath = request.webcam.enabled ? webcamOutputPath : null; nativeWindowsCaptureRecordingId = recordingId; + nativeWindowsCaptureStarted = false; + nativeWindowsCaptureClosed = false; + nativeWindowsCaptureCloseCode = null; + nativeWindowsCaptureStopRequested = false; nativeWindowsCursorOffsetMs = 0; nativeWindowsCursorCaptureMode = cursorCaptureMode; nativeWindowsCursorRecordingStartMs = 0; @@ -1676,8 +1910,30 @@ export function registerIpcHandlers( windowsHide: true, }); nativeWindowsCaptureProcess = proc; + attachNativeWindowsCaptureOutputDrain(proc); + proc.once("close", (code) => { + nativeWindowsCaptureClosed = true; + nativeWindowsCaptureCloseCode = code; + if ( + nativeWindowsCaptureStarted && + !nativeWindowsCaptureStopRequested && + (getNativeWindowsStoppedPathFromOutput() || + (code === 0 && nativeWindowsCaptureTargetPath)) + ) { + notifyNativeRecordingStopped(); + } + }); await waitForNativeWindowsCaptureStart(proc); + nativeWindowsCaptureStarted = true; + if ( + nativeWindowsCaptureClosed && + !nativeWindowsCaptureStopRequested && + (getNativeWindowsStoppedPathFromOutput() || + (nativeWindowsCaptureCloseCode === 0 && nativeWindowsCaptureTargetPath)) + ) { + notifyNativeRecordingStopped(); + } const captureStartedAtMs = Date.now(); nativeWindowsCursorOffsetMs = cursorCaptureMode === "editable-overlay" @@ -1708,6 +1964,10 @@ export function registerIpcHandlers( nativeWindowsCaptureTargetPath = null; nativeWindowsCaptureWebcamTargetPath = null; nativeWindowsCaptureRecordingId = null; + nativeWindowsCaptureStarted = false; + nativeWindowsCaptureClosed = false; + nativeWindowsCaptureCloseCode = null; + nativeWindowsCaptureStopRequested = false; nativeWindowsCursorOffsetMs = 0; nativeWindowsCursorCaptureMode = "editable-overlay"; nativeWindowsCursorRecordingStartMs = 0; @@ -1806,6 +2066,10 @@ export function registerIpcHandlers( nativeMacCaptureOutput = ""; nativeMacCaptureTargetPath = outputPath; nativeMacCaptureRecordingId = recordingId; + nativeMacCaptureStarted = false; + nativeMacCaptureClosed = false; + nativeMacCaptureCloseCode = null; + nativeMacCaptureStopRequested = false; nativeMacCursorOffsetMs = 0; nativeMacCursorCaptureMode = cursorCaptureMode; nativeMacCursorRecordingStartMs = 0; @@ -1827,8 +2091,28 @@ export function registerIpcHandlers( }); nativeMacCaptureProcess = proc; attachNativeMacCaptureOutputDrain(proc); + proc.once("close", (code) => { + nativeMacCaptureClosed = true; + nativeMacCaptureCloseCode = code; + if ( + nativeMacCaptureStarted && + !nativeMacCaptureStopRequested && + (getNativeMacStoppedPathFromOutput() || (code === 0 && nativeMacCaptureTargetPath)) + ) { + notifyNativeRecordingStopped(); + } + }); await waitForNativeMacCaptureStart(proc); + nativeMacCaptureStarted = true; + if ( + nativeMacCaptureClosed && + !nativeMacCaptureStopRequested && + (getNativeMacStoppedPathFromOutput() || + (nativeMacCaptureCloseCode === 0 && nativeMacCaptureTargetPath)) + ) { + notifyNativeRecordingStopped(); + } const captureStartedAtMs = Date.now(); nativeMacCursorOffsetMs = cursorCaptureMode === "editable-overlay" @@ -1852,6 +2136,10 @@ export function registerIpcHandlers( nativeMacCaptureProcess = null; nativeMacCaptureTargetPath = null; nativeMacCaptureRecordingId = null; + nativeMacCaptureStarted = false; + nativeMacCaptureClosed = false; + nativeMacCaptureCloseCode = null; + nativeMacCaptureStopRequested = false; nativeMacCursorOffsetMs = 0; nativeMacCursorCaptureMode = "editable-overlay"; nativeMacCursorRecordingStartMs = 0; @@ -1972,9 +2260,19 @@ export function registerIpcHandlers( try { completeNativeWindowsCursorPauseRange(); - const stoppedPathPromise = waitForNativeWindowsCaptureStop(proc); - proc.stdin.write("stop\n"); - const stoppedPath = await stoppedPathPromise; + let stoppedPath: string | null; + if (nativeWindowsCaptureClosed) { + stoppedPath = + getNativeWindowsStoppedPathFromOutput() ?? + (nativeWindowsCaptureCloseCode === 0 ? preferredPath : null); + } else { + nativeWindowsCaptureStopRequested = true; + const stoppedPathPromise = waitForNativeWindowsCaptureStop(proc); + if (proc.stdin.writable) { + proc.stdin.write("stop\n"); + } + stoppedPath = await stoppedPathPromise; + } const screenVideoPath = stoppedPath || preferredPath; if (!screenVideoPath) { throw new Error("Native Windows capture did not return an output path."); @@ -2036,6 +2334,10 @@ export function registerIpcHandlers( nativeWindowsCaptureTargetPath = null; nativeWindowsCaptureWebcamTargetPath = null; nativeWindowsCaptureRecordingId = null; + nativeWindowsCaptureStarted = false; + nativeWindowsCaptureClosed = false; + nativeWindowsCaptureCloseCode = null; + nativeWindowsCaptureStopRequested = false; nativeWindowsCursorOffsetMs = 0; nativeWindowsCursorCaptureMode = "editable-overlay"; nativeWindowsCursorRecordingStartMs = 0; @@ -2065,9 +2367,19 @@ export function registerIpcHandlers( try { completeNativeMacCursorPauseRange(); - const stoppedPathPromise = waitForNativeMacCaptureStop(proc); - proc.stdin.write("stop\n"); - const stoppedPath = await stoppedPathPromise; + let stoppedPath: string | null; + if (nativeMacCaptureClosed) { + stoppedPath = + getNativeMacStoppedPathFromOutput() ?? + (nativeMacCaptureCloseCode === 0 ? preferredPath : null); + } else { + nativeMacCaptureStopRequested = true; + const stoppedPathPromise = waitForNativeMacCaptureStop(proc); + if (proc.stdin.writable) { + proc.stdin.write("stop\n"); + } + stoppedPath = await stoppedPathPromise; + } const screenVideoPath = stoppedPath || preferredPath; if (!screenVideoPath) { throw new Error("Native macOS capture did not return an output path."); @@ -2121,6 +2433,10 @@ export function registerIpcHandlers( nativeMacCaptureProcess = null; nativeMacCaptureTargetPath = null; nativeMacCaptureRecordingId = null; + nativeMacCaptureStarted = false; + nativeMacCaptureClosed = false; + nativeMacCaptureCloseCode = null; + nativeMacCaptureStopRequested = false; nativeMacCursorOffsetMs = 0; nativeMacCursorCaptureMode = "editable-overlay"; nativeMacCursorRecordingStartMs = 0; @@ -2555,6 +2871,46 @@ export function registerIpcHandlers( } }); + ipcMain.handle( + "start-caption-generation", + async ( + _, + videoPath: string, + options?: import("../../src/lib/captions").CaptionGenerationOptions, + ) => { + try { + const jobId = options?.jobId ?? `caption-${Date.now()}`; + const normalizedPath = resolveApprovedVideoPath(videoPath); + if (!normalizedPath) { + return { + jobId, + status: "error", + segments: [], + message: "File path is not approved or is not a supported video file", + }; + } + + return await captionJobRegistry.start({ + jobId, + videoPath: normalizedPath, + language: options?.language, + }); + } catch (error) { + return { + jobId: options?.jobId ?? `caption-${Date.now()}`, + status: "error", + segments: [], + message: "Failed to generate captions", + error: String(error), + }; + } + }, + ); + + ipcMain.handle("cancel-caption-generation", (_, jobId: string) => { + return captionJobRegistry.cancel(jobId); + }); + ipcMain.handle( "save-project-file", async (_, projectData: unknown, suggestedName?: string, existingProjectPath?: string) => { diff --git a/electron/main.ts b/electron/main.ts index bfb949ed7c..b48882f517 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -23,6 +23,7 @@ import { createCountdownOverlayWindow, createEditorWindow, createHudOverlayWindow, + createRecordingAnnotationOverlayWindow, createSourceSelectorWindow, } from "./windows"; @@ -82,6 +83,7 @@ process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL let mainWindow: BrowserWindow | null = null; let sourceSelectorWindow: BrowserWindow | null = null; let countdownOverlayWindow: BrowserWindow | null = null; +let recordingAnnotationOverlayWindow: BrowserWindow | null = null; let tray: Tray | null = null; let selectedSourceName = ""; const isMac = process.platform === "darwin"; @@ -427,6 +429,21 @@ function createCountdownOverlayWindowWrapper() { return countdownOverlayWindow; } +function createRecordingAnnotationOverlayWindowWrapper(bounds?: Electron.Rectangle) { + if (recordingAnnotationOverlayWindow && !recordingAnnotationOverlayWindow.isDestroyed()) { + if (bounds) { + recordingAnnotationOverlayWindow.setBounds(bounds, false); + } + return recordingAnnotationOverlayWindow; + } + + recordingAnnotationOverlayWindow = createRecordingAnnotationOverlayWindow(bounds); + recordingAnnotationOverlayWindow.on("closed", () => { + recordingAnnotationOverlayWindow = null; + }); + return recordingAnnotationOverlayWindow; +} + // Closing every window quits the app (tray goes too). The in-app "Return to Recorder" // button covers the editor-to-HUD round-trip, so closing the last window means "I'm done". app.on("window-all-closed", () => { @@ -442,7 +459,10 @@ app.on("activate", () => { const url = window.webContents.getURL(); const isCountdownOverlayWindow = url.includes("windowType=countdown-overlay"); - return !isCountdownOverlayWindow; + const isRecordingAnnotationOverlayWindow = url.includes( + "windowType=recording-annotation-overlay", + ); + return !isCountdownOverlayWindow && !isRecordingAnnotationOverlayWindow; }); if (!hasVisibleWindow) { showMainWindow(); @@ -545,9 +565,11 @@ app.whenReady().then(async () => { createEditorWindowWrapper, createSourceSelectorWindowWrapper, createCountdownOverlayWindowWrapper, + createRecordingAnnotationOverlayWindowWrapper, () => mainWindow, () => sourceSelectorWindow, () => countdownOverlayWindow, + () => recordingAnnotationOverlayWindow, (recording: boolean, sourceName: string) => { selectedSourceName = sourceName; if (!tray) createTray(); diff --git a/electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.test.ts b/electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.test.ts new file mode 100644 index 0000000000..666033313b --- /dev/null +++ b/electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { requestMacCursorAccessibilityAccess } from "./macNativeCursorRecordingSession"; + +const mocks = vi.hoisted(() => ({ + accessSync: vi.fn(), + isTrustedAccessibilityClient: vi.fn(), + spawn: vi.fn(), +})); + +vi.mock("electron", () => ({ + screen: { + getCursorScreenPoint: vi.fn(() => ({ x: 0, y: 0 })), + getDisplayNearestPoint: vi.fn(() => ({ bounds: { x: 0, y: 0, width: 1, height: 1 } })), + }, + systemPreferences: { + isTrustedAccessibilityClient: mocks.isTrustedAccessibilityClient, + }, +})); + +vi.mock("node:child_process", () => ({ + default: { spawn: mocks.spawn }, + spawn: mocks.spawn, +})); + +vi.mock("node:fs", () => ({ + default: { + accessSync: mocks.accessSync, + constants: { X_OK: 1 }, + }, + accessSync: mocks.accessSync, + constants: { X_OK: 1 }, +})); + +describe("requestMacCursorAccessibilityAccess", () => { + beforeEach(() => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + mocks.accessSync.mockImplementation(() => { + throw new Error("missing helper"); + }); + mocks.isTrustedAccessibilityClient.mockReset(); + mocks.spawn.mockReset(); + }); + + it("trusts the OpenScreen app accessibility grant without requiring the helper grant", async () => { + mocks.isTrustedAccessibilityClient.mockReturnValue(true); + + const result = await requestMacCursorAccessibilityAccess(); + + expect(result).toEqual({ success: true, granted: true, status: "granted" }); + expect(mocks.isTrustedAccessibilityClient).toHaveBeenCalledWith(true); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); +}); diff --git a/electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts b/electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts index 27a8a870c2..10e5620b2c 100644 --- a/electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts +++ b/electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts @@ -85,7 +85,9 @@ export async function requestMacCursorAccessibilityAccess() { } try { - systemPreferences.isTrustedAccessibilityClient(true); + if (systemPreferences.isTrustedAccessibilityClient(true)) { + return { success: true, granted: true, status: "granted" }; + } } catch { // Continue with helper probing; it can trigger the same macOS prompt. } diff --git a/electron/preload.ts b/electron/preload.ts index 96cc831fd2..800e17f3ce 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,4 +1,5 @@ import { contextBridge, ipcRenderer, webUtils } from "electron"; +import type { RecordingAnnotationTool } from "../src/components/launch/recordingAnnotations"; import type { NativeMacRecordingRequest } from "../src/lib/nativeMacRecording"; import type { NativeWindowsRecordingRequest } from "../src/lib/nativeWindowsRecording"; import type { RecordingSession, StoreRecordedSessionInput } from "../src/lib/recordingSession"; @@ -137,6 +138,11 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.on("stop-recording-from-tray", listener); return () => ipcRenderer.removeListener("stop-recording-from-tray", listener); }, + onNativeRecordingStopped: (callback: () => void) => { + const listener = () => callback(); + ipcRenderer.on("native-recording-stopped", listener); + return () => ipcRenderer.removeListener("native-recording-stopped", listener); + }, openExternalUrl: (url: string) => { return ipcRenderer.invoke("open-external-url", url); }, @@ -167,6 +173,15 @@ contextBridge.exposeInMainWorld("electronAPI", { preparePreviewAudioTrack: (filePath: string) => { return ipcRenderer.invoke("prepare-preview-audio-track", filePath); }, + startCaptionGeneration: ( + videoPath: string, + options?: import("../src/lib/captions").CaptionGenerationOptions, + ) => { + return ipcRenderer.invoke("start-caption-generation", videoPath, options); + }, + cancelCaptionGeneration: (jobId: string) => { + return ipcRenderer.invoke("cancel-caption-generation", jobId); + }, clearCurrentVideoPath: () => { return ipcRenderer.invoke("clear-current-video-path"); }, @@ -255,6 +270,37 @@ contextBridge.exposeInMainWorld("electronAPI", { hideCountdownOverlay: (runId: number) => { return ipcRenderer.invoke("countdown-overlay-hide", runId); }, + showRecordingAnnotationOverlay: () => { + return ipcRenderer.invoke("recording-annotation-overlay-show"); + }, + hideRecordingAnnotationOverlay: () => { + return ipcRenderer.invoke("recording-annotation-overlay-hide"); + }, + setRecordingAnnotationTool: (tool: RecordingAnnotationTool | null) => { + return ipcRenderer.invoke("recording-annotation-tool-set", tool); + }, + clearRecordingAnnotations: () => { + return ipcRenderer.invoke("recording-annotation-clear"); + }, + undoRecordingAnnotation: () => { + return ipcRenderer.invoke("recording-annotation-undo"); + }, + onRecordingAnnotationToolChange: (callback: (tool: RecordingAnnotationTool | null) => void) => { + const listener = (_event: unknown, tool: unknown) => + callback((typeof tool === "string" ? tool : null) as RecordingAnnotationTool | null); + ipcRenderer.on("recording-annotation-tool", listener); + return () => ipcRenderer.removeListener("recording-annotation-tool", listener); + }, + onRecordingAnnotationClear: (callback: () => void) => { + const listener = () => callback(); + ipcRenderer.on("recording-annotation-clear", listener); + return () => ipcRenderer.removeListener("recording-annotation-clear", listener); + }, + onRecordingAnnotationUndo: (callback: () => void) => { + const listener = () => callback(); + ipcRenderer.on("recording-annotation-undo", listener); + return () => ipcRenderer.removeListener("recording-annotation-undo", listener); + }, onCountdownOverlayValue: (callback: (value: number | null) => void) => { const listener = (_event: unknown, value: number | null) => callback(value); ipcRenderer.on("countdown-overlay-value", listener); diff --git a/electron/windows.ts b/electron/windows.ts index c3830157f5..bcbb40041e 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -320,3 +320,47 @@ export function createCountdownOverlayWindow(): BrowserWindow { return win; } + +export function createRecordingAnnotationOverlayWindow(bounds?: Electron.Rectangle): BrowserWindow { + const targetBounds = bounds ?? screen.getPrimaryDisplay().bounds; + + const win = new BrowserWindow({ + x: targetBounds.x, + y: targetBounds.y, + width: targetBounds.width, + height: targetBounds.height, + frame: false, + resizable: false, + alwaysOnTop: true, + skipTaskbar: true, + focusable: true, + transparent: true, + backgroundColor: "#00000000", + hasShadow: false, + show: false, + webPreferences: { + preload: path.join(__dirname, "preload.mjs"), + additionalArguments: [ASSET_BASE_URL_ARG], + nodeIntegration: false, + contextIsolation: true, + backgroundThrottling: false, + }, + }); + + win.setIgnoreMouseEvents(true, { forward: true }); + win.setAlwaysOnTop(true, "floating"); + + if (process.platform === "darwin") { + win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + } + + if (VITE_DEV_SERVER_URL) { + win.loadURL(VITE_DEV_SERVER_URL + "?windowType=recording-annotation-overlay"); + } else { + win.loadFile(path.join(RENDERER_DIST, "index.html"), { + query: { windowType: "recording-annotation-overlay" }, + }); + } + + return win; +} diff --git a/index.html b/index.html index 510c15ddd1..bc1707a25a 100644 --- a/index.html +++ b/index.html @@ -1,12 +1,12 @@ - + - +
diff --git a/package-lock.json b/package-lock.json index f3f21713bc..399f1c579c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,6 @@ "@uiw/color-convert": "^2.10.1", "@uiw/react-color-block": "^2.10.1", "@uiw/react-color-colorful": "^2.9.2", - "@xenova/transformers": "^2.17.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dnd-timeline": "^2.4.0", @@ -187,6 +186,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -395,6 +395,7 @@ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -718,6 +719,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -766,6 +768,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -1197,7 +1200,6 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, - "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1219,7 +1221,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -1236,7 +1237,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -1251,7 +1251,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 10.0.0" } @@ -1773,15 +1772,6 @@ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, - "node_modules/@huggingface/jinja": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.2.2.tgz", - "integrity": "sha512-/KPde26khDUIPkTGU82jdtTW9UAuvUTumCAbFs/7giR0SxsvZC4hru51PBvpijH6BVkHcROcvZM/lpy5h1jRRA==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -1970,7 +1960,6 @@ "resolved": "https://registry.npmjs.org/@pixi/color/-/color-7.4.3.tgz", "integrity": "sha512-a6R+bXKeXMDcRmjYQoBIK+v2EYqxSX49wcjAY579EYM/WrFKS98nSees6lqVUcLKrcQh2DT9srJHX7XMny3voQ==", "license": "MIT", - "peer": true, "dependencies": { "@pixi/colord": "^2.9.6" } @@ -1985,8 +1974,7 @@ "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/constants/-/constants-7.4.3.tgz", "integrity": "sha512-QGmwJUNQy/vVEHzL6VGQvnwawLZ1wceZMI8HwJAT4/I2uAzbBeFDdmCS8WsTpSWLZjF/DszDc1D8BFp4pVJ5UQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@pixi/core": { "version": "7.4.3", @@ -2013,8 +2001,7 @@ "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/extensions/-/extensions-7.4.3.tgz", "integrity": "sha512-FhoiYkHQEDYHUE7wXhqfsTRz6KxLXjuMbSiAwnLb9uG1vAgp6q6qd6HEsf4X30YaZbLFY8a4KY6hFZWjF+4Fdw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@pixi/filter-drop-shadow": { "version": "5.2.0", @@ -2041,22 +2028,19 @@ "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/math/-/math-7.4.3.tgz", "integrity": "sha512-/uJOVhR2DOZ+zgdI6Bs/CwcXT4bNRKsS+TqX3ekRIxPCwaLra+Qdm7aDxT5cTToDzdxbKL5+rwiLu3Y1egILDw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@pixi/runner": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/runner/-/runner-7.4.3.tgz", "integrity": "sha512-TJyfp7y23u5vvRAyYhVSa7ytq0PdKSvPLXu4G3meoFh1oxTLHH6g/RIzLuxUAThPG2z7ftthuW3qWq6dRV+dhw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@pixi/settings": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/settings/-/settings-7.4.3.tgz", "integrity": "sha512-SmGK8smc0PxRB9nr0UJioEtE9hl4gvj9OedCvZx3bxBwA3omA5BmP3CyhQfN8XJ29+o2OUL01r3zAPVol4l4lA==", "license": "MIT", - "peer": true, "dependencies": { "@pixi/constants": "7.4.3", "@types/css-font-loading-module": "^0.0.12", @@ -2068,7 +2052,6 @@ "resolved": "https://registry.npmjs.org/@pixi/ticker/-/ticker-7.4.3.tgz", "integrity": "sha512-tHsAD0iOUb6QSGGw+c8cyRBvxsq/NlfzIFBZLEHhWZ+Bx4a0MmXup6I/yJDGmyPCYE+ctCcAfY13wKAzdiVFgQ==", "license": "MIT", - "peer": true, "dependencies": { "@pixi/extensions": "7.4.3", "@pixi/settings": "7.4.3", @@ -2080,7 +2063,6 @@ "resolved": "https://registry.npmjs.org/@pixi/utils/-/utils-7.4.3.tgz", "integrity": "sha512-NO3Y9HAn2UKS1YdxffqsPp+kDpVm8XWvkZcS/E+rBzY9VTLnNOI7cawSRm+dacdET3a8Jad3aDKEDZ0HmAqAFA==", "license": "MIT", - "peer": true, "dependencies": { "@pixi/color": "7.4.3", "@pixi/constants": "7.4.3", @@ -2114,70 +2096,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/codegen": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", - "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" - } - }, - "node_modules/@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz", - "integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/utf8": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", - "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", - "license": "BSD-3-Clause" - }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -3723,8 +3641,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3799,8 +3716,7 @@ "version": "0.0.12", "resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.12.tgz", "integrity": "sha512-x2tZZYkSxXqWvTDgveSynfjq/T2HyiZHXb00j/+gy19yp70PHCizM48XFdjBCWH7eHBD0R5i/pw9yMBP/BH5uA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/debug": { "version": "4.1.13", @@ -3838,8 +3754,7 @@ "version": "2.1.4", "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-2.1.4.tgz", "integrity": "sha512-qp3m9PPz4gULB9MhjGID7wpo3gJ4bTGXm7ltNDsmOvsPduTeHp8wSW9YckBj3mljeOh4F0m2z/0JKAALRKbmLQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.8", @@ -3896,12 +3811,6 @@ "@types/node": "*" } }, - "node_modules/@types/long": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", - "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", - "license": "MIT" - }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -3913,6 +3822,7 @@ "version": "22.19.17", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -3943,6 +3853,7 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -3954,6 +3865,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -4235,6 +4147,7 @@ "integrity": "sha512-CWy0lBQJq97nionyJJdnaU4961IXTl43a7UCu5nHy51IoKxAt6PVIJLo+76rVl7KOOgcWHNkG4kbJu/pW7knvA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/browser": "4.1.5", "@vitest/mocker": "4.1.5", @@ -4372,20 +4285,6 @@ "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==", "license": "BSD-3-Clause" }, - "node_modules/@xenova/transformers": { - "version": "2.17.2", - "resolved": "https://registry.npmjs.org/@xenova/transformers/-/transformers-2.17.2.tgz", - "integrity": "sha512-lZmHqzrVIkSvZdKZEx7IYY51TK0WDrC8eR0c5IMnBsO8di8are1zzw8BlLhyO2TklZKLN5UffNGs1IJwT6oOqQ==", - "license": "Apache-2.0", - "dependencies": { - "@huggingface/jinja": "^0.2.2", - "onnxruntime-web": "1.14.0", - "sharp": "^0.32.0" - }, - "optionalDependencies": { - "onnxruntime-node": "1.14.0" - } - }, "node_modules/@xmldom/xmldom": { "version": "0.8.13", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", @@ -4441,6 +4340,7 @@ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -4856,101 +4756,11 @@ "node": "18 || 20 || >=22" } }, - "node_modules/bare-events": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", - "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", - "license": "Apache-2.0", - "peerDependencies": { - "bare-abort-controller": "*" - }, - "peerDependenciesMeta": { - "bare-abort-controller": { - "optional": true - } - } - }, - "node_modules/bare-fs": { - "version": "4.7.1", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.1.tgz", - "integrity": "sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw==", - "license": "Apache-2.0", - "dependencies": { - "bare-events": "^2.5.4", - "bare-path": "^3.0.0", - "bare-stream": "^2.6.4", - "bare-url": "^2.2.2", - "fast-fifo": "^1.3.2" - }, - "engines": { - "bare": ">=1.16.0" - }, - "peerDependencies": { - "bare-buffer": "*" - }, - "peerDependenciesMeta": { - "bare-buffer": { - "optional": true - } - } - }, - "node_modules/bare-os": { - "version": "3.9.1", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.1.tgz", - "integrity": "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==", - "license": "Apache-2.0", - "engines": { - "bare": ">=1.14.0" - } - }, - "node_modules/bare-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", - "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", - "license": "Apache-2.0", - "dependencies": { - "bare-os": "^3.0.1" - } - }, - "node_modules/bare-stream": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.1.tgz", - "integrity": "sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==", - "license": "Apache-2.0", - "dependencies": { - "streamx": "^2.25.0", - "teex": "^1.0.1" - }, - "peerDependencies": { - "bare-abort-controller": "*", - "bare-buffer": "*", - "bare-events": "*" - }, - "peerDependenciesMeta": { - "bare-abort-controller": { - "optional": true - }, - "bare-buffer": { - "optional": true - }, - "bare-events": { - "optional": true - } - } - }, - "node_modules/bare-url": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.3.tgz", - "integrity": "sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ==", - "license": "Apache-2.0", - "dependencies": { - "bare-path": "^3.0.0" - } - }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, "funding": [ { "type": "github", @@ -5002,17 +4812,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, "node_modules/boolean": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", @@ -5067,6 +4866,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -5085,6 +4885,7 @@ "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, "funding": [ { "type": "github", @@ -5100,6 +4901,7 @@ } ], "license": "MIT", + "optional": true, "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" @@ -5246,7 +5048,6 @@ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "license": "MIT", - "peer": true, "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" @@ -5498,23 +5299,11 @@ "node": ">=6" } }, - "node_modules/color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" - }, - "engines": { - "node": ">=12.5.0" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -5527,18 +5316,9 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, "license": "MIT" }, - "node_modules/color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", - "license": "MIT", - "dependencies": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" - } - }, "node_modules/colorette": { "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", @@ -5618,8 +5398,7 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -5742,6 +5521,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, "license": "MIT", "dependencies": { "mimic-response": "^3.1.0" @@ -5757,6 +5537,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -5765,15 +5546,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/defer-to-connect": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", @@ -5842,15 +5614,6 @@ "node": ">=6" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, "node_modules/detect-node": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", @@ -5925,6 +5688,7 @@ "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", @@ -6017,8 +5781,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dotenv": { "version": "16.6.1", @@ -6067,8 +5830,7 @@ "version": "2.2.4", "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==", - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/ejs": { "version": "3.1.10", @@ -6251,7 +6013,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -6272,7 +6033,6 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -6325,6 +6085,7 @@ "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, "license": "MIT", "dependencies": { "once": "^1.4.0" @@ -6514,26 +6275,7 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", - "license": "MIT", - "peer": true - }, - "node_modules/events-universal": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", - "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", - "license": "Apache-2.0", - "dependencies": { - "bare-events": "^2.7.0" - } - }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "license": "(MIT OR WTFPL)", - "engines": { - "node": ">=6" - } + "license": "MIT" }, "node_modules/expect-type": { "version": "1.3.0", @@ -6614,12 +6356,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-fifo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", - "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", - "license": "MIT" - }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -6755,12 +6491,6 @@ "integrity": "sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==", "license": "MIT" }, - "node_modules/flatbuffers": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-1.12.0.tgz", - "integrity": "sha512-c7CZADjRcl6j0PlvFy0ZqXQ67qSEZfrVPynmnL+2zPc+NtMvrF8Y0QceMo7QqnSPc7+uWjUIAbvCQ5WIKlMVdQ==", - "license": "SEE LICENSE IN LICENSE.txt" - }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -6819,12 +6549,6 @@ } } }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "license": "MIT" - }, "node_modules/fs-extra": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", @@ -6980,12 +6704,6 @@ "js-binary-schema-parser": "^2.0.3" } }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT" - }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -7153,12 +6871,6 @@ "integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==", "license": "Standard 'no charge' license: https://gsap.com/standard-license." }, - "node_modules/guid-typescript": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", - "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==", - "license": "ISC" - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -7369,6 +7081,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, "funding": [ { "type": "github", @@ -7383,7 +7096,8 @@ "url": "https://feross.org/support" } ], - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "optional": true }, "node_modules/indent-string": { "version": "4.0.0", @@ -7411,20 +7125,9 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, "license": "ISC" }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" - }, - "node_modules/is-arrayish": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", - "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", - "license": "MIT" - }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -7937,12 +7640,6 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/long": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", - "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", - "license": "Apache-2.0" - }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -7990,7 +7687,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -8175,6 +7871,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -8209,7 +7906,6 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -8217,12 +7913,6 @@ "mkdirp": "bin/cmd.js" } }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT" - }, "node_modules/motion": { "version": "12.38.0", "resolved": "https://registry.npmjs.org/motion/-/motion-12.38.0.tgz", @@ -8319,12 +8009,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "license": "MIT" - }, "node_modules/node-abi": { "version": "4.28.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.28.0.tgz", @@ -8524,7 +8208,6 @@ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4" }, @@ -8558,6 +8241,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -8579,50 +8263,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/onnx-proto": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/onnx-proto/-/onnx-proto-4.0.4.tgz", - "integrity": "sha512-aldMOB3HRoo6q/phyB6QRQxSt895HNNw82BNyZ2CMh4bjeKv7g/c+VpAFtJuEMVfYLMbRx61hbuqnKceLeDcDA==", - "license": "MIT", - "dependencies": { - "protobufjs": "^6.8.8" - } - }, - "node_modules/onnxruntime-common": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.14.0.tgz", - "integrity": "sha512-3LJpegM2iMNRX2wUmtYfeX/ytfOzNwAWKSq1HbRrKc9+uqG/FsEA0bbKZl1btQeZaXhC26l44NWpNUeXPII7Ew==", - "license": "MIT" - }, - "node_modules/onnxruntime-node": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.14.0.tgz", - "integrity": "sha512-5ba7TWomIV/9b6NH/1x/8QEeowsb+jBEvFzU6z0T4mNsFwdPqXeFUM7uxC6QeSRkEbWu3qEB0VMjrvzN/0S9+w==", - "license": "MIT", - "optional": true, - "os": [ - "win32", - "darwin", - "linux" - ], - "dependencies": { - "onnxruntime-common": "~1.14.0" - } - }, - "node_modules/onnxruntime-web": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.14.0.tgz", - "integrity": "sha512-Kcqf43UMfW8mCydVGcX9OMXI2VN17c0p6XvR7IPSZzBf/6lteBzXHvcEVWDPmCKuGombl997HgLqj91F11DzXw==", - "license": "MIT", - "dependencies": { - "flatbuffers": "^1.12.0", - "guid-typescript": "^1.0.9", - "long": "^4.0.0", - "onnx-proto": "^4.0.4", - "onnxruntime-common": "~1.14.0", - "platform": "^1.3.6" - } - }, "node_modules/p-cancelable": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", @@ -8776,6 +8416,7 @@ "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.18.1.tgz", "integrity": "sha512-6LUPWYgulZhp/w4kam2XHXB0QedISZIqrJbRdHLLQ3csn5a38uzKxAp6B5j6s89QFYaIJbg95kvgTRcbgpO1ow==", "license": "MIT", + "peer": true, "workspaces": [ "examples", "playground" @@ -8815,18 +8456,13 @@ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, - "node_modules/platform": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", - "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", - "license": "MIT" - }, "node_modules/playwright": { "version": "1.59.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "playwright-core": "1.59.1" }, @@ -8897,6 +8533,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -9041,7 +8678,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -9059,103 +8695,16 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": "^12.20.0 || >=14" } }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", - "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/prebuild-install/node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" - }, - "node_modules/prebuild-install/node_modules/node-abi": { - "version": "3.92.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", - "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", - "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/prebuild-install/node_modules/semver": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/prebuild-install/node_modules/tar-fs": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", - "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", - "license": "MIT", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/prebuild-install/node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "license": "MIT", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -9171,7 +8720,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -9242,36 +8790,11 @@ "signal-exit": "^3.0.2" } }, - "node_modules/protobufjs": { - "version": "6.11.6", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.6.tgz", - "integrity": "sha512-k8BHqgPBOtrlougZZqF2uUk5Z7bN8f0wj+3e8M3hvtSv0NBAz4VBy5f6R5Nxq/l+i7mRFTgNZb2trxqTpHNY/A==", - "hasInstallScript": true, - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/long": "^4.0.1", - "@types/node": ">=13.7.0", - "long": "^4.0.0" - }, - "bin": { - "pbjs": "bin/pbjs", - "pbts": "bin/pbts" - } - }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", @@ -9310,7 +8833,6 @@ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "side-channel": "^1.1.0" }, @@ -9354,21 +8876,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, "node_modules/re-resizable": { "version": "6.11.2", "resolved": "https://registry.npmjs.org/re-resizable/-/re-resizable-6.11.2.tgz", @@ -9384,6 +8891,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -9396,6 +8904,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -9432,8 +8941,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-refresh": { "version": "0.18.0", @@ -9567,20 +9075,6 @@ "pify": "^2.3.0" } }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -9768,7 +9262,6 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -9863,26 +9356,6 @@ "queue-microtask": "^1.2.2" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -9967,47 +9440,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/sharp": { - "version": "0.32.6", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.6.tgz", - "integrity": "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "color": "^4.2.3", - "detect-libc": "^2.0.2", - "node-addon-api": "^6.1.0", - "prebuild-install": "^7.1.1", - "semver": "^7.5.4", - "simple-get": "^4.0.1", - "tar-fs": "^3.0.4", - "tunnel-agent": "^0.6.0" - }, - "engines": { - "node": ">=14.15.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/sharp/node_modules/node-addon-api": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", - "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", - "license": "MIT" - }, - "node_modules/sharp/node_modules/semver": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -10036,7 +9468,6 @@ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "license": "MIT", - "peer": true, "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", @@ -10056,7 +9487,6 @@ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", - "peer": true, "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" @@ -10073,7 +9503,6 @@ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "license": "MIT", - "peer": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -10092,7 +9521,6 @@ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "license": "MIT", - "peer": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -10121,60 +9549,6 @@ "dev": true, "license": "ISC" }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, - "node_modules/simple-swizzle": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", - "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.3.1" - } - }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -10316,26 +9690,6 @@ "dev": true, "license": "MIT" }, - "node_modules/streamx": { - "version": "2.25.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz", - "integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==", - "license": "MIT", - "dependencies": { - "events-universal": "^1.0.0", - "fast-fifo": "^1.3.2", - "text-decoder": "^1.1.0" - } - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -10416,15 +9770,6 @@ "node": ">=8" } }, - "node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -10516,6 +9861,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -10583,46 +9929,6 @@ "node": ">=18" } }, - "node_modules/tar-fs": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz", - "integrity": "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==", - "license": "MIT", - "dependencies": { - "pump": "^3.0.0", - "tar-stream": "^3.1.5" - }, - "optionalDependencies": { - "bare-fs": "^4.0.1", - "bare-path": "^3.0.0" - } - }, - "node_modules/tar-stream": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", - "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", - "license": "MIT", - "dependencies": { - "b4a": "^1.6.4", - "bare-fs": "^4.5.5", - "fast-fifo": "^1.2.0", - "streamx": "^2.15.0" - } - }, - "node_modules/tar-stream/node_modules/b4a": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", - "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", - "license": "Apache-2.0", - "peerDependencies": { - "react-native-b4a": "*" - }, - "peerDependenciesMeta": { - "react-native-b4a": { - "optional": true - } - } - }, "node_modules/tar/node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", @@ -10633,22 +9939,12 @@ "node": ">=18" } }, - "node_modules/teex": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", - "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", - "license": "MIT", - "dependencies": { - "streamx": "^2.12.5" - } - }, "node_modules/temp": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -10732,29 +10028,6 @@ "dev": true, "license": "MIT" }, - "node_modules/text-decoder": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", - "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", - "license": "Apache-2.0", - "dependencies": { - "b4a": "^1.6.4" - } - }, - "node_modules/text-decoder/node_modules/b4a": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", - "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", - "license": "Apache-2.0", - "peerDependencies": { - "react-native-b4a": "*" - }, - "peerDependenciesMeta": { - "react-native-b4a": { - "optional": true - } - } - }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -10958,18 +10231,6 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, "node_modules/type-fest": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", @@ -11012,6 +10273,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, "license": "MIT" }, "node_modules/universalify": { @@ -11070,7 +10332,6 @@ "resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz", "integrity": "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==", "license": "MIT", - "peer": true, "dependencies": { "punycode": "^1.4.1", "qs": "^6.12.3" @@ -11083,8 +10344,7 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/use-callback-ref": { "version": "1.3.3", @@ -11177,6 +10437,7 @@ "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -11266,7 +10527,8 @@ "resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz", "integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/vite/node_modules/fsevents": { "version": "2.3.3", @@ -11289,6 +10551,7 @@ "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", @@ -11523,6 +10786,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, "license": "ISC" }, "node_modules/ws": { diff --git a/package.json b/package.json index 54e0c833d4..61cafff460 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,6 @@ "@uiw/color-convert": "^2.10.1", "@uiw/react-color-block": "^2.10.1", "@uiw/react-color-colorful": "^2.9.2", - "@xenova/transformers": "^2.17.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dnd-timeline": "^2.4.0", diff --git a/scripts/before-pack.cjs b/scripts/before-pack.cjs deleted file mode 100644 index 934756a4db..0000000000 --- a/scripts/before-pack.cjs +++ /dev/null @@ -1,14 +0,0 @@ -// electron-builder beforePack hook: ensure the auto-caption assets (Whisper model + ORT wasm) exist -// before packaging, so the `caption-assets` extraResources entry has something to copy. Runs on -// every package invocation (local `npm run build:*` and CI's bare `electron-builder`). The fetch -// script is idempotent, so it's a no-op once the assets are present. - -const { execFileSync } = require("node:child_process"); -const path = require("node:path"); - -exports.default = async function beforePack() { - execFileSync("node", [path.join(__dirname, "fetch-caption-model.mjs")], { - stdio: "inherit", - cwd: path.join(__dirname, ".."), - }); -}; diff --git a/scripts/fetch-caption-model.mjs b/scripts/fetch-caption-model.mjs deleted file mode 100644 index f1d0a1f50e..0000000000 --- a/scripts/fetch-caption-model.mjs +++ /dev/null @@ -1,154 +0,0 @@ -// Populates `caption-assets/` so the packaged app can transcribe offline (under file://) -// instead of fetching the Whisper model from HuggingFace and the onnxruntime wasm from a CDN. -// -// caption-assets/ -// models/Xenova/whisper-tiny/... ← downloaded from HuggingFace (config + quantized ONNX) -// ort/ort-wasm*.wasm ← copied from @xenova/transformers/dist -// -// Idempotent: existing non-empty files are left alone, so re-runs and CI cache hits are no-ops. -// `caption-assets/` is gitignored and shipped via electron-builder `extraResources`. - -import { createWriteStream } from "node:fs"; -import { copyFile, mkdir, stat } from "node:fs/promises"; -import path from "node:path"; -import { Readable } from "node:stream"; -import { pipeline } from "node:stream/promises"; -import { fileURLToPath } from "node:url"; - -const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const OUT = path.join(ROOT, "caption-assets"); -const MODEL_ID = "Xenova/whisper-tiny"; -const HF_BASE = `https://huggingface.co/${MODEL_ID}/resolve/main`; - -// Small config/tokenizer/preprocessor files plus the quantized ONNX the ASR pipeline loads by -// default (encoder + merged decoder). Grab every metadata file so transformers never requests -// one we forgot to bundle. -const MODEL_FILES = [ - "config.json", - "generation_config.json", - "preprocessor_config.json", - "tokenizer.json", - "tokenizer_config.json", - "added_tokens.json", - "special_tokens_map.json", - "normalizer.json", - "merges.txt", - "vocab.json", - "quantize_config.json", - "onnx/encoder_model_quantized.onnx", - "onnx/decoder_model_merged_quantized.onnx", -]; - -async function exists(filePath) { - try { - const s = await stat(filePath); - return s.isFile() && s.size > 0; - } catch { - return false; - } -} - -const MAX_ATTEMPTS = 6; -// HuggingFace rate-limits (429) when the parallel CI matrix builds all hit it at once; also retry the -// usual transient server errors. -const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]); - -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function backoffMs(attempt, retryAfter) { - // Honor Retry-After when the server sends it (seconds or an HTTP date). - if (retryAfter) { - const secs = Number(retryAfter); - if (Number.isFinite(secs)) return Math.min(60_000, secs * 1000); - const at = Date.parse(retryAfter); - if (!Number.isNaN(at)) return Math.min(60_000, Math.max(0, at - Date.now())); - } - // Exponential backoff with jitter: ~2s, 4s, 8s, 16s, 32s, capped at 60s. - return Math.min(60_000, 2000 * 2 ** (attempt - 1)) + Math.floor(Math.random() * 1000); -} - -async function fetchWithRetry(url) { - let lastErr; - for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { - try { - const res = await fetch(url, { headers: { "user-agent": "openscreen-build" } }); - if (res.ok && res.body) return res; - if (RETRYABLE_STATUS.has(res.status) && attempt < MAX_ATTEMPTS) { - const wait = backoffMs(attempt, res.headers.get("retry-after")); - console.log( - ` … HTTP ${res.status}, retry ${attempt}/${MAX_ATTEMPTS - 1} in ${Math.round(wait / 1000)}s`, - ); - await sleep(wait); - continue; - } - throw new Error(`Failed to download ${url}: HTTP ${res.status} ${res.statusText}`); - } catch (err) { - lastErr = err; - const isHttp = err instanceof Error && err.message.startsWith("Failed to download"); - if (isHttp || attempt >= MAX_ATTEMPTS) throw err; - // Network/DNS error: back off and retry. - const wait = backoffMs(attempt, null); - console.log( - ` … ${err.message}, retry ${attempt}/${MAX_ATTEMPTS - 1} in ${Math.round(wait / 1000)}s`, - ); - await sleep(wait); - } - } - throw lastErr; -} - -async function download(url, dest) { - if (await exists(dest)) { - console.log(` ✓ cached ${path.relative(OUT, dest)}`); - return; - } - await mkdir(path.dirname(dest), { recursive: true }); - const res = await fetchWithRetry(url); - const tmp = `${dest}.partial`; - await pipeline(Readable.fromWeb(res.body), createWriteStream(tmp)); - const { rename } = await import("node:fs/promises"); - await rename(tmp, dest); - const mb = ((await stat(dest)).size / 1_000_000).toFixed(1); - console.log(` ↓ ${path.relative(OUT, dest)} (${mb} MB)`); -} - -async function copyOrtWasm() { - const distDir = path.join(ROOT, "node_modules", "@xenova", "transformers", "dist"); - // Non-threaded variants only: the worker runs ORT with numThreads=1 (no SharedArrayBuffer - // under file://), so the threaded wasm is never loaded. Saves ~20MB. - const wasm = ["ort-wasm.wasm", "ort-wasm-simd.wasm"]; - const ortOut = path.join(OUT, "ort"); - await mkdir(ortOut, { recursive: true }); - for (const name of wasm) { - const src = path.join(distDir, name); - const dest = path.join(ortOut, name); - if (!(await exists(src))) { - throw new Error(`Missing ${src} — is @xenova/transformers installed? Run npm ci first.`); - } - if (await exists(dest)) { - console.log(` ✓ cached ort/${name}`); - continue; - } - await copyFile(src, dest); - console.log(` + copied ort/${name}`); - } -} - -async function main() { - console.log(`Fetching caption assets → ${path.relative(ROOT, OUT)}/`); - console.log("ONNX Runtime wasm:"); - await copyOrtWasm(); - console.log(`Whisper model (${MODEL_ID}):`); - const modelDir = path.join(OUT, "models", ...MODEL_ID.split("/")); - for (const rel of MODEL_FILES) { - await download(`${HF_BASE}/${rel}`, path.join(modelDir, rel)); - } - console.log("Caption assets ready."); -} - -main().catch((err) => { - console.error(`\nfetch-caption-model failed: ${err.message}`); - process.exit(1); -}); diff --git a/src/App.tsx b/src/App.tsx index 0c8875d04b..079c2190df 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,7 @@ import { lazy, Suspense, useEffect, useState } from "react"; import { CountdownOverlay } from "./components/launch/CountdownOverlay.tsx"; import { LaunchWindow } from "./components/launch/LaunchWindow"; +import { RecordingAnnotationOverlay } from "./components/launch/RecordingAnnotationOverlay"; import { SourceSelector } from "./components/launch/SourceSelector"; import { Toaster } from "./components/ui/sonner"; import { TooltipProvider } from "./components/ui/tooltip"; @@ -27,7 +28,12 @@ export default function App() { setWindowType(type); } - if (type === "hud-overlay" || type === "source-selector" || type === "countdown-overlay") { + if ( + type === "hud-overlay" || + type === "source-selector" || + type === "countdown-overlay" || + type === "recording-annotation-overlay" + ) { document.body.style.background = "transparent"; document.documentElement.style.background = "transparent"; document.getElementById("root")?.style.setProperty("background", "transparent"); @@ -63,6 +69,8 @@ export default function App() { return ; case "countdown-overlay": return ; + case "recording-annotation-overlay": + return ; case "editor": return ( diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index a93514c5ad..d7729e3fbb 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -1,4 +1,19 @@ -import { Check, ChevronDown, Clapperboard, Columns3, Languages, Rows3 } from "lucide-react"; +import { + ArrowUpRight, + Check, + ChevronDown, + Circle, + Clapperboard, + Columns3, + Eraser, + Highlighter, + Languages, + Pencil, + Rows3, + Square, + Type, + Undo2, +} from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { BsPauseCircle, BsPlayCircle, BsRecordCircle } from "react-icons/bs"; @@ -34,6 +49,7 @@ import { Button } from "../ui/button"; import { Tooltip } from "../ui/tooltip"; import styles from "./LaunchWindow.module.css"; import { openSourceSelectorWithPermissionRetry } from "./openSourceSelectorFlow"; +import type { RecordingAnnotationTool } from "./recordingAnnotations"; const ICON_SIZE = 20; @@ -88,6 +104,19 @@ const hudSidebarClasses = "ml-0.5 pl-1.5 border-l border-white/10 flex items-cen const hudSidebarVerticalClasses = "mt-0.5 pt-1.5 border-t border-white/10 flex flex-col items-center gap-0.5"; +const annotationToolConfig = [ + { tool: "pen", label: "Pen / brush", icon: Pencil }, + { tool: "arrow", label: "Arrow", icon: ArrowUpRight }, + { tool: "rectangle", label: "Rectangle", icon: Square }, + { tool: "ellipse", label: "Circle", icon: Circle }, + { tool: "highlight", label: "Highlight", icon: Highlighter }, + { tool: "text", label: "Text", icon: Type }, +] satisfies Array<{ + tool: RecordingAnnotationTool; + label: string; + icon: typeof Pencil; +}>; + /** Launches the floating recording HUD and its recorder controls. */ export function LaunchWindow() { const t = useScopedT("launch"); @@ -143,6 +172,9 @@ export function LaunchWindow() { () => loadUserPreferences().trayLayout, ); const [supportsCursorModeToggle, setSupportsCursorModeToggle] = useState(false); + const [activeAnnotationTool, setActiveAnnotationTool] = useState( + null, + ); const languageTriggerRef = useRef(null); const languageMenuPanelRef = useRef(null); const hudBarRef = useRef(null); @@ -235,6 +267,20 @@ export function LaunchWindow() { }); }, []); + useEffect(() => { + if (!recording && activeAnnotationTool) { + setActiveAnnotationTool(null); + } + }, [activeAnnotationTool, recording]); + + useEffect(() => { + const cleanup = window.electronAPI?.onRecordingAnnotationToolChange?.((nextTool) => { + setActiveAnnotationTool(nextTool); + }); + + return () => cleanup?.(); + }, []); + useEffect(() => { if (!isLanguageMenuOpen) return; @@ -475,6 +521,30 @@ export function LaunchWindow() { setMicrophoneEnabled(!microphoneEnabled); } }; + + const selectAnnotationTool = useCallback( + (tool: RecordingAnnotationTool) => { + const nextTool = activeAnnotationTool === tool ? null : tool; + setActiveAnnotationTool(nextTool); + window.electronAPI?.setRecordingAnnotationTool(nextTool).catch((error) => { + console.warn("Failed to update recording annotation tool:", error); + setActiveAnnotationTool(activeAnnotationTool); + }); + }, + [activeAnnotationTool], + ); + + const undoRecordingAnnotation = useCallback(() => { + window.electronAPI?.undoRecordingAnnotation?.().catch((error) => { + console.warn("Failed to undo recording annotation:", error); + }); + }, []); + + const clearRecordingAnnotations = useCallback(() => { + window.electronAPI?.clearRecordingAnnotations?.().catch((error) => { + console.warn("Failed to clear recording annotations:", error); + }); + }, []); const dragLastPositionRef = useRef<{ x: number; y: number } | null>(null); const handleHudDragPointerDown = (event: React.PointerEvent) => { event.preventDefault(); @@ -843,6 +913,60 @@ export function LaunchWindow() { )} + {recording && ( +
+ {annotationToolConfig.map(({ tool, label, icon: Icon }) => { + const isActive = activeAnnotationTool === tool; + return ( + + + + ); + })} +
+ + + + + + +
+ )} + {/* Record/Stop group */} +
+
+ +

+ {t("annotation.stickerPresetsDescription")} +

+
+
+ {ANNOTATION_STICKER_PRESETS.map((sticker) => ( + + ))} +
+
+ {annotation.content && annotation.content.startsWith("data:image") && (
(null); const [videoSourcePath, setVideoSourcePath] = useState(null); + const [autoCaptionSourcePath, setAutoCaptionSourcePath] = useState(null); const [webcamVideoPath, setWebcamVideoPath] = useState(null); const [webcamVideoSourcePath, setWebcamVideoSourcePath] = useState(null); const [currentProjectPath, setCurrentProjectPath] = useState(null); @@ -314,14 +299,10 @@ export default function VideoEditor() { const t = useScopedT("editor"); const ts = useScopedT("settings"); const availableLocales = getAvailableLocales(); + const captionLanguage = useMemo(() => getWhisperLanguageForLocale(locale), [locale]); const nextAnnotationIdRef = useRef(1); const nextAnnotationZIndexRef = useRef(1); - const isAutoCaptioningRef = useRef(false); - const [isAutoCaptioning, setIsAutoCaptioning] = useState(false); - const [showAutoCaptionsDialog, setShowAutoCaptionsDialog] = useState(false); - const [captionWordsMin, setCaptionWordsMin] = useState(2); - const [captionWordsMax, setCaptionWordsMax] = useState(7); const exporterRef = useRef(null); const annotationOnlyRegions = useMemo( @@ -389,6 +370,7 @@ export default function VideoEditor() { setError(null); setVideoSourcePath(sourcePath); setVideoPath(toFileUrl(sourcePath)); + setAutoCaptionSourcePath(null); setWebcamVideoSourcePath(webcamSourcePath); setWebcamVideoPath(webcamSourcePath ? toFileUrl(webcamSourcePath) : null); setRecordingCursorCaptureMode(projectCursorCaptureMode); @@ -576,6 +558,7 @@ export default function VideoEditor() { INITIAL_EDITOR_STATE, ), ); + setAutoCaptionSourcePath(sourcePath); return; } @@ -588,6 +571,7 @@ export default function VideoEditor() { setLastSavedSnapshot( createProjectSnapshot({ screenVideoPath: result.path }, INITIAL_EDITOR_STATE), ); + setAutoCaptionSourcePath(null); } // No video/project/session, so leave videoPath null and let the // EditorEmptyState dashboard render instead of an error screen. @@ -1409,11 +1393,8 @@ export default function VideoEditor() { const handleAnnotationSpanChange = useCallback( (id: string, span: Span) => { - pushState((prev) => { - const editedAutoCaption = - prev.annotationRegions.find((region) => region.id === id)?.annotationSource === - "auto-caption"; - const next = prev.annotationRegions.map((region) => + pushState((prev) => ({ + annotationRegions: prev.annotationRegions.map((region) => region.id === id ? { ...region, @@ -1421,11 +1402,8 @@ export default function VideoEditor() { endMs: Math.round(span.end), } : region, - ); - return { - annotationRegions: editedAutoCaption ? reconcileAutoCaptionTimelineGaps(next) : next, - }; - }); + ), + })); }, [pushState], ); @@ -2204,138 +2182,6 @@ export default function VideoEditor() { } }, []); - const generateAutoCaptions = useCallback( - async (minWords: number, maxWords: number) => { - if (!videoPath) { - toast.error(t("errors.noVideoLoaded")); - return; - } - if (isAutoCaptioningRef.current) { - toast.error(t("autoCaptions.busy")); - return; - } - const minW = Math.max(1, Math.min(minWords, maxWords)); - const maxW = Math.max(minW, maxWords); - - isAutoCaptioningRef.current = true; - setIsAutoCaptioning(true); - toast.loading(t("autoCaptions.generating"), { id: AUTO_CAPTION_PROGRESS_TOAST_ID }); - try { - const { samples, truncated, durationSec } = await extractMono16kFromVideoUrl(videoPath); - if (!Number.isFinite(durationSec) || durationSec <= 0 || samples.length < 800) { - toast.dismiss(AUTO_CAPTION_PROGRESS_TOAST_ID); - toast.error(t("autoCaptions.noAudio")); - return; - } - - const { samples: speechSamples, trimSec } = trimLeadingSilenceMono16k(samples); - if (speechSamples.length < 800) { - toast.dismiss(AUTO_CAPTION_PROGRESS_TOAST_ID); - toast.error(t("autoCaptions.noAudio")); - return; - } - - const trimMs = Math.round(trimSec * 1000); - const trimRegionsForTranscribe = shiftTrimRegionsMsForCaptionBuffer(trimRegions, trimMs); - - const transcribeOptions = { - onStatus: (phase: "model" | "transcribe") => { - if (phase === "model") { - toast.loading(t("autoCaptions.loadingModel"), { - id: AUTO_CAPTION_PROGRESS_TOAST_ID, - }); - } else { - toast.loading(t("autoCaptions.transcribing"), { - id: AUTO_CAPTION_PROGRESS_TOAST_ID, - }); - } - }, - }; - - let { segments: segmentsRaw, granularity } = await transcribeMono16kToSegments( - speechSamples, - { - trimRegions: trimRegionsForTranscribe, - ...transcribeOptions, - }, - ); - let transcribedFromTrimmedBuffer = true; - - // Leading-silence trimming can return empty even when the full source has - // speech. Retry once against the untrimmed buffer before giving up. - if (segmentsRaw.length === 0 && trimSec > 0) { - ({ segments: segmentsRaw, granularity } = await transcribeMono16kToSegments(samples, { - trimRegions, - ...transcribeOptions, - })); - transcribedFromTrimmedBuffer = false; - } - - const segments = - transcribedFromTrimmedBuffer && trimSec > 0 - ? segmentsRaw.map((s) => ({ - ...s, - startSec: s.startSec + trimSec, - endSec: s.endSec + trimSec, - })) - : segmentsRaw; - - let { regions, nextNumericId, nextZIndex } = captionSegmentsToAnnotationRegions( - segments, - nextAnnotationIdRef.current, - nextAnnotationZIndexRef.current, - { - minWordsPerCaption: minW, - maxWordsPerCaption: maxW, - timestampGranularity: granularity, - }, - ); - - if (regions.length === 0 && segments.length > 0) { - ({ regions, nextNumericId, nextZIndex } = captionSegmentsToAnnotationRegions( - segments, - nextAnnotationIdRef.current, - nextAnnotationZIndexRef.current, - { - minWordsPerCaption: 1, - maxWordsPerCaption: Number.MAX_SAFE_INTEGER, - timestampGranularity: granularity, - }, - )); - } - - if (regions.length === 0) { - toast.dismiss(AUTO_CAPTION_PROGRESS_TOAST_ID); - toast.info(t("autoCaptions.noneHeard")); - return; - } - - pushState((prev) => ({ annotationRegions: [...prev.annotationRegions, ...regions] })); - nextAnnotationIdRef.current = nextNumericId; - nextAnnotationZIndexRef.current = nextZIndex; - - toast.dismiss(AUTO_CAPTION_PROGRESS_TOAST_ID); - const minutesTrunc = String(Math.round(MAX_CAPTION_AUDIO_SEC / 60)); - if (truncated) { - toast.success(t("autoCaptions.done", { count: String(regions.length) }), { - description: t("autoCaptions.truncated", { minutes: minutesTrunc }), - }); - } else { - toast.success(t("autoCaptions.done", { count: String(regions.length) })); - } - } catch (e) { - console.error(e); - toast.dismiss(AUTO_CAPTION_PROGRESS_TOAST_ID); - const detail = e instanceof Error ? e.message : String(e); - toast.error(t("autoCaptions.failed"), { description: detail }); - } finally { - isAutoCaptioningRef.current = false; - setIsAutoCaptioning(false); - } - }, - [videoPath, trimRegions, pushState, t], - ); - const handleSaveDiagnostic = useCallback(async () => { const result = await window.electronAPI.saveDiagnostic({ error: exportError ?? "Manual diagnostic export", @@ -2349,6 +2195,53 @@ export default function VideoEditor() { } }, [exportError, editorState]); + const handleGeneratedCaptions = useCallback( + (segments: CaptionSegment[]) => { + if (segments.length === 0) return; + + pushState((prev) => { + const annotations = createCaptionAnnotations(segments, { + existingIds: prev.annotationRegions.map((region) => region.id), + startZIndex: nextAnnotationZIndexRef.current, + }); + nextAnnotationZIndexRef.current += annotations.length; + return { + annotationRegions: [...prev.annotationRegions, ...annotations], + }; + }); + }, + [pushState], + ); + + const handleCaptionStatusMessage = useCallback( + (result: CaptionGenerationResult) => { + if (result.status === "success" && result.segments.length > 0) { + toast.success(t("captions.generated", { count: result.segments.length })); + return; + } + if (result.status === "skipped") { + toast.info(t("captions.skippedNoAudio")); + return; + } + if (result.status === "unavailable") { + toast.warning(t("captions.unavailable")); + return; + } + if (result.status === "error") { + toast.error(result.message || t("captions.failed")); + } + }, + [t], + ); + + const captionStatus = useAutoCaptionGeneration({ + sourcePath: autoCaptionSourcePath, + enabled: Boolean(autoCaptionSourcePath), + language: captionLanguage, + onCaptions: handleGeneratedCaptions, + onStatusMessage: handleCaptionStatusMessage, + }); + if (loading) { return (
@@ -2403,85 +2296,6 @@ export default function VideoEditor() { - - - - {t("autoCaptions.dialogTitle")} - {t("autoCaptions.dialogDescription")} - -
-
- - -
-
- - -
-
- - - - -
-
-
{ts("project.save")} + {captionStatus === "running" && ( + + {t("captions.generating")} + + )}
@@ -2900,19 +2719,6 @@ export default function VideoEditor() { } videoUrl={videoPath ?? undefined} showTrimWaveform={showTrimWaveform} - captionsLabel={t("autoCaptions.button")} - isGeneratingCaptions={isAutoCaptioning} - onGenerateCaptions={() => { - if (!videoPath) { - toast.error(t("errors.noVideoLoaded")); - return; - } - if (isAutoCaptioningRef.current) { - toast.error(t("autoCaptions.busy")); - return; - } - setShowAutoCaptionsDialog(true); - }} />
diff --git a/src/components/video-editor/annotationStickers.ts b/src/components/video-editor/annotationStickers.ts new file mode 100644 index 0000000000..00bd8429fe --- /dev/null +++ b/src/components/video-editor/annotationStickers.ts @@ -0,0 +1,130 @@ +export interface AnnotationStickerPreset { + id: string; + label: string; + dataUrl: string; +} + +const SVG_SIZE = 128; + +function createStickerSvg(id: string, label: string, body: string): string { + return [ + ``, + body, + "", + ].join(""); +} + +function createStickerDataUrl(id: string, label: string, body: string): string { + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(createStickerSvg(id, label, body))}`; +} + +export const ANNOTATION_STICKER_PRESETS: AnnotationStickerPreset[] = [ + { + id: "check", + label: "Check", + dataUrl: createStickerDataUrl( + "check", + "Check", + '', + ), + }, + { + id: "cross", + label: "Cross", + dataUrl: createStickerDataUrl( + "cross", + "Cross", + '', + ), + }, + { + id: "warning", + label: "Warning", + dataUrl: createStickerDataUrl( + "warning", + "Warning", + '', + ), + }, + { + id: "question", + label: "Question", + dataUrl: createStickerDataUrl( + "question", + "Question", + '', + ), + }, + { + id: "number-1", + label: "Number 1", + dataUrl: createStickerDataUrl( + "number-1", + "Number 1", + '', + ), + }, + { + id: "number-2", + label: "Number 2", + dataUrl: createStickerDataUrl( + "number-2", + "Number 2", + '', + ), + }, + { + id: "number-3", + label: "Number 3", + dataUrl: createStickerDataUrl( + "number-3", + "Number 3", + '', + ), + }, + { + id: "click-target", + label: "Click target", + dataUrl: createStickerDataUrl( + "click-target", + "Click target", + '', + ), + }, + { + id: "arrow-up-right", + label: "Arrow up right", + dataUrl: createStickerDataUrl( + "arrow-up-right", + "Arrow up right", + '', + ), + }, + { + id: "command", + label: "Command", + dataUrl: createStickerDataUrl( + "command", + "Command", + '', + ), + }, + { + id: "spotlight", + label: "Spotlight", + dataUrl: createStickerDataUrl( + "spotlight", + "Spotlight", + '', + ), + }, + { + id: "play", + label: "Play", + dataUrl: createStickerDataUrl( + "play", + "Play", + '', + ), + }, +]; diff --git a/src/components/video-editor/captionAnnotations.test.ts b/src/components/video-editor/captionAnnotations.test.ts new file mode 100644 index 0000000000..3e36bdbbe9 --- /dev/null +++ b/src/components/video-editor/captionAnnotations.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { createCaptionAnnotations } from "./captionAnnotations"; + +describe("createCaptionAnnotations", () => { + it("maps caption segments to bottom-centered text annotations", () => { + const annotations = createCaptionAnnotations( + [ + { id: "caption-1", startMs: 100, endMs: 1200, text: "First line" }, + { id: "caption-2", startMs: 1300, endMs: 2600, text: "Second line" }, + ], + { existingIds: [], startZIndex: 7 }, + ); + + expect(annotations).toHaveLength(2); + expect(annotations[0]).toMatchObject({ + id: "caption-1", + type: "text", + content: "First line", + textContent: "First line", + startMs: 100, + endMs: 1200, + position: { x: 10, y: 78 }, + size: { width: 80, height: 14 }, + zIndex: 7, + }); + expect(annotations[0].style).toMatchObject({ + color: "#ffffff", + backgroundColor: "rgba(0, 0, 0, 0.58)", + fontWeight: "bold", + textAlign: "center", + }); + expect(annotations[1].zIndex).toBe(8); + }); + + it("keeps generated IDs unique when a project already contains caption IDs", () => { + const annotations = createCaptionAnnotations( + [{ id: "caption-1", startMs: 0, endMs: 1000, text: "Generated" }], + { existingIds: ["caption-1"], startZIndex: 1 }, + ); + + expect(annotations[0].id).toBe("caption-2"); + }); +}); diff --git a/src/components/video-editor/captionAnnotations.ts b/src/components/video-editor/captionAnnotations.ts new file mode 100644 index 0000000000..b857e0e4d5 --- /dev/null +++ b/src/components/video-editor/captionAnnotations.ts @@ -0,0 +1,56 @@ +import type { CaptionSegment } from "@/lib/captions"; +import { type AnnotationRegion, DEFAULT_ANNOTATION_STYLE } from "./types"; + +const CAPTION_POSITION = { x: 10, y: 78 }; +const CAPTION_SIZE = { width: 80, height: 14 }; +const CAPTION_BACKGROUND = "rgba(0, 0, 0, 0.58)"; + +type CreateCaptionAnnotationsOptions = { + existingIds: string[]; + startZIndex: number; +}; + +function nextCaptionId(existingIds: Set, preferredId: string): string { + if (!existingIds.has(preferredId)) { + existingIds.add(preferredId); + return preferredId; + } + + let index = 1; + while (existingIds.has(`caption-${index}`)) { + index += 1; + } + + const id = `caption-${index}`; + existingIds.add(id); + return id; +} + +export function createCaptionAnnotations( + segments: CaptionSegment[], + options: CreateCaptionAnnotationsOptions, +): AnnotationRegion[] { + const existingIds = new Set(options.existingIds); + + return segments.map((segment, index) => { + const id = nextCaptionId(existingIds, segment.id); + return { + id, + startMs: segment.startMs, + endMs: segment.endMs, + type: "text", + content: segment.text, + textContent: segment.text, + position: { ...CAPTION_POSITION }, + size: { ...CAPTION_SIZE }, + style: { + ...DEFAULT_ANNOTATION_STYLE, + color: "#ffffff", + backgroundColor: CAPTION_BACKGROUND, + fontWeight: "bold", + textAlign: "center", + }, + zIndex: options.startZIndex + index, + }; + }); +} diff --git a/src/components/video-editor/useAutoCaptionGeneration.test.ts b/src/components/video-editor/useAutoCaptionGeneration.test.ts new file mode 100644 index 0000000000..22075afe93 --- /dev/null +++ b/src/components/video-editor/useAutoCaptionGeneration.test.ts @@ -0,0 +1,236 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { createElement, type ReactNode, StrictMode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { CaptionGenerationResult } from "@/lib/captions"; +import { useAutoCaptionGeneration } from "./useAutoCaptionGeneration"; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function strictModeWrapper({ children }: { children: ReactNode }) { + return createElement(StrictMode, null, children); +} + +describe("useAutoCaptionGeneration", () => { + beforeEach(() => { + window.electronAPI = { + ...(window.electronAPI ?? {}), + startCaptionGeneration: vi.fn(), + cancelCaptionGeneration: vi.fn(async () => ({ success: true, cancelled: true })), + } as Window["electronAPI"]; + }); + + it("starts one caption job for a fresh recording source and forwards generated segments", async () => { + const result: CaptionGenerationResult = { + jobId: "caption-job", + status: "success", + segments: [{ id: "caption-1", startMs: 0, endMs: 1000, text: "Hello" }], + message: "Generated 1 captions.", + }; + vi.mocked(window.electronAPI.startCaptionGeneration).mockResolvedValue(result); + const onCaptions = vi.fn(); + const onStatusMessage = vi.fn(); + + const { result: hook } = renderHook(() => + useAutoCaptionGeneration({ + sourcePath: "/recordings/one.webm", + enabled: true, + onCaptions, + onStatusMessage, + }), + ); + + expect(hook.current).toBe("running"); + await waitFor(() => expect(onCaptions).toHaveBeenCalledWith(result.segments)); + expect(onStatusMessage).toHaveBeenCalledWith(result); + expect(hook.current).toBe("success"); + expect(window.electronAPI.startCaptionGeneration).toHaveBeenCalledTimes(1); + }); + + it("does not start again for the same source after rerender", async () => { + vi.mocked(window.electronAPI.startCaptionGeneration).mockResolvedValue({ + jobId: "caption-job", + status: "skipped", + segments: [], + message: "No audio track found; captions skipped.", + }); + const onCaptions = vi.fn(); + const onStatusMessage = vi.fn(); + + const { rerender } = renderHook(() => + useAutoCaptionGeneration({ + sourcePath: "/recordings/reused.webm", + enabled: true, + onCaptions, + onStatusMessage, + }), + ); + + rerender(); + await waitFor(() => expect(onStatusMessage).toHaveBeenCalledTimes(1)); + expect(window.electronAPI.startCaptionGeneration).toHaveBeenCalledTimes(1); + expect(onCaptions).not.toHaveBeenCalled(); + }); + + it("passes the requested transcription language to caption generation", async () => { + vi.mocked(window.electronAPI.startCaptionGeneration).mockResolvedValue({ + jobId: "caption-job", + status: "success", + segments: [{ id: "caption-1", startMs: 0, endMs: 1000, text: "你好" }], + }); + const onCaptions = vi.fn(); + const onStatusMessage = vi.fn(); + + renderHook(() => + useAutoCaptionGeneration({ + sourcePath: "/recordings/chinese.webm", + enabled: true, + language: "zh", + onCaptions, + onStatusMessage, + }), + ); + + await waitFor(() => expect(onStatusMessage).toHaveBeenCalledTimes(1)); + expect(window.electronAPI.startCaptionGeneration).toHaveBeenCalledWith( + "/recordings/chinese.webm", + expect.objectContaining({ language: "zh" }), + ); + }); + + it("cancels the active job and ignores stale results on unmount", async () => { + const pending = deferred(); + vi.mocked(window.electronAPI.startCaptionGeneration).mockReturnValue(pending.promise); + const onCaptions = vi.fn(); + const onStatusMessage = vi.fn(); + + const { unmount } = renderHook(() => + useAutoCaptionGeneration({ + sourcePath: "/recordings/stale.webm", + enabled: true, + onCaptions, + onStatusMessage, + }), + ); + + unmount(); + await act(async () => { + pending.resolve({ + jobId: "caption-job", + status: "success", + segments: [{ id: "caption-1", startMs: 0, endMs: 1000, text: "Late" }], + }); + await pending.promise; + }); + + expect(window.electronAPI.cancelCaptionGeneration).toHaveBeenCalledTimes(1); + expect(onCaptions).not.toHaveBeenCalled(); + expect(onStatusMessage).not.toHaveBeenCalled(); + }); + + it("keeps the active job running when callback references change", async () => { + const pending = deferred(); + vi.mocked(window.electronAPI.startCaptionGeneration).mockReturnValue(pending.promise); + const firstOnCaptions = vi.fn(); + const firstOnStatusMessage = vi.fn(); + const secondOnCaptions = vi.fn(); + const secondOnStatusMessage = vi.fn(); + + const { rerender } = renderHook( + ({ + onCaptions, + onStatusMessage, + }: { + onCaptions: Parameters[0]["onCaptions"]; + onStatusMessage: Parameters[0]["onStatusMessage"]; + }) => + useAutoCaptionGeneration({ + sourcePath: "/recordings/changing-callbacks.webm", + enabled: true, + onCaptions, + onStatusMessage, + }), + { + initialProps: { + onCaptions: firstOnCaptions, + onStatusMessage: firstOnStatusMessage, + }, + }, + ); + + rerender({ + onCaptions: secondOnCaptions, + onStatusMessage: secondOnStatusMessage, + }); + + const result: CaptionGenerationResult = { + jobId: "caption-job", + status: "success", + segments: [{ id: "caption-1", startMs: 0, endMs: 1000, text: "Latest" }], + }; + await act(async () => { + pending.resolve(result); + await pending.promise; + }); + + expect(window.electronAPI.startCaptionGeneration).toHaveBeenCalledTimes(1); + expect(window.electronAPI.cancelCaptionGeneration).not.toHaveBeenCalled(); + expect(firstOnCaptions).not.toHaveBeenCalled(); + expect(firstOnStatusMessage).not.toHaveBeenCalled(); + expect(secondOnCaptions).toHaveBeenCalledWith(result.segments); + expect(secondOnStatusMessage).toHaveBeenCalledWith(result); + }); + + it("restarts the job after React StrictMode replays the mount effect", async () => { + const firstPending = deferred(); + const secondPending = deferred(); + vi.mocked(window.electronAPI.startCaptionGeneration) + .mockReturnValueOnce(firstPending.promise) + .mockReturnValueOnce(secondPending.promise); + const onCaptions = vi.fn(); + const onStatusMessage = vi.fn(); + + renderHook( + () => + useAutoCaptionGeneration({ + sourcePath: "/recordings/strict-mode.webm", + enabled: true, + onCaptions, + onStatusMessage, + }), + { wrapper: strictModeWrapper }, + ); + + await waitFor(() => expect(window.electronAPI.startCaptionGeneration).toHaveBeenCalledTimes(2)); + + const staleResult: CaptionGenerationResult = { + jobId: "stale-caption-job", + status: "success", + segments: [{ id: "caption-1", startMs: 0, endMs: 1000, text: "Stale" }], + }; + const activeResult: CaptionGenerationResult = { + jobId: "active-caption-job", + status: "success", + segments: [{ id: "caption-2", startMs: 1000, endMs: 2000, text: "Active" }], + }; + await act(async () => { + firstPending.resolve(staleResult); + secondPending.resolve(activeResult); + await firstPending.promise; + await secondPending.promise; + }); + + expect(window.electronAPI.cancelCaptionGeneration).toHaveBeenCalledTimes(1); + expect(onCaptions).toHaveBeenCalledTimes(1); + expect(onCaptions).toHaveBeenCalledWith(activeResult.segments); + expect(onStatusMessage).toHaveBeenCalledTimes(1); + expect(onStatusMessage).toHaveBeenCalledWith(activeResult); + }); +}); diff --git a/src/components/video-editor/useAutoCaptionGeneration.ts b/src/components/video-editor/useAutoCaptionGeneration.ts new file mode 100644 index 0000000000..7ad28bfec5 --- /dev/null +++ b/src/components/video-editor/useAutoCaptionGeneration.ts @@ -0,0 +1,92 @@ +import { useEffect, useRef, useState } from "react"; +import type { CaptionGenerationResult, CaptionSegment } from "@/lib/captions"; + +export type AutoCaptionStatus = + | "idle" + | "running" + | "success" + | "skipped" + | "unavailable" + | "error"; + +type UseAutoCaptionGenerationOptions = { + sourcePath: string | null; + enabled: boolean; + language?: string; + onCaptions: (segments: CaptionSegment[]) => void; + onStatusMessage: (result: CaptionGenerationResult) => void; +}; + +export function useAutoCaptionGeneration({ + sourcePath, + enabled, + language, + onCaptions, + onStatusMessage, +}: UseAutoCaptionGenerationOptions) { + const startedSourcesRef = useRef(new Set()); + const onCaptionsRef = useRef(onCaptions); + const onStatusMessageRef = useRef(onStatusMessage); + const [status, setStatus] = useState("idle"); + + onCaptionsRef.current = onCaptions; + onStatusMessageRef.current = onStatusMessage; + + useEffect(() => { + const startKey = sourcePath ? `${sourcePath}\n${language ?? ""}` : null; + if (!enabled || !sourcePath || !startKey || startedSourcesRef.current.has(startKey)) { + return; + } + + startedSourcesRef.current.add(startKey); + const jobId = `caption-${Date.now()}`; + let cancelled = false; + let settled = false; + setStatus("running"); + + void window.electronAPI + .startCaptionGeneration(sourcePath, { jobId, ...(language ? { language } : {}) }) + .then((result) => { + if (cancelled) return; + settled = true; + + if (result.status === "success" && result.segments.length > 0) { + onCaptionsRef.current(result.segments); + setStatus("success"); + } else if (result.status === "skipped") { + setStatus("skipped"); + } else if (result.status === "unavailable") { + setStatus("unavailable"); + } else if (result.status === "cancelled") { + setStatus("idle"); + } else { + setStatus("error"); + } + + onStatusMessageRef.current(result); + }) + .catch((error) => { + if (cancelled) return; + settled = true; + + setStatus("error"); + onStatusMessageRef.current({ + jobId, + status: "error", + segments: [], + message: "Failed to generate captions", + error: String(error), + }); + }); + + return () => { + cancelled = true; + if (!settled) { + startedSourcesRef.current.delete(startKey); + } + void window.electronAPI.cancelCaptionGeneration(jobId); + }; + }, [enabled, sourcePath, language]); + + return status; +} diff --git a/src/hooks/useScreenRecorder.test.tsx b/src/hooks/useScreenRecorder.test.tsx new file mode 100644 index 0000000000..dc792a35f9 --- /dev/null +++ b/src/hooks/useScreenRecorder.test.tsx @@ -0,0 +1,419 @@ +import { act, cleanup, renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { I18nProvider } from "@/contexts/I18nContext"; +import { useScreenRecorder } from "./useScreenRecorder"; + +vi.mock("@fix-webm-duration/fix", () => ({ + fixWebmDuration: vi.fn(async (blob: Blob) => blob), +})); + +type ElectronAPI = Window["electronAPI"]; + +const countdownValuesMs = [1000, 1000, 1000]; +const flushPromises = () => Promise.resolve(); + +class FakeMediaStreamTrack extends EventTarget { + kind: string; + enabled = true; + readyState: "live" | "ended" = "live"; + onended: (() => void) | null = null; + + constructor(kind: string) { + super(); + this.kind = kind; + } + + stop(): void { + if (this.readyState === "ended") return; + this.readyState = "ended"; + } + + end(): void { + if (this.readyState === "ended") return; + this.readyState = "ended"; + this.onended?.(); + this.dispatchEvent(new Event("ended")); + } + + getSettings(): MediaTrackSettings { + return { width: 1920, height: 1080, frameRate: 60 }; + } + + applyConstraints(): Promise { + return Promise.resolve(); + } +} + +class FakeMediaStream { + private tracks: FakeMediaStreamTrack[]; + + constructor(tracks: FakeMediaStreamTrack[] = []) { + this.tracks = [...tracks]; + } + + addTrack(track: FakeMediaStreamTrack): void { + this.tracks.push(track); + } + + getTracks(): FakeMediaStreamTrack[] { + return [...this.tracks]; + } + + getVideoTracks(): FakeMediaStreamTrack[] { + return this.tracks.filter((track) => track.kind === "video"); + } + + getAudioTracks(): FakeMediaStreamTrack[] { + return this.tracks.filter((track) => track.kind === "audio"); + } +} + +class FakeMediaRecorder extends EventTarget { + static instances: FakeMediaRecorder[] = []; + static isTypeSupported = vi.fn(() => true); + + ondataavailable: ((event: BlobEvent) => void) | null = null; + onstop: (() => void) | null = null; + onerror: (() => void) | null = null; + state: "inactive" | "recording" | "paused" = "inactive"; + + constructor() { + super(); + FakeMediaRecorder.instances.push(this); + } + + start(): void { + this.state = "recording"; + } + + stop(): void { + if (this.state === "inactive") return; + this.ondataavailable?.({ + data: new Blob(["screen-data"], { type: "video/webm" }), + } as BlobEvent); + this.state = "inactive"; + this.onstop?.(); + this.dispatchEvent(new Event("stop")); + } + + pause(): void { + this.state = "paused"; + } + + resume(): void { + this.state = "recording"; + } +} + +function wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +function stubElectronAPI(api: Partial): void { + window.electronAPI = api as unknown as ElectronAPI; +} + +describe("useScreenRecorder", () => { + let screenTrack: FakeMediaStreamTrack; + let screenStream: FakeMediaStream; + let storeRecordedSession: ReturnType; + let switchToEditor: ReturnType; + let showRecordingAnnotationOverlay: ReturnType; + let hideRecordingAnnotationOverlay: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + FakeMediaRecorder.instances = []; + screenTrack = new FakeMediaStreamTrack("video"); + screenStream = new FakeMediaStream([screenTrack]); + storeRecordedSession = vi.fn(async () => ({ + success: true, + path: "/tmp/recording.webm", + session: { + screenVideoPath: "/tmp/recording.webm", + createdAt: 1, + cursorCaptureMode: "editable-overlay", + }, + })); + switchToEditor = vi.fn(async () => undefined); + showRecordingAnnotationOverlay = vi.fn(async () => undefined); + hideRecordingAnnotationOverlay = vi.fn(async () => undefined); + + vi.stubGlobal("MediaRecorder", FakeMediaRecorder); + vi.stubGlobal("MediaStream", FakeMediaStream); + Object.defineProperty(navigator, "mediaDevices", { + value: { + getDisplayMedia: vi.fn(async () => screenStream), + getUserMedia: vi.fn(async () => screenStream), + }, + configurable: true, + }); + stubElectronAPI({ + getSelectedSource: vi.fn(async () => ({ + id: "screen:1:0", + name: "Entire Screen", + display_id: "1", + thumbnail: null, + appIcon: null, + })), + getPlatform: vi.fn(async () => "linux"), + showCountdownOverlay: vi.fn(async () => undefined), + setCountdownOverlayValue: vi.fn(async () => undefined), + hideCountdownOverlay: vi.fn(async () => undefined), + showRecordingAnnotationOverlay, + hideRecordingAnnotationOverlay, + setRecordingState: vi.fn(async () => undefined), + openRecordingStream: vi.fn(async () => ({ success: false })), + appendRecordingChunk: vi.fn(async () => ({ success: true })), + storeRecordedSession, + setCurrentRecordingSession: vi.fn(async () => undefined), + setCurrentVideoPath: vi.fn(async () => undefined), + switchToEditor, + onStopRecordingFromTray: vi.fn(() => () => undefined), + setLocale: vi.fn(async () => undefined), + }); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + window.electronAPI = undefined as unknown as ElectronAPI; + }); + + it("opens the editor when a full-screen display track ends", async () => { + const { result } = renderHook(() => useScreenRecorder(), { wrapper }); + + await act(async () => { + result.current.toggleRecording(); + for (const ms of countdownValuesMs) { + await vi.advanceTimersByTimeAsync(ms); + } + await flushPromises(); + }); + + expect(result.current.recording).toBe(true); + + await act(async () => { + screenTrack.end(); + await flushPromises(); + }); + + expect(storeRecordedSession).toHaveBeenCalled(); + expect(switchToEditor).toHaveBeenCalled(); + }); + + it("shows the recording annotation overlay while recording and hides it after finalize", async () => { + const { result } = renderHook(() => useScreenRecorder(), { wrapper }); + + await act(async () => { + result.current.toggleRecording(); + for (const ms of countdownValuesMs) { + await vi.advanceTimersByTimeAsync(ms); + } + await flushPromises(); + }); + + expect(result.current.recording).toBe(true); + expect(showRecordingAnnotationOverlay).toHaveBeenCalledTimes(1); + expect(hideRecordingAnnotationOverlay).not.toHaveBeenCalled(); + + await act(async () => { + screenTrack.end(); + await flushPromises(); + }); + + expect(hideRecordingAnnotationOverlay).toHaveBeenCalled(); + }); + + it("opens the editor when a native macOS full-screen recording stops externally", async () => { + let nativeStoppedHandler: (() => void) | undefined; + const stopNativeMacRecording = vi.fn(async () => ({ + success: true, + path: "/tmp/native-recording.mp4", + session: { + screenVideoPath: "/tmp/native-recording.mp4", + createdAt: 2, + cursorCaptureMode: "editable-overlay", + }, + })); + + stubElectronAPI({ + ...window.electronAPI, + getPlatform: vi.fn(async () => "darwin"), + requestNativeMacCursorAccess: vi.fn(async () => ({ + success: true, + granted: true, + status: "granted", + })), + isNativeMacCaptureAvailable: vi.fn(async () => ({ + success: true, + available: true, + helperPath: "/tmp/helper", + })), + startNativeMacRecording: vi.fn(async () => ({ + success: true, + recordingId: 2, + path: "/tmp/native-recording.mp4", + helperPath: "/tmp/helper", + })), + stopNativeMacRecording, + onNativeRecordingStopped: vi.fn((callback: () => void) => { + nativeStoppedHandler = callback; + return () => undefined; + }), + } as Partial & { + onNativeRecordingStopped: (callback: () => void) => () => void; + }); + + const { result } = renderHook(() => useScreenRecorder(), { wrapper }); + + await act(async () => { + result.current.toggleRecording(); + for (const ms of countdownValuesMs) { + await vi.advanceTimersByTimeAsync(ms); + } + await flushPromises(); + }); + + expect(result.current.recording).toBe(true); + expect(nativeStoppedHandler).toBeDefined(); + + await act(async () => { + nativeStoppedHandler?.(); + await flushPromises(); + }); + + expect(stopNativeMacRecording).toHaveBeenCalledWith(false); + expect(window.electronAPI.setCurrentRecordingSession).toHaveBeenCalledWith({ + screenVideoPath: "/tmp/native-recording.mp4", + createdAt: 2, + cursorCaptureMode: "editable-overlay", + }); + expect(switchToEditor).toHaveBeenCalled(); + }); + + it("falls back to browser recording when native macOS capture lacks screen permission", async () => { + const startNativeMacRecording = vi.fn(async () => ({ + success: false, + error: "Screen recording permission is required for ScreenCaptureKit capture.", + })); + + stubElectronAPI({ + ...window.electronAPI, + getPlatform: vi.fn(async () => "darwin"), + requestNativeMacCursorAccess: vi.fn(async () => ({ + success: true, + granted: true, + status: "granted", + })), + isNativeMacCaptureAvailable: vi.fn(async () => ({ + success: true, + available: true, + helperPath: "/tmp/helper", + })), + startNativeMacRecording, + stopNativeMacRecording: vi.fn(async () => ({ + success: true, + discarded: true, + })), + }); + + const { result } = renderHook(() => useScreenRecorder(), { wrapper }); + + await act(async () => { + result.current.toggleRecording(); + for (const ms of countdownValuesMs) { + await vi.advanceTimersByTimeAsync(ms); + } + await flushPromises(); + }); + + expect(startNativeMacRecording).toHaveBeenCalled(); + expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalled(); + expect(result.current.recording).toBe(true); + + await act(async () => { + screenTrack.end(); + await flushPromises(); + }); + + expect(storeRecordedSession).toHaveBeenCalled(); + expect(switchToEditor).toHaveBeenCalled(); + }); + + it("uses browser recording on macOS when microphone recording is enabled", async () => { + const micStream = new FakeMediaStream([new FakeMediaStreamTrack("audio")]); + const getUserMedia = vi.fn(async (constraints: MediaStreamConstraints) => { + if (constraints.video === false) { + return micStream; + } + return screenStream; + }); + Object.defineProperty(navigator, "mediaDevices", { + value: { + getDisplayMedia: vi.fn(async () => screenStream), + getUserMedia, + }, + configurable: true, + }); + const startNativeMacRecording = vi.fn(async () => ({ + success: true, + recordingId: 3, + path: "/tmp/native-recording.mp4", + helperPath: "/tmp/helper", + })); + + stubElectronAPI({ + ...window.electronAPI, + getPlatform: vi.fn(async () => "darwin"), + requestNativeMacCursorAccess: vi.fn(async () => ({ + success: true, + granted: true, + status: "granted", + })), + isNativeMacCaptureAvailable: vi.fn(async () => ({ + success: true, + available: true, + helperPath: "/tmp/helper", + })), + startNativeMacRecording, + stopNativeMacRecording: vi.fn(async () => ({ + success: true, + discarded: true, + })), + }); + + const { result } = renderHook(() => useScreenRecorder(), { wrapper }); + + await act(async () => { + result.current.setMicrophoneEnabled(true); + }); + expect(result.current.microphoneEnabled).toBe(true); + + await act(async () => { + result.current.toggleRecording(); + for (const ms of countdownValuesMs) { + await vi.advanceTimersByTimeAsync(ms); + } + await flushPromises(); + }); + + expect(startNativeMacRecording).not.toHaveBeenCalled(); + expect(getUserMedia).toHaveBeenCalledWith( + expect.objectContaining({ + video: false, + }), + ); + expect(result.current.recording).toBe(true); + + await act(async () => { + screenTrack.end(); + await flushPromises(); + }); + + expect(storeRecordedSession).toHaveBeenCalled(); + expect(switchToEditor).toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index ce0e0b77f4..cc6adf92f5 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -46,6 +46,8 @@ const AUDIO_BITRATE_SYSTEM = 192_000; const MIC_GAIN_BOOST = 1.4; const WEBCAM_TARGET_FRAME_RATE = 30; +const SCREEN_CAPTUREKIT_PERMISSION_ERROR = + "Screen recording permission is required for ScreenCaptureKit capture"; type UseScreenRecorderReturn = { recording: boolean; @@ -87,6 +89,13 @@ type NativeMacRecordingHandle = { paused: boolean; }; +function isRecoverableNativeMacScreenPermissionError(message: string) { + return ( + message.includes(SCREEN_CAPTUREKIT_PERMISSION_ERROR) || + message.includes("screen-permission-denied") + ); +} + export function useScreenRecorder(): UseScreenRecorderReturn { const t = useScopedT("editor"); const [recording, setRecording] = useState(false); @@ -104,6 +113,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const webcamRecorder = useRef(null); const nativeWindowsRecording = useRef(null); const nativeMacRecording = useRef(null); + const cleanupScreenTrackEndedListener = useRef<(() => void) | null>(null); const stream = useRef(null); const screenStream = useRef(null); const microphoneStream = useRef(null); @@ -134,6 +144,22 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return accumulatedDurationMs.current + segmentDuration; }, []); + const safeShowRecordingAnnotationOverlay = useCallback(async () => { + try { + await window.electronAPI?.showRecordingAnnotationOverlay?.(); + } catch (error) { + console.warn("Failed to show recording annotation overlay:", error); + } + }, []); + + const safeHideRecordingAnnotationOverlay = useCallback(async () => { + try { + await window.electronAPI?.hideRecordingAnnotationOverlay?.(); + } catch (error) { + console.warn("Failed to hide recording annotation overlay:", error); + } + }, []); + const selectMimeType = () => { // H.264 first: hardware-accelerated, so sharp real-time output. AV1/VP9 are // better for distribution but too CPU-heavy for live 60 fps capture (software @@ -166,6 +192,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn { }; const teardownMedia = useCallback(() => { + cleanupScreenTrackEndedListener.current?.(); + cleanupScreenTrackEndedListener.current = null; if (stream.current) { stream.current.getTracks().forEach((track) => track.stop()); stream.current = null; @@ -334,6 +362,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { let webcamIncludedInSave = false; try { const screenBlob = await activeScreenRecorder.recordedBlobPromise; + await safeHideRecordingAnnotationOverlay(); if (discardRecordingId.current === activeRecordingId) { window.electronAPI?.discardCursorTelemetry(activeRecordingId); return; @@ -397,6 +426,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } catch (error) { console.error("Error saving recording:", error); } finally { + await safeHideRecordingAnnotationOverlay(); // Discard any recorder whose data wasn't part of a successful save (discarded // run, failed save, or a webcam whose disk write failed while the screen still // saved) so no stream or partial file is left open or orphaned. @@ -415,7 +445,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } })(); }, - [cursorCaptureMode, teardownMedia], + [cursorCaptureMode, safeHideRecordingAnnotationOverlay, teardownMedia], ); const finalizeNativeWindowsRecording = useCallback( @@ -453,6 +483,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { try { const result = await window.electronAPI.stopNativeWindowsRecording(discard); + await safeHideRecordingAnnotationOverlay(); if (discard || result.discarded) { clearNativeRecordingState(); return true; @@ -510,12 +541,13 @@ export function useScreenRecorder(): UseScreenRecorderReturn { activeNativeRecording.finalizing = false; return true; } finally { + await safeHideRecordingAnnotationOverlay(); if (discardRecordingId.current === activeNativeRecording.recordingId) { discardRecordingId.current = null; } } }, - [cursorCaptureMode, getRecordingDurationMs], + [cursorCaptureMode, getRecordingDurationMs, safeHideRecordingAnnotationOverlay], ); const finalizeNativeMacRecording = useCallback( @@ -566,6 +598,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { try { const result = await window.electronAPI.stopNativeMacRecording(discard); + await safeHideRecordingAnnotationOverlay(); const webcamAsset = await webcamAssetPromise; if (discard || result.discarded) { clearNativeRecordingState(); @@ -610,12 +643,13 @@ export function useScreenRecorder(): UseScreenRecorderReturn { activeNativeRecording.finalizing = false; return true; } finally { + await safeHideRecordingAnnotationOverlay(); if (discardRecordingId.current === activeNativeRecording.recordingId) { discardRecordingId.current = null; } } }, - [cursorCaptureMode, getRecordingDurationMs], + [cursorCaptureMode, getRecordingDurationMs, safeHideRecordingAnnotationOverlay], ); const stopRecording = useRef(() => { @@ -677,19 +711,28 @@ export function useScreenRecorder(): UseScreenRecorderReturn { }, []); useEffect(() => { - let cleanup: (() => void) | undefined; + let cleanupStopFromTray: (() => void) | undefined; + let cleanupNativeRecordingStopped: (() => void) | undefined; if (window.electronAPI?.onStopRecordingFromTray) { - cleanup = window.electronAPI.onStopRecordingFromTray(() => { + cleanupStopFromTray = window.electronAPI.onStopRecordingFromTray(() => { + stopRecording.current(); + }); + } + + if (window.electronAPI?.onNativeRecordingStopped) { + cleanupNativeRecordingStopped = window.electronAPI.onNativeRecordingStopped(() => { stopRecording.current(); }); } return () => { const activeRunId = countdownRunId.current; - if (cleanup) cleanup(); + if (cleanupStopFromTray) cleanupStopFromTray(); + if (cleanupNativeRecordingStopped) cleanupNativeRecordingStopped(); countdownRunId.current += 1; void safeHideCountdownOverlay(activeRunId); + void safeHideRecordingAnnotationOverlay(); allowAutoFinalize.current = false; restarting.current = false; discardRecordingId.current = null; @@ -727,6 +770,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { }, [ teardownMedia, safeHideCountdownOverlay, + safeHideRecordingAnnotationOverlay, finalizeNativeWindowsRecording, finalizeNativeMacRecording, ]); @@ -903,6 +947,14 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return false; } + // The current ScreenCaptureKit helper can write microphone samples as a + // separate track, but it does not yet mix them into the primary AAC track. + // Use the browser path for microphone recordings so the mic is captured + // into the MediaRecorder audio track reliably. + if (microphoneEnabled) { + return false; + } + const availability = await window.electronAPI.isNativeMacCaptureAvailable(); if (!availability.success || !availability.available) { if (availability.reason === "unsupported-platform") { @@ -1007,7 +1059,15 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (nativeWebcamRecorder && nativeWebcamRecorder.recorder.state !== "inactive") { nativeWebcamRecorder.recorder.stop(); } - throw new Error(result.error ?? "Native macOS capture failed."); + const nativeError = result.error ?? "Native macOS capture failed."; + if (isRecoverableNativeMacScreenPermissionError(nativeError)) { + console.warn( + "Native macOS ScreenCaptureKit permission is unavailable; falling back to browser recording.", + nativeError, + ); + return false; + } + throw new Error(nativeError); } if (!isCountdownRunActive(countdownRunToken)) { if (nativeWebcamRecorder && nativeWebcamRecorder.recorder.state !== "inactive") { @@ -1144,10 +1204,24 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return; } + await safeShowRecordingAnnotationOverlay(); + + if (!isCountdownRunActive(countdownRunToken)) { + await safeHideRecordingAnnotationOverlay(); + teardownMedia(); + return; + } + if (await startNativeWindowsRecordingIfAvailable(selectedSource, countdownRunToken)) { + if (!isCountdownRunActive(countdownRunToken)) { + await safeHideRecordingAnnotationOverlay(); + } return; } if (await startNativeMacRecordingIfAvailable(selectedSource, countdownRunToken)) { + if (!isCountdownRunActive(countdownRunToken)) { + await safeHideRecordingAnnotationOverlay(); + } return; } @@ -1272,6 +1346,17 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (!videoTrack) { throw new Error("Video track is not available."); } + const handleScreenTrackEnded = () => { + if (!allowAutoFinalize.current) { + return; + } + stopRecording.current(); + }; + cleanupScreenTrackEndedListener.current?.(); + videoTrack.addEventListener("ended", handleScreenTrackEnded, { once: true }); + cleanupScreenTrackEndedListener.current = () => { + videoTrack.removeEventListener("ended", handleScreenTrackEnded); + }; stream.current.addTrack(videoTrack); const systemAudioTrack = screenMediaStream.getAudioTracks()[0]; @@ -1407,6 +1492,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { segmentStartedAt.current = null; screenRecorder.current = null; webcamRecorder.current = null; + await safeHideRecordingAnnotationOverlay(); teardownMedia(); } }; diff --git a/src/i18n/locales/ar/dialogs.json b/src/i18n/locales/ar/dialogs.json index 568c542cff..7e091a6d9d 100644 --- a/src/i18n/locales/ar/dialogs.json +++ b/src/i18n/locales/ar/dialogs.json @@ -57,6 +57,8 @@ "detailLoadProject": "هل تريد حفظ مشروعك قبل تحميل مشروع آخر؟", "saveAndLoadProject": "حفظ وتحميل مشروع", "discardAndLoadProject": "تجاهل وتحميل مشروع", + "newProject": "New Project", + "importVideo": "Import Video File…", "loadProject": "تحميل مشروع...", "saveProject": "حفظ المشروع...", "saveProjectAs": "حفظ المشروع باسم..." diff --git a/src/i18n/locales/ar/editor.json b/src/i18n/locales/ar/editor.json index 39750e5eb8..306c474c02 100644 --- a/src/i18n/locales/ar/editor.json +++ b/src/i18n/locales/ar/editor.json @@ -44,24 +44,12 @@ "permissionDenied": "تم رفض إذن التسجيل. يرجى السماح بتسجيل الشاشة.", "accessibilityAllowAndRetry": "اسمح بوصول تسهيلات الاستخدام لـ OpenScreen، ثم اضغط على التسجيل مرة أخرى لبدء العد التنازلي." }, - "autoCaptions": { - "button": "التسميات التوضيحية التلقائية", - "dialogTitle": "التسميات التوضيحية التلقائية", - "dialogDescription": "اختر تقريبا كم عدد الكلمات التي تظهر في كل تسمية توضيحية. يتم توزيع التوقيت عبر الكلمات في تلك العبارة.", - "minWords": "الحد الأدنى من الكلمات لكل تسمية", - "maxWords": "الحد الأقصى من الكلمات لكل تسمية", - "wordsCount": "{{count}} كلمة", - "generate": "توليد", - "dialogCancel": "إلغاء", - "generating": "جارٍ توليد التسميات من الصوت…", - "loadingModel": "جارٍ تحميل نموذج الكلام (سيتم تنزيل ~75 ميغابايت عند الاستخدام الأول)…", - "transcribing": "جارٍ نسخ الكلام إلى نص…", - "busy": "توليد التسميات قيد التنفيذ بالفعل.", - "done": "تمت إضافة {{count}} تسمية.", - "noneHeard": "لم يتم الكشف عن أي كلام.", - "noAudio": "لا يحتوي هذا الفيديو على صوت صالح للنسخ.", - "failed": "تعذّر توليد التسميات.", - "truncated": "تم نسخ الدقائق الأولى فقط: {{minutes}} دقيقة." + "captions": { + "generating": "جارٍ إنشاء الترجمات...", + "generated": "تم إنشاء {{count}} ترجمة.", + "skippedNoAudio": "لم يتم العثور على مسار صوتي؛ تم تخطي الترجمات.", + "unavailable": "نموذج الترجمات المحلي غير متاح.", + "failed": "فشل إنشاء الترجمات." }, "emptyState": { "title": "لا يوجد مشروع مفتوح", diff --git a/src/i18n/locales/ar/launch.json b/src/i18n/locales/ar/launch.json index ef000a8758..86fac6600a 100644 --- a/src/i18n/locales/ar/launch.json +++ b/src/i18n/locales/ar/launch.json @@ -9,7 +9,8 @@ "openVideoFile": "فتح ملف فيديو", "openProject": "فتح مشروع", "useVerticalTray": "استخدام الشريط العمودي", - "useHorizontalTray": "استخدام الشريط الأفقي" + "useHorizontalTray": "استخدام الشريط الأفقي", + "openStudio": "Open Studio" }, "audio": { "enableSystemAudio": "تفعيل صوت النظام", @@ -26,10 +27,17 @@ "noneFound": "لم يتم العثور على كاميرا", "unavailable": "الكاميرا غير متوفرة" }, + "cursor": { + "useEditableCursor": "استخدام مؤشر قابل للتحرير", + "useSystemCursor": "استخدام مؤشر النظام" + }, "sourceSelector": { "loading": "جاري تحميل المصادر...", "screens": "الشاشات ({{count}})", "windows": "النوافذ ({{count}})", + "emptyTitle": "No screens or windows found", + "emptyDescription": "If you just granted screen recording permission, reload this picker. On macOS you may need to reopen OpenScreen.", + "loadFailedDescription": "OpenScreen could not load capture sources. Reload this picker and try again.", "defaultSourceName": "الشاشة" }, "recording": { @@ -41,9 +49,5 @@ "description": "اكتشفنا أن {{language}} هي لغة نظامك. هل تريد تبديل OpenScreen إلى {{language}}؟", "switch": "التبديل إلى {{language}}", "keepDefault": "الاحتفاظ باللغة الحالية" - }, - "cursor": { - "useEditableCursor": "استخدام مؤشر قابل للتحرير", - "useSystemCursor": "استخدام مؤشر النظام" } } diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json index c91928890f..c7b444e279 100644 --- a/src/i18n/locales/ar/settings.json +++ b/src/i18n/locales/ar/settings.json @@ -2,20 +2,15 @@ "zoom": { "previewHold": "اضغط مع الاستمرار لمعاينة تأثير التكبير", "level": "مستوى التكبير", + "customScale": "تكبير مخصص", "selectRegion": "حدد منطقة التكبير للتعديل", "deleteZoom": "حذف التكبير", "focusMode": { "title": "وضع التركيز", "manual": "يدوي", "auto": "تلقائي", - "autoDescription": "الكاميرا تتبع موضع المؤشر المسجل" - }, - "customScale": "تكبير مخصص", - "position": { - "title": "موضع التركيز", - "x": "X (%)", - "y": "Y (%)", - "hint": "0 = أقصى اليسار / الأعلى، 100 = أقصى اليمين / الأسفل" + "autoDescription": "الكاميرا تتبع موضع المؤشر المسجل", + "lockedDisclaimer": "Controlled by the global Auto-Focus toggle in the timeline. Turn it off to set focus mode per zoom." }, "threeD": { "title": "دوران ثلاثي الأبعاد", @@ -24,6 +19,12 @@ "left": "يسار", "right": "يمين" } + }, + "position": { + "title": "موضع التركيز", + "x": "X (%)", + "y": "Y (%)", + "hint": "0 = أقصى اليسار / الأعلى، 100 = أقصى اليمين / الأسفل" } }, "speed": { @@ -43,9 +44,9 @@ "pictureInPicture": "صورة داخل صورة", "verticalStack": "تكدس عمودي", "dualFrame": "إطار مزدوج", + "noWebcam": "بدون كاميرا", "webcamShape": "شكل الكاميرا", "webcamSize": "حجم كاميرا الويب", - "noWebcam": "بدون كاميرا", "mirrorWebcam": "عكس كاميرا الويب", "reactiveWebcam": "تصغير عند التكبير", "reactiveWebcamDescription": "تتقلص الكاميرا بسلاسة أثناء تكبير الفيديو حتى لا تعيق الرؤية." @@ -132,8 +133,6 @@ "textPlaceholder": "أدخل النص هنا...", "fontStyle": "نمط الخط", "selectStyle": "حدد النمط", - "textAnimation": "تحريك النص", - "selectAnimation": "حدد الحركة", "size": "الحجم", "customFonts": "خطوط مخصصة", "textColor": "لون النص", @@ -145,6 +144,8 @@ "clearBackground": "مسح الخلفية", "uploadImage": "رفع صورة", "supportedFormats": "الصيغ المدعومة: JPG, PNG, GIF, WebP", + "stickerPresets": "ملصقات جاهزة", + "stickerPresetsDescription": "انقر على ملصق لاستخدامه كتعليق صورة.", "arrowDirection": "اتجاه السهم", "strokeWidth": "عرض الخط: {{width}}px", "arrowColor": "لون السهم", @@ -180,6 +181,17 @@ "modern": "حديث", "clean": "نظيف" }, + "textAnimation": { + "title": "Text Animation", + "selectAnimation": "Select animation", + "none": "None", + "fade": "Fade", + "rise": "Rise", + "pop": "Pop", + "slideLeft": "Slide Left", + "typewriter": "Typewriter", + "pulse": "Pulse" + }, "customFont": { "dialogTitle": "إضافة خط Google", "urlLabel": "رابط استيراد خطوط Google", @@ -207,7 +219,8 @@ "smoothing": "التنعيم", "motionBlur": "ضبابية الحركة", "clickBounce": "ارتداد النقر", - "clipToBounds": "القص ضمن اللوحة" + "clipToBounds": "القص ضمن اللوحة", + "clipToBoundsDescription": "Keeps the cursor inside the video frame. Turn off to let the cursor extend past the edges - useful when zoomed in or panned." }, "language": { "title": "اللغة" diff --git a/src/i18n/locales/ar/timeline.json b/src/i18n/locales/ar/timeline.json index 09d55c4afb..be07d7b87e 100644 --- a/src/i18n/locales/ar/timeline.json +++ b/src/i18n/locales/ar/timeline.json @@ -2,6 +2,10 @@ "buttons": { "addZoom": "إضافة تكبير (Z)", "suggestZooms": "اقتراح تكبير من المؤشر", + "autoZoomOn": "Auto zoom suggestions on — click to remove suggested zooms", + "autoZoomOff": "Auto zoom suggestions off — click to suggest zooms from cursor", + "autoFocusAllOn": "Auto-Focus on for all zooms — click to switch all to manual", + "autoFocusAllOff": "Auto-Focus all zooms (camera follows the cursor)", "addTrim": "إضافة قص (T)", "addAnnotation": "إضافة شرح (A)", "addBlur": "إضافة تمويه (B)", diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index d6a56f0336..0d95c4d304 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -44,24 +44,12 @@ "permissionDenied": "Recording permission denied. Please allow screen recording.", "accessibilityAllowAndRetry": "Allow Accessibility access for OpenScreen, then press record again to start the countdown." }, - "autoCaptions": { - "button": "Auto captions", - "dialogTitle": "Auto captions", - "dialogDescription": "Choose roughly how many words each caption shows at once. Timing is spread across the words in that phrase.", - "minWords": "Minimum words per caption", - "maxWords": "Maximum words per caption", - "wordsCount": "{{count}} words", - "generate": "Generate", - "dialogCancel": "Cancel", - "generating": "Generating captions from audio…", - "loadingModel": "Loading speech model (first use downloads ~75 MB)…", - "transcribing": "Transcribing speech…", - "busy": "Caption generation is already in progress.", - "done": "Added {{count}} captions.", - "noneHeard": "No speech was detected.", - "noAudio": "This video has no usable audio to transcribe.", - "failed": "Could not generate captions.", - "truncated": "Only the first {{minutes}} minutes were transcribed." + "captions": { + "generating": "Generating captions...", + "generated": "Generated {{count}} captions.", + "skippedNoAudio": "No audio track found; captions skipped.", + "unavailable": "Local caption model is not available.", + "failed": "Failed to generate captions." }, "emptyState": { "title": "No project open", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 3594ac51e8..0e1762c1b0 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -144,6 +144,8 @@ "clearBackground": "Clear Background", "uploadImage": "Upload Image", "supportedFormats": "Supported formats: JPG, PNG, GIF, WebP", + "stickerPresets": "Sticker presets", + "stickerPresetsDescription": "Click a sticker to use it as this image annotation.", "arrowDirection": "Arrow Direction", "strokeWidth": "Stroke Width: {{width}}px", "arrowColor": "Arrow Color", diff --git a/src/i18n/locales/es/dialogs.json b/src/i18n/locales/es/dialogs.json index 0b9090f143..fae7f5137c 100644 --- a/src/i18n/locales/es/dialogs.json +++ b/src/i18n/locales/es/dialogs.json @@ -57,6 +57,8 @@ "detailLoadProject": "¿Deseas guardar tu proyecto antes de cargar otro?", "saveAndLoadProject": "Guardar y cargar proyecto", "discardAndLoadProject": "Descartar y cargar proyecto", + "newProject": "New Project", + "importVideo": "Import Video File…", "loadProject": "Cargar proyecto…", "saveProject": "Guardar proyecto…", "saveProjectAs": "Guardar proyecto como…" diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json index 277ce40ff4..4a3d1849d5 100644 --- a/src/i18n/locales/es/editor.json +++ b/src/i18n/locales/es/editor.json @@ -1,4 +1,12 @@ { + "newRecording": { + "title": "Volver a la grabadora", + "description": "Tu sesión actual ha sido guardada.", + "cancel": "Cancelar", + "confirm": "Confirmar" + }, + "loadingVideo": "Cargando video...", + "loadingEditor": "Cargando editor...", "errors": { "noVideoLoaded": "No hay video cargado", "videoNotReady": "El video no está listo", @@ -36,32 +44,12 @@ "permissionDenied": "Permiso de grabación denegado. Por favor permite la grabación de pantalla.", "accessibilityAllowAndRetry": "Permite el acceso de accesibilidad para OpenScreen y luego pulsa grabar de nuevo para iniciar la cuenta atrás." }, - "loadingVideo": "Cargando video...", - "loadingEditor": "Cargando editor...", - "newRecording": { - "title": "Volver a la grabadora", - "description": "Tu sesión actual ha sido guardada.", - "cancel": "Cancelar", - "confirm": "Confirmar" - }, - "autoCaptions": { - "button": "Subtítulos automáticos", - "dialogTitle": "Subtítulos automáticos", - "dialogDescription": "Elige aproximadamente cuántas palabras muestra cada subtítulo a la vez. El tiempo se reparte entre las palabras de esa frase.", - "minWords": "Número mínimo de palabras por subtítulo", - "maxWords": "Número máximo de palabras por subtítulo", - "wordsCount": "{{count}} palabras", - "generate": "Generar", - "dialogCancel": "Cancelar", - "generating": "Generando subtítulos a partir del audio…", - "loadingModel": "Cargando el modelo de voz (el primer uso descarga ~75 MB)…", - "transcribing": "Transcribiendo el habla…", - "busy": "La generación de subtítulos ya está en curso.", - "done": "Se añadieron {{count}} subtítulos.", - "noneHeard": "No se detectó voz.", - "noAudio": "Este video no tiene audio utilizable para transcribir.", - "failed": "No se pudieron generar los subtítulos.", - "truncated": "Solo se transcribieron los primeros {{minutes}} minutos." + "captions": { + "generating": "Generando subtítulos...", + "generated": "{{count}} subtítulos generados.", + "skippedNoAudio": "No se encontró pista de audio; se omitieron los subtítulos.", + "unavailable": "El modelo local de subtítulos no está disponible.", + "failed": "Error al generar subtítulos." }, "emptyState": { "title": "No hay proyecto abierto", diff --git a/src/i18n/locales/es/launch.json b/src/i18n/locales/es/launch.json index bc6ba52a89..2f9b232fe9 100644 --- a/src/i18n/locales/es/launch.json +++ b/src/i18n/locales/es/launch.json @@ -9,7 +9,8 @@ "openVideoFile": "Abrir archivo de video", "openProject": "Abrir proyecto", "useVerticalTray": "Usar bandeja vertical", - "useHorizontalTray": "Usar bandeja horizontal" + "useHorizontalTray": "Usar bandeja horizontal", + "openStudio": "Open Studio" }, "audio": { "enableSystemAudio": "Activar audio del sistema", @@ -34,6 +35,9 @@ "loading": "Cargando fuentes...", "screens": "Pantallas ({{count}})", "windows": "Ventanas ({{count}})", + "emptyTitle": "No screens or windows found", + "emptyDescription": "If you just granted screen recording permission, reload this picker. On macOS you may need to reopen OpenScreen.", + "loadFailedDescription": "OpenScreen could not load capture sources. Reload this picker and try again.", "defaultSourceName": "Pantalla" }, "recording": { diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index fa046e7940..b2ddb37a08 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -2,13 +2,15 @@ "zoom": { "previewHold": "Mantener para previsualizar el efecto de zoom", "level": "Nivel de zoom", + "customScale": "Zoom personalizado", "selectRegion": "Selecciona una región de zoom para ajustar", "deleteZoom": "Eliminar zoom", "focusMode": { "title": "Modo de enfoque", "manual": "Manual", "auto": "Auto", - "autoDescription": "La cámara sigue la posición del cursor grabado" + "autoDescription": "La cámara sigue la posición del cursor grabado", + "lockedDisclaimer": "Controlled by the global Auto-Focus toggle in the timeline. Turn it off to set focus mode per zoom." }, "threeD": { "title": "Rotación 3D", @@ -18,7 +20,6 @@ "right": "Derecha" } }, - "customScale": "Zoom personalizado", "position": { "title": "Posición de enfoque", "x": "X (%)", @@ -43,9 +44,9 @@ "pictureInPicture": "Imagen en imagen", "verticalStack": "Apilado vertical", "dualFrame": "Marco dual", + "noWebcam": "Sin cámara", "webcamShape": "Forma de cámara", "webcamSize": "Tamaño de cámara", - "noWebcam": "Sin cámara", "mirrorWebcam": "Reflejar cámara", "reactiveWebcam": "Reducir al ampliar", "reactiveWebcamDescription": "La cámara se reduce suavemente mientras el vídeo está ampliado, para no estorbar." @@ -55,10 +56,10 @@ "blurBg": "Desenfocar fondo", "motionBlur": "Desenfoque de movimiento", "off": "desactivado", + "on": "activado", "shadow": "Sombra", "roundness": "Redondez", - "padding": "Relleno", - "on": "activado" + "padding": "Relleno" }, "background": { "title": "Fondo", @@ -143,6 +144,8 @@ "clearBackground": "Quitar fondo", "uploadImage": "Subir imagen", "supportedFormats": "Formatos compatibles: JPG, PNG, GIF, WebP", + "stickerPresets": "Stickers predefinidos", + "stickerPresetsDescription": "Haz clic en un sticker para usarlo como esta anotación de imagen.", "arrowDirection": "Dirección de la flecha", "strokeWidth": "Grosor del trazo: {{width}}px", "arrowColor": "Color de la flecha", @@ -216,7 +219,8 @@ "smoothing": "Suavizado", "motionBlur": "Desenfoque de movimiento", "clickBounce": "Rebote al clic", - "clipToBounds": "Recortar al lienzo" + "clipToBounds": "Recortar al lienzo", + "clipToBoundsDescription": "Keeps the cursor inside the video frame. Turn off to let the cursor extend past the edges - useful when zoomed in or panned." }, "language": { "title": "Idioma" diff --git a/src/i18n/locales/es/timeline.json b/src/i18n/locales/es/timeline.json index 1a1abe1b1a..9aa5582572 100644 --- a/src/i18n/locales/es/timeline.json +++ b/src/i18n/locales/es/timeline.json @@ -2,17 +2,21 @@ "buttons": { "addZoom": "Agregar zoom (Z)", "suggestZooms": "Sugerir zooms desde el cursor", + "autoZoomOn": "Auto zoom suggestions on — click to remove suggested zooms", + "autoZoomOff": "Auto zoom suggestions off — click to suggest zooms from cursor", + "autoFocusAllOn": "Auto-Focus on for all zooms — click to switch all to manual", + "autoFocusAllOff": "Auto-Focus all zooms (camera follows the cursor)", "addTrim": "Agregar recorte (T)", "addAnnotation": "Agregar anotación (A)", - "addSpeed": "Agregar velocidad (S)", - "addBlur": "Agregar desenfoque (B)" + "addBlur": "Agregar desenfoque (B)", + "addSpeed": "Agregar velocidad (S)" }, "hints": { "pressZoom": "Presiona Z para agregar zoom", "pressTrim": "Presiona T para agregar recorte", "pressAnnotation": "Presiona A para agregar anotación", - "pressSpeed": "Presiona S para agregar velocidad", - "pressBlur": "Presiona B para agregar una región de desenfoque" + "pressBlur": "Presiona B para agregar una región de desenfoque", + "pressSpeed": "Presiona S para agregar velocidad" }, "labels": { "pan": "Desplazar", @@ -23,9 +27,9 @@ "trimItem": "Recorte {{index}}", "speedItem": "Velocidad {{index}}", "annotationItem": "Anotación", + "blurItem": "Desenfoque {{index}}", "imageItem": "Imagen", - "emptyText": "Texto vacío", - "blurItem": "Desenfoque {{index}}" + "emptyText": "Texto vacío" }, "emptyState": { "noVideo": "No hay video cargado", diff --git a/src/i18n/locales/fr/dialogs.json b/src/i18n/locales/fr/dialogs.json index e67e186370..55e1bc80e4 100644 --- a/src/i18n/locales/fr/dialogs.json +++ b/src/i18n/locales/fr/dialogs.json @@ -57,6 +57,8 @@ "detailLoadProject": "Voulez-vous enregistrer votre projet avant d'en charger un autre ?", "saveAndLoadProject": "Enregistrer et charger un projet", "discardAndLoadProject": "Ignorer et charger un projet", + "newProject": "New Project", + "importVideo": "Import Video File…", "loadProject": "Charger un projet…", "saveProject": "Enregistrer le projet…", "saveProjectAs": "Enregistrer le projet sous…" diff --git a/src/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json index 40dc24fd77..ce500e9105 100644 --- a/src/i18n/locales/fr/editor.json +++ b/src/i18n/locales/fr/editor.json @@ -5,6 +5,8 @@ "cancel": "Annuler", "confirm": "Confirmer" }, + "loadingVideo": "Chargement de la vidéo...", + "loadingEditor": "Chargement de l'éditeur...", "errors": { "noVideoLoaded": "Aucune vidéo chargée", "videoNotReady": "Vidéo non prête", @@ -42,26 +44,12 @@ "permissionDenied": "Permission d'enregistrement refusée. Veuillez autoriser l'enregistrement d'écran.", "accessibilityAllowAndRetry": "Autorisez l'accès Accessibilité pour OpenScreen, puis appuyez de nouveau sur enregistrer pour lancer le compte à rebours." }, - "loadingVideo": "Chargement de la vidéo...", - "loadingEditor": "Chargement de l'éditeur...", - "autoCaptions": { - "button": "Sous-titres automatiques", - "dialogTitle": "Sous-titres automatiques", - "dialogDescription": "Choisissez approximativement combien de mots chaque sous-titre affiche à la fois. Le timing est réparti entre les mots de cette phrase.", - "minWords": "Nombre minimum de mots par sous-titre", - "maxWords": "Nombre maximum de mots par sous-titre", - "wordsCount": "{{count}} mots", - "generate": "Générer", - "dialogCancel": "Annuler", - "generating": "Génération des sous-titres à partir de l'audio…", - "loadingModel": "Chargement du modèle vocal (le premier usage télécharge ~75 MB)…", - "transcribing": "Transcription de la parole…", - "busy": "La génération des sous-titres est déjà en cours.", - "done": "{{count}} sous-titres ajoutés.", - "noneHeard": "Aucune parole n'a été détectée.", - "noAudio": "Cette vidéo ne contient pas d'audio exploitable pour la transcription.", - "failed": "Impossible de générer les sous-titres.", - "truncated": "Seules les {{minutes}} premières minutes ont été transcrites." + "captions": { + "generating": "Génération des sous-titres...", + "generated": "{{count}} sous-titres générés.", + "skippedNoAudio": "Aucune piste audio trouvée ; sous-titres ignorés.", + "unavailable": "Le modèle de sous-titres local n'est pas disponible.", + "failed": "Échec de la génération des sous-titres." }, "emptyState": { "title": "Aucun projet ouvert", diff --git a/src/i18n/locales/fr/launch.json b/src/i18n/locales/fr/launch.json index 0a89376c22..57332791c9 100644 --- a/src/i18n/locales/fr/launch.json +++ b/src/i18n/locales/fr/launch.json @@ -9,7 +9,8 @@ "openVideoFile": "Ouvrir un fichier vidéo", "openProject": "Ouvrir un projet", "useVerticalTray": "Utiliser la barre verticale", - "useHorizontalTray": "Utiliser la barre horizontale" + "useHorizontalTray": "Utiliser la barre horizontale", + "openStudio": "Open Studio" }, "audio": { "enableSystemAudio": "Activer l'audio système", @@ -34,6 +35,9 @@ "loading": "Chargement des sources...", "screens": "Écrans ({{count}})", "windows": "Fenêtres ({{count}})", + "emptyTitle": "No screens or windows found", + "emptyDescription": "If you just granted screen recording permission, reload this picker. On macOS you may need to reopen OpenScreen.", + "loadFailedDescription": "OpenScreen could not load capture sources. Reload this picker and try again.", "defaultSourceName": "Écran" }, "recording": { diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index 06d51e2da9..61839b8289 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -2,13 +2,15 @@ "zoom": { "previewHold": "Maintenir pour prévisualiser l'effet de zoom", "level": "Niveau de zoom", + "customScale": "Zoom personnalisé", "selectRegion": "Sélectionnez une région de zoom à ajuster", "deleteZoom": "Supprimer le zoom", "focusMode": { "title": "Mode focus", "manual": "Manuel", "auto": "Auto", - "autoDescription": "La caméra suit la position du curseur enregistré" + "autoDescription": "La caméra suit la position du curseur enregistré", + "lockedDisclaimer": "Controlled by the global Auto-Focus toggle in the timeline. Turn it off to set focus mode per zoom." }, "threeD": { "title": "Rotation 3D", @@ -18,7 +20,6 @@ "right": "Droite" } }, - "customScale": "Zoom personnalisé", "position": { "title": "Position du focus", "x": "X (%)", @@ -43,9 +44,9 @@ "pictureInPicture": "Incrustation d'image", "verticalStack": "Empilement vertical", "dualFrame": "Double cadre", + "noWebcam": "Sans webcam", "webcamShape": "Forme de la caméra", "webcamSize": "Taille de la caméra", - "noWebcam": "Sans webcam", "mirrorWebcam": "Inverser la webcam", "reactiveWebcam": "Réduire au zoom", "reactiveWebcamDescription": "La caméra rétrécit doucement pendant le zoom, pour ne pas gêner." @@ -55,10 +56,10 @@ "blurBg": "Flou arrière-plan", "motionBlur": "Flou de mouvement", "off": "désactivé", + "on": "activé", "shadow": "Ombre", "roundness": "Arrondi", - "padding": "Marge", - "on": "activé" + "padding": "Marge" }, "background": { "title": "Arrière-plan", @@ -143,6 +144,8 @@ "clearBackground": "Supprimer l'arrière-plan", "uploadImage": "Téléverser une image", "supportedFormats": "Formats supportés : JPG, PNG, GIF, WebP", + "stickerPresets": "Préréglages de stickers", + "stickerPresetsDescription": "Cliquez sur un sticker pour l'utiliser comme annotation d'image.", "arrowDirection": "Direction de la flèche", "strokeWidth": "Épaisseur du trait : {{width}}px", "arrowColor": "Couleur de la flèche", @@ -216,7 +219,8 @@ "smoothing": "Lissage", "motionBlur": "Flou de mouvement", "clickBounce": "Rebond au clic", - "clipToBounds": "Rogner au canevas" + "clipToBounds": "Rogner au canevas", + "clipToBoundsDescription": "Keeps the cursor inside the video frame. Turn off to let the cursor extend past the edges - useful when zoomed in or panned." }, "language": { "title": "Langue" diff --git a/src/i18n/locales/fr/timeline.json b/src/i18n/locales/fr/timeline.json index b4c6e16a95..fdd2caf11a 100644 --- a/src/i18n/locales/fr/timeline.json +++ b/src/i18n/locales/fr/timeline.json @@ -2,17 +2,21 @@ "buttons": { "addZoom": "Ajouter un zoom (Z)", "suggestZooms": "Suggérer des zooms depuis le curseur", + "autoZoomOn": "Auto zoom suggestions on — click to remove suggested zooms", + "autoZoomOff": "Auto zoom suggestions off — click to suggest zooms from cursor", + "autoFocusAllOn": "Auto-Focus on for all zooms — click to switch all to manual", + "autoFocusAllOff": "Auto-Focus all zooms (camera follows the cursor)", "addTrim": "Ajouter une coupe (T)", "addAnnotation": "Ajouter une annotation (A)", - "addSpeed": "Ajouter une vitesse (S)", - "addBlur": "Ajouter un flou (B)" + "addBlur": "Ajouter un flou (B)", + "addSpeed": "Ajouter une vitesse (S)" }, "hints": { "pressZoom": "Appuyez sur Z pour ajouter un zoom", "pressTrim": "Appuyez sur T pour ajouter une coupe", "pressAnnotation": "Appuyez sur A pour ajouter une annotation", - "pressSpeed": "Appuyez sur S pour ajouter une vitesse", - "pressBlur": "Appuyez sur B pour ajouter une zone de flou" + "pressBlur": "Appuyez sur B pour ajouter une zone de flou", + "pressSpeed": "Appuyez sur S pour ajouter une vitesse" }, "labels": { "pan": "Panoramique", @@ -23,9 +27,9 @@ "trimItem": "Coupe {{index}}", "speedItem": "Vitesse {{index}}", "annotationItem": "Annotation", + "blurItem": "Flou {{index}}", "imageItem": "Image", - "emptyText": "Texte vide", - "blurItem": "Flou {{index}}" + "emptyText": "Texte vide" }, "emptyState": { "noVideo": "Aucune vidéo chargée", @@ -33,20 +37,20 @@ }, "errors": { "cannotPlaceZoom": "Impossible de placer le zoom ici", - "zoomExistsAtLocation": "Un zoom existe déjà à cet emplacement ou l\u0027espace disponible est insuffisant.", + "zoomExistsAtLocation": "Un zoom existe déjà à cet emplacement ou l'espace disponible est insuffisant.", "zoomSuggestionUnavailable": "Gestionnaire de suggestions de zoom non disponible", "noCursorTelemetry": "Aucune télémétrie de curseur disponible", - "noCursorTelemetryDescription": "Enregistrez d\u0027abord un screencast pour générer des suggestions basées sur le curseur.", + "noCursorTelemetryDescription": "Enregistrez d'abord un screencast pour générer des suggestions basées sur le curseur.", "noUsableTelemetry": "Aucune télémétrie de curseur utilisable", - "noUsableTelemetryDescription": "L\u0027enregistrement ne contient pas suffisamment de données de mouvement du curseur.", + "noUsableTelemetryDescription": "L'enregistrement ne contient pas suffisamment de données de mouvement du curseur.", "noDwellMoments": "Aucun moment de pause du curseur trouvé", "noDwellMomentsDescription": "Essayez un enregistrement avec des pauses plus lentes du curseur sur les actions importantes.", "noAutoZoomSlots": "Aucun emplacement de zoom automatique disponible", "noAutoZoomSlotsDescription": "Les points de pause détectés chevauchent des régions de zoom existantes.", "cannotPlaceTrim": "Impossible de placer la coupe ici", - "trimExistsAtLocation": "Une coupe existe déjà à cet emplacement ou l\u0027espace disponible est insuffisant.", + "trimExistsAtLocation": "Une coupe existe déjà à cet emplacement ou l'espace disponible est insuffisant.", "cannotPlaceSpeed": "Impossible de placer la vitesse ici", - "speedExistsAtLocation": "Une région de vitesse existe déjà à cet emplacement ou l\u0027espace disponible est insuffisant." + "speedExistsAtLocation": "Une région de vitesse existe déjà à cet emplacement ou l'espace disponible est insuffisant." }, "success": { "addedZoomSuggestions": "{{count}} suggestion de zoom basée sur le curseur ajoutée", diff --git a/src/i18n/locales/it/dialogs.json b/src/i18n/locales/it/dialogs.json index 5593b30859..d3e1e91fd4 100644 --- a/src/i18n/locales/it/dialogs.json +++ b/src/i18n/locales/it/dialogs.json @@ -51,6 +51,14 @@ "detail": "Vuoi salvare il progetto prima di chiudere?", "saveAndClose": "Salva e chiudi", "discardAndClose": "Scarta e chiudi", + "detailNewProject": "Do you want to save your project before creating a new one?", + "saveAndNewProject": "Save & New Project", + "discardAndNewProject": "Discard & New Project", + "detailLoadProject": "Do you want to save your project before loading another one?", + "saveAndLoadProject": "Save & Load Project", + "discardAndLoadProject": "Discard & Load Project", + "newProject": "New Project", + "importVideo": "Import Video File…", "loadProject": "Carica progetto…", "saveProject": "Salva progetto…", "saveProjectAs": "Salva progetto come…" diff --git a/src/i18n/locales/it/editor.json b/src/i18n/locales/it/editor.json index 0e94b9a9f5..da33ef6410 100644 --- a/src/i18n/locales/it/editor.json +++ b/src/i18n/locales/it/editor.json @@ -6,6 +6,7 @@ "confirm": "Conferma" }, "loadingVideo": "Caricamento video...", + "loadingEditor": "Loading editor...", "errors": { "noVideoLoaded": "Nessun video caricato", "videoNotReady": "Video non pronto", @@ -43,23 +44,26 @@ "permissionDenied": "Autorizzazione di registrazione negata. Consenti la registrazione dello schermo.", "accessibilityAllowAndRetry": "Consenti l'accesso all'accessibilità per OpenScreen, poi premi di nuovo registra per avviare il conto alla rovescia." }, - "autoCaptions": { - "button": "Sottotitoli automatici", - "dialogTitle": "Sottotitoli automatici", - "dialogDescription": "Scegli all'incirca quante parole mostrare per ogni sottotitolo. La temporizzazione viene distribuita tra le parole della frase.", - "minWords": "Numero minimo di parole per sottotitolo", - "maxWords": "Numero massimo di parole per sottotitolo", - "wordsCount": "{{count}} parole", - "generate": "Genera", - "dialogCancel": "Annulla", - "generating": "Generazione dei sottotitoli dall'audio…", - "loadingModel": "Caricamento del modello vocale (al primo utilizzo vengono scaricati ~75 MB)…", - "transcribing": "Trascrizione del parlato…", - "busy": "La generazione dei sottotitoli è già in corso.", - "done": "Aggiunti {{count}} sottotitoli.", - "noneHeard": "Nessun parlato rilevato.", - "noAudio": "Questo video non contiene audio utilizzabile per la trascrizione.", - "failed": "Impossibile generare i sottotitoli.", - "truncated": "Sono stati trascritti solo i primi {{minutes}} minuti." + "captions": { + "generating": "Generazione sottotitoli...", + "generated": "{{count}} sottotitoli generati.", + "skippedNoAudio": "Nessuna traccia audio trovata; sottotitoli saltati.", + "unavailable": "Il modello locale per i sottotitoli non è disponibile.", + "failed": "Impossibile generare i sottotitoli." + }, + "emptyState": { + "title": "No project open", + "description": "Import a video to start editing, or load an existing OpenScreen project.", + "importVideoButton": "Import Video File…", + "loadProjectButton": "Load Project…", + "supportedFormats": "Supported formats: MP4, MOV, WebM, MKV, AVI, M4V, WMV", + "dragDropHint": "or drag & drop a .openscreen project file here", + "dropOverlay": "Drop project file to open", + "dropErrors": { + "unsupportedFormatTitle": "Unsupported Format", + "unsupportedFormatMessage": "Only .openscreen project files can be dropped here. To import a video file, use the \"Import Video File…\" button on this screen.", + "couldNotOpenTitle": "Could Not Open File", + "couldNotOpenMessage": "The project file could not be opened. The video it references may have been moved or deleted." + } } } diff --git a/src/i18n/locales/it/launch.json b/src/i18n/locales/it/launch.json index 66c18bc9d2..1f98016f6e 100644 --- a/src/i18n/locales/it/launch.json +++ b/src/i18n/locales/it/launch.json @@ -9,7 +9,8 @@ "openVideoFile": "Apri file video", "openProject": "Apri progetto", "useVerticalTray": "Usa barra verticale", - "useHorizontalTray": "Usa barra orizzontale" + "useHorizontalTray": "Usa barra orizzontale", + "openStudio": "Open Studio" }, "audio": { "enableSystemAudio": "Abilita audio di sistema", @@ -34,6 +35,9 @@ "loading": "Caricamento sorgenti...", "screens": "Schermi ({{count}})", "windows": "Finestre ({{count}})", + "emptyTitle": "No screens or windows found", + "emptyDescription": "If you just granted screen recording permission, reload this picker. On macOS you may need to reopen OpenScreen.", + "loadFailedDescription": "OpenScreen could not load capture sources. Reload this picker and try again.", "defaultSourceName": "Schermo" }, "recording": { diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index 9345f95ee0..ef82fab48a 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -9,7 +9,8 @@ "title": "Modalità messa a fuoco", "manual": "Manuale", "auto": "Automatico", - "autoDescription": "La fotocamera segue la posizione del cursore registrato" + "autoDescription": "La fotocamera segue la posizione del cursore registrato", + "lockedDisclaimer": "Controlled by the global Auto-Focus toggle in the timeline. Turn it off to set focus mode per zoom." }, "threeD": { "title": "Rotazione 3D", @@ -101,7 +102,8 @@ }, "project": { "save": "Salva progetto", - "load": "Carica progetto" + "load": "Carica progetto", + "new": "New Project" }, "export": { "videoButton": "Esporta video", @@ -142,6 +144,8 @@ "clearBackground": "Rimuovi sfondo", "uploadImage": "Carica immagine", "supportedFormats": "Formati supportati: JPG, PNG, GIF, WebP", + "stickerPresets": "Sticker predefiniti", + "stickerPresetsDescription": "Fai clic su uno sticker per usarlo come annotazione immagine.", "arrowDirection": "Direzione freccia", "strokeWidth": "Larghezza tratto: {{width}}px", "arrowColor": "Colore freccia", @@ -215,7 +219,8 @@ "smoothing": "Smussatura", "motionBlur": "Sfocatura movimento", "clickBounce": "Rimbalzo clic", - "clipToBounds": "Ritaglia al canvas" + "clipToBounds": "Ritaglia al canvas", + "clipToBoundsDescription": "Keeps the cursor inside the video frame. Turn off to let the cursor extend past the edges - useful when zoomed in or panned." }, "language": { "title": "Lingua" diff --git a/src/i18n/locales/it/timeline.json b/src/i18n/locales/it/timeline.json index 499a39a335..cdafa2af7c 100644 --- a/src/i18n/locales/it/timeline.json +++ b/src/i18n/locales/it/timeline.json @@ -2,6 +2,10 @@ "buttons": { "addZoom": "Aggiungi zoom (Z)", "suggestZooms": "Suggerisci zoom dal cursore", + "autoZoomOn": "Auto zoom suggestions on — click to remove suggested zooms", + "autoZoomOff": "Auto zoom suggestions off — click to suggest zooms from cursor", + "autoFocusAllOn": "Auto-Focus on for all zooms — click to switch all to manual", + "autoFocusAllOff": "Auto-Focus all zooms (camera follows the cursor)", "addTrim": "Aggiungi taglio (T)", "addAnnotation": "Aggiungi annotazione (A)", "addBlur": "Aggiungi sfocatura (B)", diff --git a/src/i18n/locales/ja-JP/dialogs.json b/src/i18n/locales/ja-JP/dialogs.json index e523ce4d9a..1b01c83363 100644 --- a/src/i18n/locales/ja-JP/dialogs.json +++ b/src/i18n/locales/ja-JP/dialogs.json @@ -42,7 +42,6 @@ "step1Title": "1. 削除範囲を追加", "step1DescriptionBefore": "キーボードの", "step1DescriptionAfter": "、またはハサミのアイコンをクリックして、削除したい範囲を指定します。", - "step2Title": "2. 範囲を調整", "step2Description": "赤い領域の両端をドラッグして、削除したい範囲を正確に調整します。" }, @@ -58,6 +57,8 @@ "detailLoadProject": "別のプロジェクトを読み込む前にプロジェクトを保存しますか?", "saveAndLoadProject": "保存してプロジェクトを読み込む", "discardAndLoadProject": "破棄してプロジェクトを読み込む", + "newProject": "New Project", + "importVideo": "Import Video File…", "loadProject": "プロジェクトを読み込む…", "saveProject": "プロジェクトを保存…", "saveProjectAs": "プロジェクトを名前を付けて保存…" diff --git a/src/i18n/locales/ja-JP/editor.json b/src/i18n/locales/ja-JP/editor.json index 8e0da42e12..937f968505 100644 --- a/src/i18n/locales/ja-JP/editor.json +++ b/src/i18n/locales/ja-JP/editor.json @@ -16,10 +16,10 @@ "failedToSaveVideo": "動画の保存に失敗しました", "exportFailed": "エクスポートに失敗しました", "exportFailedWithError": "エクスポートに失敗しました: {{error}}", + "exportBackgroundLoadFailed": "エクスポートに失敗しました: 背景画像を読み込めませんでした ({{url}})", "failedToSaveExport": "エクスポートの保存に失敗しました", "failedToSaveExportedVideo": "エクスポートした動画の保存に失敗しました", - "failedToRevealInFolder": "フォルダの表示に失敗しました: {{error}}", - "exportBackgroundLoadFailed": "エクスポートに失敗しました: 背景画像を読み込めませんでした ({{url}})" + "failedToRevealInFolder": "フォルダの表示に失敗しました: {{error}}" }, "export": { "canceled": "エクスポートがキャンセルされました", @@ -39,29 +39,17 @@ "systemAudioUnavailable": "システム音声を利用できません。システム音声なしで録画します。", "microphoneDenied": "マイクへのアクセスが拒否されました。音声なしで録画を続行します。", "cameraDenied": "カメラのアクセスが拒否されました。ウェブカメラなしで録画を続行します。", - "permissionDenied": "録画の権限が拒否されました。画面録画を許可してください。", "cameraDisconnected": "ウェブカメラが切断されました。", "cameraNotFound": "カメラが見つかりません。", + "permissionDenied": "録画の権限が拒否されました。画面録画を許可してください。", "accessibilityAllowAndRetry": "OpenScreenにアクセシビリティアクセスを許可してから、もう一度録画を押してカウントダウンを開始してください。" }, - "autoCaptions": { - "button": "自動キャプション", - "dialogTitle": "自動キャプション", - "dialogDescription": "各キャプションに一度に表示する語数の目安を選びます。タイミングはそのフレーズ内の語に分配されます。", - "minWords": "キャプションあたりの最小語数", - "maxWords": "キャプションあたりの最大語数", - "wordsCount": "{{count}} 語", - "generate": "生成", - "dialogCancel": "キャンセル", - "generating": "音声からキャプションを生成しています…", - "loadingModel": "音声モデルを読み込んでいます(初回利用時は約 75 MB をダウンロードします)…", - "transcribing": "音声を文字起こししています…", - "busy": "キャプションの生成はすでに実行中です。", - "done": "{{count}} 件のキャプションを追加しました。", - "noneHeard": "音声が検出されませんでした。", - "noAudio": "この動画には書き起こしに使える音声がありません。", - "failed": "キャプションを生成できませんでした。", - "truncated": "最初の {{minutes}} 分のみが書き起こされました。" + "captions": { + "generating": "字幕を生成しています...", + "generated": "{{count}} 件の字幕を生成しました。", + "skippedNoAudio": "音声トラックが見つからないため、字幕生成をスキップしました。", + "unavailable": "ローカル字幕モデルを利用できません。", + "failed": "字幕の生成に失敗しました。" }, "emptyState": { "title": "プロジェクトが開かれていません", diff --git a/src/i18n/locales/ja-JP/launch.json b/src/i18n/locales/ja-JP/launch.json index 66fd8ee6b8..f0666da2c4 100644 --- a/src/i18n/locales/ja-JP/launch.json +++ b/src/i18n/locales/ja-JP/launch.json @@ -9,7 +9,8 @@ "openVideoFile": "動画ファイルを開く", "openProject": "プロジェクトを開く", "useVerticalTray": "縦型トレイを使用", - "useHorizontalTray": "横型トレイを使用" + "useHorizontalTray": "横型トレイを使用", + "openStudio": "Open Studio" }, "audio": { "enableSystemAudio": "システム音声を有効にする", @@ -34,6 +35,9 @@ "loading": "ソースを読み込み中...", "screens": "画面 ({{count}})", "windows": "ウィンドウ ({{count}})", + "emptyTitle": "No screens or windows found", + "emptyDescription": "If you just granted screen recording permission, reload this picker. On macOS you may need to reopen OpenScreen.", + "loadFailedDescription": "OpenScreen could not load capture sources. Reload this picker and try again.", "defaultSourceName": "画面" }, "recording": { diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index 2950deb039..6fd04889d2 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -2,13 +2,15 @@ "zoom": { "previewHold": "押している間ズーム効果をプレビュー", "level": "ズーム倍率", + "customScale": "カスタムズーム", "selectRegion": "ズーム範囲を選択して調整", "deleteZoom": "ズームを削除", "focusMode": { "title": "フォーカスモード", "manual": "手動", "auto": "自動", - "autoDescription": "表示範囲が録画中のカーソル位置に追従します" + "autoDescription": "表示範囲が録画中のカーソル位置に追従します", + "lockedDisclaimer": "Controlled by the global Auto-Focus toggle in the timeline. Turn it off to set focus mode per zoom." }, "threeD": { "title": "3D回転", @@ -18,7 +20,6 @@ "right": "右" } }, - "customScale": "カスタムズーム", "position": { "title": "フォーカス位置", "x": "X (%)", @@ -43,9 +44,9 @@ "pictureInPicture": "ピクチャーインピクチャ", "verticalStack": "縦並び", "dualFrame": "デュアルフレーム", + "noWebcam": "Webカメラなし", "webcamShape": "カメラの形状", "webcamSize": "カメラのサイズ", - "noWebcam": "Webカメラなし", "mirrorWebcam": "Webカメラを反転", "reactiveWebcam": "ズーム時に縮小", "reactiveWebcamDescription": "ズーム中はカメラがスムーズに縮小し、邪魔になりません。" @@ -55,10 +56,10 @@ "blurBg": "背景をぼかす", "motionBlur": "モーションブラー", "off": "オフ", + "on": "オン", "shadow": "影", "roundness": "丸み", - "padding": "余白", - "on": "オン" + "padding": "余白" }, "background": { "title": "背景", @@ -143,6 +144,8 @@ "clearBackground": "背景をクリア", "uploadImage": "画像を読み込む", "supportedFormats": "サポートされている形式: JPG, PNG, GIF, WebP", + "stickerPresets": "ステッカープリセット", + "stickerPresetsDescription": "ステッカーをクリックすると、この画像注釈として使用します。", "arrowDirection": "矢印の方向", "strokeWidth": "線の太さ: {{width}}px", "arrowColor": "矢印の色", @@ -216,7 +219,8 @@ "smoothing": "スムージング", "motionBlur": "モーションブラー", "clickBounce": "クリックバウンス", - "clipToBounds": "キャンバスにクリップ" + "clipToBounds": "キャンバスにクリップ", + "clipToBoundsDescription": "Keeps the cursor inside the video frame. Turn off to let the cursor extend past the edges - useful when zoomed in or panned." }, "language": { "title": "言語" diff --git a/src/i18n/locales/ja-JP/timeline.json b/src/i18n/locales/ja-JP/timeline.json index ec9472a4ba..23d13bbc0c 100644 --- a/src/i18n/locales/ja-JP/timeline.json +++ b/src/i18n/locales/ja-JP/timeline.json @@ -2,6 +2,10 @@ "buttons": { "addZoom": "ズームを追加 (Z)", "suggestZooms": "カーソル位置からズームを提案", + "autoZoomOn": "Auto zoom suggestions on — click to remove suggested zooms", + "autoZoomOff": "Auto zoom suggestions off — click to suggest zooms from cursor", + "autoFocusAllOn": "Auto-Focus on for all zooms — click to switch all to manual", + "autoFocusAllOff": "Auto-Focus all zooms (camera follows the cursor)", "addTrim": "トリムを追加 (T)", "addAnnotation": "注釈を追加 (A)", "addBlur": "ぼかしを追加 (B)", diff --git a/src/i18n/locales/ko-KR/dialogs.json b/src/i18n/locales/ko-KR/dialogs.json index 6bff11dc97..7f4c49e656 100644 --- a/src/i18n/locales/ko-KR/dialogs.json +++ b/src/i18n/locales/ko-KR/dialogs.json @@ -57,6 +57,8 @@ "detailLoadProject": "다른 프로젝트를 불러오기 전에 저장하시겠습니까?", "saveAndLoadProject": "저장 후 프로젝트 불러오기", "discardAndLoadProject": "저장하지 않고 프로젝트 불러오기", + "newProject": "New Project", + "importVideo": "Import Video File…", "loadProject": "프로젝트 불러오기...", "saveProject": "프로젝트 저장...", "saveProjectAs": "다른 이름으로 프로젝트 저장..." diff --git a/src/i18n/locales/ko-KR/editor.json b/src/i18n/locales/ko-KR/editor.json index a63a22a572..e00c5c7acb 100644 --- a/src/i18n/locales/ko-KR/editor.json +++ b/src/i18n/locales/ko-KR/editor.json @@ -39,29 +39,17 @@ "systemAudioUnavailable": "시스템 오디오를 사용할 수 없습니다. 시스템 오디오 없이 녹화합니다.", "microphoneDenied": "마이크 접근이 거부되었습니다. 오디오 없이 녹화를 계속합니다.", "cameraDenied": "카메라 접근이 거부되었습니다. 웹캠 없이 녹화를 계속합니다.", - "permissionDenied": "녹화 권한이 거부되었습니다. 화면 녹화를 허용해 주세요.", "cameraDisconnected": "웹캠 연결이 끊어졌습니다.", "cameraNotFound": "카메라를 찾을 수 없습니다.", + "permissionDenied": "녹화 권한이 거부되었습니다. 화면 녹화를 허용해 주세요.", "accessibilityAllowAndRetry": "OpenScreen의 손쉬운 사용 접근을 허용한 다음, 카운트다운을 시작하려면 다시 녹화를 누르세요." }, - "autoCaptions": { - "button": "자동 자막", - "dialogTitle": "자동 자막", - "dialogDescription": "각 자막에 한 번에 표시할 단어 수의 대략적인 값을 선택하세요. 타이밍은 해당 구문의 단어들에 나뉩니다.", - "minWords": "자막당 최소 단어 수", - "maxWords": "자막당 최대 단어 수", - "wordsCount": "{{count}}개 단어", - "generate": "생성", - "dialogCancel": "취소", - "generating": "오디오에서 자막을 생성하는 중…", - "loadingModel": "음성 모델을 불러오는 중(첫 사용 시 약 75MB 다운로드)…", - "transcribing": "음성을 전사하는 중…", - "busy": "자막 생성이 이미 진행 중입니다.", - "done": "자막 {{count}}개를 추가했습니다.", - "noneHeard": "음성이 감지되지 않았습니다.", - "noAudio": "이 동영상에는 전사에 사용할 수 있는 음성이 없습니다.", - "failed": "자막을 생성할 수 없습니다.", - "truncated": "처음 {{minutes}}분만 전사되었습니다." + "captions": { + "generating": "자막을 생성하는 중...", + "generated": "자막 {{count}}개를 생성했습니다.", + "skippedNoAudio": "오디오 트랙을 찾을 수 없어 자막 생성을 건너뛰었습니다.", + "unavailable": "로컬 자막 모델을 사용할 수 없습니다.", + "failed": "자막 생성에 실패했습니다." }, "emptyState": { "title": "열린 프로젝트 없음", diff --git a/src/i18n/locales/ko-KR/launch.json b/src/i18n/locales/ko-KR/launch.json index 361ce8657c..083ec6c4e8 100644 --- a/src/i18n/locales/ko-KR/launch.json +++ b/src/i18n/locales/ko-KR/launch.json @@ -9,7 +9,8 @@ "openVideoFile": "비디오 파일 열기", "openProject": "프로젝트 열기", "useVerticalTray": "세로 트레이 사용", - "useHorizontalTray": "가로 트레이 사용" + "useHorizontalTray": "가로 트레이 사용", + "openStudio": "Open Studio" }, "audio": { "enableSystemAudio": "시스템 오디오 활성화", @@ -34,6 +35,9 @@ "loading": "소스 불러오는 중...", "screens": "화면 ({{count}}개)", "windows": "창 ({{count}}개)", + "emptyTitle": "No screens or windows found", + "emptyDescription": "If you just granted screen recording permission, reload this picker. On macOS you may need to reopen OpenScreen.", + "loadFailedDescription": "OpenScreen could not load capture sources. Reload this picker and try again.", "defaultSourceName": "화면" }, "recording": { diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json index d593014ed2..17de9f24f6 100644 --- a/src/i18n/locales/ko-KR/settings.json +++ b/src/i18n/locales/ko-KR/settings.json @@ -9,7 +9,8 @@ "title": "포커스 모드", "manual": "수동", "auto": "자동", - "autoDescription": "녹화된 커서 위치를 따라 카메라가 이동합니다" + "autoDescription": "녹화된 커서 위치를 따라 카메라가 이동합니다", + "lockedDisclaimer": "Controlled by the global Auto-Focus toggle in the timeline. Turn it off to set focus mode per zoom." }, "threeD": { "title": "3D 회전", @@ -42,10 +43,10 @@ "selectPreset": "프리셋 선택", "pictureInPicture": "화면 속 화면", "verticalStack": "세로 배치", - "webcamShape": "카메라 모양", - "webcamSize": "웹캠 크기", "dualFrame": "듀얼 프레임", "noWebcam": "웹캠 없음", + "webcamShape": "카메라 모양", + "webcamSize": "웹캠 크기", "mirrorWebcam": "웹캠 미러링", "reactiveWebcam": "확대 시 축소", "reactiveWebcamDescription": "확대하는 동안 카메라가 부드럽게 작아져 방해되지 않습니다." @@ -143,6 +144,8 @@ "clearBackground": "배경 지우기", "uploadImage": "이미지 업로드", "supportedFormats": "지원 형식: JPG, PNG, GIF, WebP", + "stickerPresets": "스티커 프리셋", + "stickerPresetsDescription": "스티커를 클릭하면 이 이미지 주석으로 사용됩니다.", "arrowDirection": "화살표 방향", "strokeWidth": "선 두께: {{width}}px", "arrowColor": "화살표 색상", @@ -216,7 +219,8 @@ "smoothing": "부드러움", "motionBlur": "모션 블러", "clickBounce": "클릭 바운스", - "clipToBounds": "캔버스에 맞춰 자르기" + "clipToBounds": "캔버스에 맞춰 자르기", + "clipToBoundsDescription": "Keeps the cursor inside the video frame. Turn off to let the cursor extend past the edges - useful when zoomed in or panned." }, "language": { "title": "언어" diff --git a/src/i18n/locales/ko-KR/shortcuts.json b/src/i18n/locales/ko-KR/shortcuts.json index ddac29542d..e5b5de11dc 100644 --- a/src/i18n/locales/ko-KR/shortcuts.json +++ b/src/i18n/locales/ko-KR/shortcuts.json @@ -20,10 +20,10 @@ "addTrim": "트림 추가", "addSpeed": "속도 추가", "addAnnotation": "주석 추가", + "addBlur": "블러 추가", "addKeyframe": "키프레임 추가", "deleteSelected": "선택 항목 삭제", - "playPause": "재생 / 일시정지", - "addBlur": "블러 추가" + "playPause": "재생 / 일시정지" }, "fixedActions": { "undo": "실행 취소", diff --git a/src/i18n/locales/ko-KR/timeline.json b/src/i18n/locales/ko-KR/timeline.json index 52e2ca7249..a43575deeb 100644 --- a/src/i18n/locales/ko-KR/timeline.json +++ b/src/i18n/locales/ko-KR/timeline.json @@ -2,17 +2,21 @@ "buttons": { "addZoom": "줌 추가 (Z)", "suggestZooms": "커서 기반 줌 제안", + "autoZoomOn": "Auto zoom suggestions on — click to remove suggested zooms", + "autoZoomOff": "Auto zoom suggestions off — click to suggest zooms from cursor", + "autoFocusAllOn": "Auto-Focus on for all zooms — click to switch all to manual", + "autoFocusAllOff": "Auto-Focus all zooms (camera follows the cursor)", "addTrim": "트림 추가 (T)", "addAnnotation": "주석 추가 (A)", - "addSpeed": "속도 추가 (S)", - "addBlur": "블러 추가 (B)" + "addBlur": "블러 추가 (B)", + "addSpeed": "속도 추가 (S)" }, "hints": { "pressZoom": "Z를 눌러 줌 추가", "pressTrim": "T를 눌러 트림 추가", "pressAnnotation": "A를 눌러 주석 추가", - "pressSpeed": "S를 눌러 속도 추가", - "pressBlur": "B 키를 눌러 블러 영역을 추가하세요" + "pressBlur": "B 키를 눌러 블러 영역을 추가하세요", + "pressSpeed": "S를 눌러 속도 추가" }, "labels": { "pan": "이동", @@ -23,9 +27,9 @@ "trimItem": "트림 {{index}}", "speedItem": "속도 {{index}}", "annotationItem": "주석", + "blurItem": "블러 {{index}}", "imageItem": "이미지", - "emptyText": "빈 텍스트", - "blurItem": "블러 {{index}}" + "emptyText": "빈 텍스트" }, "emptyState": { "noVideo": "불러온 비디오 없음", diff --git a/src/i18n/locales/pt-BR/dialogs.json b/src/i18n/locales/pt-BR/dialogs.json index c2b201161c..0d9cde9010 100644 --- a/src/i18n/locales/pt-BR/dialogs.json +++ b/src/i18n/locales/pt-BR/dialogs.json @@ -42,7 +42,6 @@ "step1Title": "1. Adicionar Recorte", "step1DescriptionBefore": "Pressione ", "step1DescriptionAfter": " ou clique no ícone da tesoura para marcar uma seção para remoção.", - "step2Title": "2. Ajustar", "step2Description": "Arraste as bordas da região vermelha para cobrir exatamente o que você deseja cortar." }, @@ -52,6 +51,14 @@ "detail": "Deseja salvar seu projeto antes de fechar?", "saveAndClose": "Salvar e Fechar", "discardAndClose": "Descartar e Fechar", + "detailNewProject": "Do you want to save your project before creating a new one?", + "saveAndNewProject": "Save & New Project", + "discardAndNewProject": "Discard & New Project", + "detailLoadProject": "Do you want to save your project before loading another one?", + "saveAndLoadProject": "Save & Load Project", + "discardAndLoadProject": "Discard & Load Project", + "newProject": "New Project", + "importVideo": "Import Video File…", "loadProject": "Carregar Projeto…", "saveProject": "Salvar Projeto…", "saveProjectAs": "Salvar Projeto Como…" diff --git a/src/i18n/locales/pt-BR/editor.json b/src/i18n/locales/pt-BR/editor.json index b0e9ab8c98..f732fdb23b 100644 --- a/src/i18n/locales/pt-BR/editor.json +++ b/src/i18n/locales/pt-BR/editor.json @@ -6,6 +6,7 @@ "confirm": "Confirmar" }, "loadingVideo": "Carregando vídeo...", + "loadingEditor": "Loading editor...", "errors": { "noVideoLoaded": "Nenhum vídeo carregado", "videoNotReady": "Vídeo não está pronto", @@ -40,25 +41,29 @@ "cameraDenied": "Acesso à câmera negado. A gravação continuará sem webcam.", "cameraDisconnected": "Webcam desconectada.", "cameraNotFound": "Câmera não encontrada.", - "permissionDenied": "Permissão de gravação negada. Por favor, permita a gravação de tela." + "permissionDenied": "Permissão de gravação negada. Por favor, permita a gravação de tela.", + "accessibilityAllowAndRetry": "Allow Accessibility access for OpenScreen, then press record again to start the countdown." }, - "autoCaptions": { - "button": "Legendas automáticas", - "dialogTitle": "Legendas automáticas", - "dialogDescription": "Escolha aproximadamente quantas palavras cada legenda mostra de cada vez. O tempo é distribuído entre as palavras da frase.", - "minWords": "Mínimo de palavras por legenda", - "maxWords": "Máximo de palavras por legenda", - "wordsCount": "{{count}} palavras", - "generate": "Gerar", - "dialogCancel": "Cancelar", - "generating": "Gerando legendas a partir do áudio…", - "loadingModel": "Carregando o modelo de fala (o primeiro uso baixa ~75 MB)…", - "transcribing": "Transcrevendo a fala…", - "busy": "A geração de legendas já está em andamento.", - "done": "{{count}} legendas adicionadas.", - "noneHeard": "Nenhuma fala foi detectada.", - "noAudio": "Este vídeo não tem áudio utilizável para transcrição.", - "failed": "Não foi possível gerar as legendas.", - "truncated": "Apenas os primeiros {{minutes}} minutos foram transcritos." + "captions": { + "generating": "Generating captions...", + "generated": "Generated {{count}} captions.", + "skippedNoAudio": "No audio track found; captions skipped.", + "unavailable": "Local caption model is not available.", + "failed": "Failed to generate captions." + }, + "emptyState": { + "title": "No project open", + "description": "Import a video to start editing, or load an existing OpenScreen project.", + "importVideoButton": "Import Video File…", + "loadProjectButton": "Load Project…", + "supportedFormats": "Supported formats: MP4, MOV, WebM, MKV, AVI, M4V, WMV", + "dragDropHint": "or drag & drop a .openscreen project file here", + "dropOverlay": "Drop project file to open", + "dropErrors": { + "unsupportedFormatTitle": "Unsupported Format", + "unsupportedFormatMessage": "Only .openscreen project files can be dropped here. To import a video file, use the \"Import Video File…\" button on this screen.", + "couldNotOpenTitle": "Could Not Open File", + "couldNotOpenMessage": "The project file could not be opened. The video it references may have been moved or deleted." + } } } diff --git a/src/i18n/locales/pt-BR/launch.json b/src/i18n/locales/pt-BR/launch.json index 1853809209..5411c7be3c 100644 --- a/src/i18n/locales/pt-BR/launch.json +++ b/src/i18n/locales/pt-BR/launch.json @@ -7,7 +7,10 @@ "pauseRecording": "Pausar gravação", "resumeRecording": "Retomar gravação", "openVideoFile": "Abrir arquivo de vídeo", - "openProject": "Abrir projeto" + "openProject": "Abrir projeto", + "useVerticalTray": "Use vertical tray", + "useHorizontalTray": "Use horizontal tray", + "openStudio": "Open Studio" }, "audio": { "enableSystemAudio": "Ativar áudio do sistema", @@ -32,6 +35,9 @@ "loading": "Carregando fontes...", "screens": "Telas ({{count}})", "windows": "Janelas ({{count}})", + "emptyTitle": "No screens or windows found", + "emptyDescription": "If you just granted screen recording permission, reload this picker. On macOS you may need to reopen OpenScreen.", + "loadFailedDescription": "OpenScreen could not load capture sources. Reload this picker and try again.", "defaultSourceName": "Tela" }, "recording": { diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json index 357db548a5..cbf4e95f10 100644 --- a/src/i18n/locales/pt-BR/settings.json +++ b/src/i18n/locales/pt-BR/settings.json @@ -1,5 +1,6 @@ { "zoom": { + "previewHold": "Hold to preview zoom effect", "level": "Nível de Zoom", "customScale": "Zoom Personalizado", "selectRegion": "Selecione uma região de zoom para ajustar", @@ -8,7 +9,8 @@ "title": "Modo de Foco", "manual": "Manual", "auto": "Automático", - "autoDescription": "A câmera segue a posição do cursor gravado" + "autoDescription": "A câmera segue a posição do cursor gravado", + "lockedDisclaimer": "Controlled by the global Auto-Focus toggle in the timeline. Turn it off to set focus mode per zoom." }, "threeD": { "title": "Rotação 3D", @@ -45,6 +47,7 @@ "noWebcam": "Sem Webcam", "webcamShape": "Formato da Câmera", "webcamSize": "Tamanho da Webcam", + "mirrorWebcam": "Mirror Webcam", "reactiveWebcam": "Encolher ao ampliar", "reactiveWebcamDescription": "A câmera diminui suavemente enquanto o vídeo está ampliado, para não atrapalhar." }, @@ -99,16 +102,18 @@ }, "project": { "save": "Salvar Projeto", - "load": "Carregar Projeto" + "load": "Carregar Projeto", + "new": "New Project" }, "export": { "videoButton": "Exportar Vídeo", "gifButton": "Exportar GIF", "chooseSaveLocation": "Escolher Local para Salvar" }, - "links": { - "reportBug": "Relatar Bug", - "starOnGithub": "Dar Estrela no GitHub" + "support": { + "reportBug": "Report Bug", + "saveDiagnostics": "Save Diagnostics", + "starOnGithub": "Star on GitHub" }, "imageUpload": { "invalidFileType": "Tipo de arquivo inválido", @@ -139,6 +144,8 @@ "clearBackground": "Limpar Fundo", "uploadImage": "Enviar Imagem", "supportedFormats": "Formatos suportados: JPG, PNG, GIF, WebP", + "stickerPresets": "Sticker presets", + "stickerPresetsDescription": "Click a sticker to use it as this image annotation.", "arrowDirection": "Direção da Seta", "strokeWidth": "Largura do Traço: {{width}}px", "arrowColor": "Cor da Seta", @@ -174,6 +181,17 @@ "modern": "Moderno", "clean": "Clean" }, + "textAnimation": { + "title": "Text Animation", + "selectAnimation": "Select animation", + "none": "None", + "fade": "Fade", + "rise": "Rise", + "pop": "Pop", + "slideLeft": "Slide Left", + "typewriter": "Typewriter", + "pulse": "Pulse" + }, "customFont": { "dialogTitle": "Adicionar Google Font", "urlLabel": "URL de Importação do Google Fonts", @@ -206,5 +224,9 @@ }, "language": { "title": "Idioma" + }, + "timeline": { + "title": "Timeline", + "waveform": "Show Audio Waveform on Trim Track" } } diff --git a/src/i18n/locales/pt-BR/shortcuts.json b/src/i18n/locales/pt-BR/shortcuts.json index 208cd1dc87..9a11a50a82 100644 --- a/src/i18n/locales/pt-BR/shortcuts.json +++ b/src/i18n/locales/pt-BR/shortcuts.json @@ -13,7 +13,9 @@ "reservedShortcut": "Este atalho é reservado para \"{{label}}\" e não pode ser reatribuído.", "savedToast": "Atalhos de teclado salvos", "resetToast": "Atalhos redefinidos para o padrão — clique em Salvar para aplicar", + "registrationFailed": "Failed to register shortcut. It may be in use by another app. Try a different key.", "actions": { + "openApp": "Open App", "addZoom": "Adicionar Zoom", "addTrim": "Adicionar Recorte", "addSpeed": "Adicionar Velocidade", diff --git a/src/i18n/locales/pt-BR/timeline.json b/src/i18n/locales/pt-BR/timeline.json index ba875db67a..bb9a69d50c 100644 --- a/src/i18n/locales/pt-BR/timeline.json +++ b/src/i18n/locales/pt-BR/timeline.json @@ -2,6 +2,10 @@ "buttons": { "addZoom": "Adicionar Zoom (Z)", "suggestZooms": "Sugerir Zooms a partir do Cursor", + "autoZoomOn": "Auto zoom suggestions on — click to remove suggested zooms", + "autoZoomOff": "Auto zoom suggestions off — click to suggest zooms from cursor", + "autoFocusAllOn": "Auto-Focus on for all zooms — click to switch all to manual", + "autoFocusAllOff": "Auto-Focus all zooms (camera follows the cursor)", "addTrim": "Adicionar Recorte (T)", "addAnnotation": "Adicionar Anotação (A)", "addBlur": "Adicionar Desfoque (B)", diff --git a/src/i18n/locales/ru/dialogs.json b/src/i18n/locales/ru/dialogs.json index 8d1f0aa2ac..02cf71fe39 100644 --- a/src/i18n/locales/ru/dialogs.json +++ b/src/i18n/locales/ru/dialogs.json @@ -57,6 +57,8 @@ "detailLoadProject": "Хотите сохранить проект перед загрузкой другого?", "saveAndLoadProject": "Сохранить и загрузить проект", "discardAndLoadProject": "Отменить и загрузить проект", + "newProject": "New Project", + "importVideo": "Import Video File…", "loadProject": "Загрузить проект…", "saveProject": "Сохранить проект…", "saveProjectAs": "Сохранить проект как…" diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json index 78fa129a1d..fe73637634 100644 --- a/src/i18n/locales/ru/editor.json +++ b/src/i18n/locales/ru/editor.json @@ -44,24 +44,12 @@ "permissionDenied": "Разрешение на запись запрещено. Пожалуйста, разрешите запись экрана.", "accessibilityAllowAndRetry": "Разрешите OpenScreen доступ к Универсальному доступу, затем снова нажмите запись, чтобы начать обратный отсчет." }, - "autoCaptions": { - "button": "Автосубтитры", - "dialogTitle": "Автосубтитры", - "dialogDescription": "Выберите, сколько примерно слов показывать в одном субтитре. Время распределяется между словами фразы.", - "minWords": "Минимум слов в субтитре", - "maxWords": "Максимум слов в субтитре", - "wordsCount": "{{count}} слов", - "generate": "Создать", - "dialogCancel": "Отмена", - "generating": "Создание субтитров из звука…", - "loadingModel": "Загрузка речевой модели (при первом запуске скачивается ~75 МБ)…", - "transcribing": "Распознавание речи…", - "busy": "Создание субтитров уже выполняется.", - "done": "Добавлено субтитров: {{count}}.", - "noneHeard": "Речь не обнаружена.", - "noAudio": "В этом видео нет звука, пригодного для расшифровки.", - "failed": "Не удалось создать субтитры.", - "truncated": "Расшифрованы только первые {{minutes}} мин." + "captions": { + "generating": "Создание субтитров...", + "generated": "Создано субтитров: {{count}}.", + "skippedNoAudio": "Аудиодорожка не найдена; субтитры пропущены.", + "unavailable": "Локальная модель субтитров недоступна.", + "failed": "Не удалось создать субтитры." }, "emptyState": { "title": "Нет открытых проектов", diff --git a/src/i18n/locales/ru/launch.json b/src/i18n/locales/ru/launch.json index 9b3361296f..9a60f231a1 100644 --- a/src/i18n/locales/ru/launch.json +++ b/src/i18n/locales/ru/launch.json @@ -9,7 +9,8 @@ "openVideoFile": "Открыть видеофайл", "openProject": "Открыть проект", "useVerticalTray": "Использовать вертикальную панель", - "useHorizontalTray": "Использовать горизонтальную панель" + "useHorizontalTray": "Использовать горизонтальную панель", + "openStudio": "Open Studio" }, "audio": { "enableSystemAudio": "Включить системное аудио", @@ -26,10 +27,17 @@ "noneFound": "Камера не найдена", "unavailable": "Камера недоступна" }, + "cursor": { + "useEditableCursor": "Использовать редактируемый курсор", + "useSystemCursor": "Использовать системный курсор" + }, "sourceSelector": { "loading": "Загрузка источников...", "screens": "Экраны ({{count}})", "windows": "Окна ({{count}})", + "emptyTitle": "No screens or windows found", + "emptyDescription": "If you just granted screen recording permission, reload this picker. On macOS you may need to reopen OpenScreen.", + "loadFailedDescription": "OpenScreen could not load capture sources. Reload this picker and try again.", "defaultSourceName": "Экран" }, "recording": { @@ -41,9 +49,5 @@ "description": "Мы обнаружили {{language}} как системный язык. Хотите переключить OpenScreen на {{language}}?", "switch": "Переключить на {{language}}", "keepDefault": "Оставить текущий язык" - }, - "cursor": { - "useEditableCursor": "Использовать редактируемый курсор", - "useSystemCursor": "Использовать системный курсор" } } diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index 17205a0999..bcf6be4a2b 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -2,13 +2,15 @@ "zoom": { "previewHold": "Удерживайте для предпросмотра эффекта зума", "level": "Уровень масштабирования", + "customScale": "Пользовательский масштаб", "selectRegion": "Выберите область масштабирования для настройки", "deleteZoom": "Удалить масштабирование", "focusMode": { "title": "Режим фокуса", "manual": "Ручной", "auto": "Авто", - "autoDescription": "Камера следует за записанной позицией курсора" + "autoDescription": "Камера следует за записанной позицией курсора", + "lockedDisclaimer": "Controlled by the global Auto-Focus toggle in the timeline. Turn it off to set focus mode per zoom." }, "threeD": { "title": "3D вращение", @@ -18,7 +20,6 @@ "right": "Справа" } }, - "customScale": "Пользовательский масштаб", "position": { "title": "Положение фокуса", "x": "X (%)", @@ -43,9 +44,9 @@ "pictureInPicture": "Картинка в картинке", "verticalStack": "Вертикальный стек", "dualFrame": "Двойной кадр", + "noWebcam": "Без веб-камеры", "webcamShape": "Форма камеры", "webcamSize": "Размер веб-камеры", - "noWebcam": "Без веб-камеры", "mirrorWebcam": "Зеркалить веб-камеру", "reactiveWebcam": "Уменьшать при зуме", "reactiveWebcamDescription": "Камера плавно уменьшается во время приближения, чтобы не мешать." @@ -143,6 +144,8 @@ "clearBackground": "Очистить фон", "uploadImage": "Загрузить изображение", "supportedFormats": "Поддерживаемые форматы: JPG, PNG, GIF, WebP", + "stickerPresets": "Готовые стикеры", + "stickerPresetsDescription": "Нажмите стикер, чтобы использовать его как аннотацию-изображение.", "arrowDirection": "Направление стрелки", "strokeWidth": "Толщина линии: {{width}}px", "arrowColor": "Цвет стрелки", @@ -216,7 +219,8 @@ "smoothing": "Сглаживание", "motionBlur": "Размытие движения", "clickBounce": "Отскок при клике", - "clipToBounds": "Обрезать по холсту" + "clipToBounds": "Обрезать по холсту", + "clipToBoundsDescription": "Keeps the cursor inside the video frame. Turn off to let the cursor extend past the edges - useful when zoomed in or panned." }, "language": { "title": "Язык" diff --git a/src/i18n/locales/ru/timeline.json b/src/i18n/locales/ru/timeline.json index fe9ce46a8a..a0d661028f 100644 --- a/src/i18n/locales/ru/timeline.json +++ b/src/i18n/locales/ru/timeline.json @@ -2,6 +2,10 @@ "buttons": { "addZoom": "Добавить масштабирование (Z)", "suggestZooms": "Предложить масштабирование на основе курсора", + "autoZoomOn": "Auto zoom suggestions on — click to remove suggested zooms", + "autoZoomOff": "Auto zoom suggestions off — click to suggest zooms from cursor", + "autoFocusAllOn": "Auto-Focus on for all zooms — click to switch all to manual", + "autoFocusAllOff": "Auto-Focus all zooms (camera follows the cursor)", "addTrim": "Добавить обрезку (T)", "addAnnotation": "Добавить аннотацию (A)", "addBlur": "Добавить размытие (B)", diff --git a/src/i18n/locales/tr/dialogs.json b/src/i18n/locales/tr/dialogs.json index 1b62e7072e..191870729c 100644 --- a/src/i18n/locales/tr/dialogs.json +++ b/src/i18n/locales/tr/dialogs.json @@ -57,6 +57,8 @@ "detailLoadProject": "Başka bir proje yüklemeden önce kaydetmek ister misiniz?", "saveAndLoadProject": "Kaydet ve Proje Yükle", "discardAndLoadProject": "Kaydetmeden Proje Yükle", + "newProject": "New Project", + "importVideo": "Import Video File…", "loadProject": "Proje Yükle…", "saveProject": "Proje Kaydet…", "saveProjectAs": "Farklı Kaydet…" diff --git a/src/i18n/locales/tr/editor.json b/src/i18n/locales/tr/editor.json index 89203e719c..e4c45c6163 100644 --- a/src/i18n/locales/tr/editor.json +++ b/src/i18n/locales/tr/editor.json @@ -1,4 +1,12 @@ { + "newRecording": { + "title": "Kaydediciye Dön", + "description": "Mevcut oturumunuz kaydedildi.", + "cancel": "İptal", + "confirm": "Onayla" + }, + "loadingVideo": "Video yükleniyor...", + "loadingEditor": "Editör yükleniyor...", "errors": { "noVideoLoaded": "Video yüklenmedi", "videoNotReady": "Video hazır değil", @@ -31,37 +39,17 @@ "systemAudioUnavailable": "Sistem sesi kullanılamıyor. Sistem sesi olmadan kaydediliyor.", "microphoneDenied": "Mikrofon erişimi reddedildi. Kayıt ses olmadan devam edecek.", "cameraDenied": "Kamera erişimi reddedildi. Kayıt kamera olmadan devam edecek.", - "permissionDenied": "Kayıt izni reddedildi. Lütfen ekran kaydına izin verin.", "cameraDisconnected": "Webcam bağlantısı kesildi.", "cameraNotFound": "Kamera bulunamadı.", + "permissionDenied": "Kayıt izni reddedildi. Lütfen ekran kaydına izin verin.", "accessibilityAllowAndRetry": "OpenScreen için Erişilebilirlik erişimine izin verin, ardından geri sayımı başlatmak için tekrar kayda basın." }, - "loadingVideo": "Video yükleniyor...", - "loadingEditor": "Editör yükleniyor...", - "newRecording": { - "title": "Kaydediciye Dön", - "description": "Mevcut oturumunuz kaydedildi.", - "cancel": "İptal", - "confirm": "Onayla" - }, - "autoCaptions": { - "button": "Otomatik altyazılar", - "dialogTitle": "Otomatik altyazılar", - "dialogDescription": "Her altyazının aynı anda yaklaşık kaç kelime göstermesini istediğinizi seçin. Zamanlama, o ifadedeki kelimelere dağıtılır.", - "minWords": "Altyazı başına en az kelime", - "maxWords": "Altyazı başına en fazla kelime", - "wordsCount": "{{count}} kelime", - "generate": "Oluştur", - "dialogCancel": "İptal", - "generating": "Sesten altyazılar oluşturuluyor…", - "loadingModel": "Konuşma modeli yükleniyor (ilk kullanımda ~75 MB indirilir)…", - "transcribing": "Konuşma yazıya dökülüyor…", - "busy": "Altyazı oluşturma zaten devam ediyor.", - "done": "{{count}} altyazı eklendi.", - "noneHeard": "Konuşma algılanmadı.", - "noAudio": "Bu videoda yazıya dökülebilecek kullanılabilir bir ses yok.", - "failed": "Altyazılar oluşturulamadı.", - "truncated": "Yalnızca ilk {{minutes}} dakika yazıya döküldü." + "captions": { + "generating": "Altyazılar oluşturuluyor...", + "generated": "{{count}} altyazı oluşturuldu.", + "skippedNoAudio": "Ses parçası bulunamadı; altyazılar atlandı.", + "unavailable": "Yerel altyazı modeli kullanılamıyor.", + "failed": "Altyazılar oluşturulamadı." }, "emptyState": { "title": "Açık proje yok", diff --git a/src/i18n/locales/tr/launch.json b/src/i18n/locales/tr/launch.json index 921a52634c..025d30cbaf 100644 --- a/src/i18n/locales/tr/launch.json +++ b/src/i18n/locales/tr/launch.json @@ -9,7 +9,8 @@ "openVideoFile": "Video dosyası aç", "openProject": "Proje aç", "useVerticalTray": "Dikey araç çubuğunu kullan", - "useHorizontalTray": "Yatay araç çubuğunu kullan" + "useHorizontalTray": "Yatay araç çubuğunu kullan", + "openStudio": "Open Studio" }, "audio": { "enableSystemAudio": "Sistem sesini etkinleştir", @@ -34,6 +35,9 @@ "loading": "Kaynaklar yükleniyor...", "screens": "Ekranlar ({{count}})", "windows": "Pencereler ({{count}})", + "emptyTitle": "No screens or windows found", + "emptyDescription": "If you just granted screen recording permission, reload this picker. On macOS you may need to reopen OpenScreen.", + "loadFailedDescription": "OpenScreen could not load capture sources. Reload this picker and try again.", "defaultSourceName": "Ekran" }, "recording": { diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index 209cddd095..b217536aef 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -2,13 +2,15 @@ "zoom": { "previewHold": "Yakınlaştırma efektini önizlemek için basılı tutun", "level": "Yakınlaştırma Seviyesi", + "customScale": "Özel Yakınlaştırma", "selectRegion": "Ayarlamak için bir yakınlaştırma bölgesi seçin", "deleteZoom": "Yakınlaştırmayı Sil", "focusMode": { "title": "Odak Modu", "manual": "Manuel", "auto": "Otomatik", - "autoDescription": "Kamera kaydedilen imleç konumunu takip eder" + "autoDescription": "Kamera kaydedilen imleç konumunu takip eder", + "lockedDisclaimer": "Controlled by the global Auto-Focus toggle in the timeline. Turn it off to set focus mode per zoom." }, "threeD": { "title": "3D Döndürme", @@ -18,7 +20,6 @@ "right": "Sağ" } }, - "customScale": "Özel Yakınlaştırma", "position": { "title": "Odak Konumu", "x": "X (%)", @@ -42,10 +43,10 @@ "selectPreset": "Ön ayar seçin", "pictureInPicture": "Resim İçinde Resim", "verticalStack": "Dikey Yığın", - "webcamShape": "Kamera Şekli", "dualFrame": "Çift Kare", - "webcamSize": "Webcam Boyutu", "noWebcam": "Web kamerası yok", + "webcamShape": "Kamera Şekli", + "webcamSize": "Webcam Boyutu", "mirrorWebcam": "Web kamerasını aynala", "reactiveWebcam": "Yakınlaştırınca küçült", "reactiveWebcamDescription": "Video yakınlaştırıldığında kamera yumuşakça küçülür, böylece yoldan çekilir." @@ -55,10 +56,10 @@ "blurBg": "Arka Planı Bulanıklaştır", "motionBlur": "Hareket Bulanıklığı", "off": "kapalı", + "on": "açık", "shadow": "Gölge", "roundness": "Yuvarlaklık", - "padding": "Dolgu", - "on": "açık" + "padding": "Dolgu" }, "background": { "title": "Arka Plan", @@ -132,8 +133,6 @@ "textPlaceholder": "Metninizi girin...", "fontStyle": "Yazı Tipi Stili", "selectStyle": "Stil seçin", - "textAnimation": "Metin Animasyonu", - "selectAnimation": "Animasyon seçin", "size": "Boyut", "customFonts": "Özel Yazı Tipleri", "textColor": "Metin Rengi", @@ -145,11 +144,20 @@ "clearBackground": "Arka Planı Temizle", "uploadImage": "Görüntü Yükle", "supportedFormats": "Desteklenen biçimler: JPG, PNG, GIF, WebP", + "stickerPresets": "Hazır çıkartmalar", + "stickerPresetsDescription": "Bu görsel açıklama için kullanmak üzere bir çıkartmaya tıklayın.", "arrowDirection": "Ok Yönü", "strokeWidth": "Çizgi Kalınlığı: {{width}}px", "arrowColor": "Ok Rengi", + "blurType": "Bulanıklık Türü", + "blurTypeBlur": "Bulanık", + "blurTypeMosaic": "Mozaik Bulanıklık", + "blurColor": "Bulanıklık Rengi", + "blurColorWhite": "Beyaz", + "blurColorBlack": "Siyah", "blurShape": "Bulanık Şekli", "blurIntensity": "Bulanıklık Yoğunluğu", + "mosaicBlockSize": "Mozaik Blok Boyutu", "blurShapeRectangle": "Dikdörtgen", "blurShapeOval": "Oval", "blurShapeFreehand": "Serbest", @@ -161,14 +169,7 @@ "invalidImageType": "Geçersiz dosya türü", "imageFormatsOnly": "Lütfen bir JPG, PNG, GIF veya WebP görüntü dosyası yükleyin.", "imageUploadSuccess": "Görüntü başarıyla yüklendi!", - "failedImageUpload": "Görüntü yüklenemedi", - "blurColor": "Bulanıklık Rengi", - "blurColorBlack": "Siyah", - "blurColorWhite": "Beyaz", - "blurType": "Bulanıklık Türü", - "blurTypeBlur": "Bulanık", - "blurTypeMosaic": "Mozaik Bulanıklık", - "mosaicBlockSize": "Mozaik Blok Boyutu" + "failedImageUpload": "Görüntü yüklenemedi" }, "fontStyles": { "classic": "Klasik", @@ -180,6 +181,17 @@ "modern": "Modern", "clean": "Temiz" }, + "textAnimation": { + "title": "Text Animation", + "selectAnimation": "Select animation", + "none": "None", + "fade": "Fade", + "rise": "Rise", + "pop": "Pop", + "slideLeft": "Slide Left", + "typewriter": "Typewriter", + "pulse": "Pulse" + }, "customFont": { "dialogTitle": "Google Yazı Tipi Ekle", "urlLabel": "Google Fonts İçe Aktarım URL'si", @@ -207,7 +219,8 @@ "smoothing": "Yumuşatma", "motionBlur": "Hareket Bulanıklığı", "clickBounce": "Tıklama Sıçraması", - "clipToBounds": "Tuvale Kırp" + "clipToBounds": "Tuvale Kırp", + "clipToBoundsDescription": "Keeps the cursor inside the video frame. Turn off to let the cursor extend past the edges - useful when zoomed in or panned." }, "language": { "title": "Dil" diff --git a/src/i18n/locales/tr/timeline.json b/src/i18n/locales/tr/timeline.json index 2632f7a6f7..90cc80245d 100644 --- a/src/i18n/locales/tr/timeline.json +++ b/src/i18n/locales/tr/timeline.json @@ -2,17 +2,21 @@ "buttons": { "addZoom": "Yakınlaştırma Ekle (Z)", "suggestZooms": "İmleçten Yakınlaştırma Öner", + "autoZoomOn": "Auto zoom suggestions on — click to remove suggested zooms", + "autoZoomOff": "Auto zoom suggestions off — click to suggest zooms from cursor", + "autoFocusAllOn": "Auto-Focus on for all zooms — click to switch all to manual", + "autoFocusAllOff": "Auto-Focus all zooms (camera follows the cursor)", "addTrim": "Kırpma Ekle (T)", "addAnnotation": "Açıklama Ekle (A)", - "addSpeed": "Hız Ekle (S)", - "addBlur": "Bulanık ekle (B)" + "addBlur": "Bulanık ekle (B)", + "addSpeed": "Hız Ekle (S)" }, "hints": { "pressZoom": "Yakınlaştırma eklemek için Z tuşuna basın", "pressTrim": "Kırpma eklemek için T tuşuna basın", "pressAnnotation": "Açıklama eklemek için A tuşuna basın", - "pressSpeed": "Hız eklemek için S tuşuna basın", - "pressBlur": "Bulanık bölge eklemek için B tuşuna basın" + "pressBlur": "Bulanık bölge eklemek için B tuşuna basın", + "pressSpeed": "Hız eklemek için S tuşuna basın" }, "labels": { "pan": "Kaydır", @@ -23,9 +27,9 @@ "trimItem": "Kırpma {{index}}", "speedItem": "Hız {{index}}", "annotationItem": "Açıklama", + "blurItem": "Bulanık {{index}}", "imageItem": "Görüntü", - "emptyText": "Boş metin", - "blurItem": "Bulanık {{index}}" + "emptyText": "Boş metin" }, "emptyState": { "noVideo": "Video Yüklenmedi", diff --git a/src/i18n/locales/vi/dialogs.json b/src/i18n/locales/vi/dialogs.json index b7216ad1dd..5eab853cfa 100644 --- a/src/i18n/locales/vi/dialogs.json +++ b/src/i18n/locales/vi/dialogs.json @@ -57,6 +57,8 @@ "detailLoadProject": "Bạn có muốn lưu dự án của mình trước khi tải dự án khác không?", "saveAndLoadProject": "Lưu & Tải dự án", "discardAndLoadProject": "Bỏ qua & Tải dự án", + "newProject": "New Project", + "importVideo": "Import Video File…", "loadProject": "Tải dự án…", "saveProject": "Lưu dự án…", "saveProjectAs": "Lưu dự án thành…" diff --git a/src/i18n/locales/vi/editor.json b/src/i18n/locales/vi/editor.json index 90004091e1..0c6f26e957 100644 --- a/src/i18n/locales/vi/editor.json +++ b/src/i18n/locales/vi/editor.json @@ -44,24 +44,12 @@ "permissionDenied": "Quyền ghi hình bị từ chối. Vui lòng cho phép ghi màn hình.", "accessibilityAllowAndRetry": "Cho phép OpenScreen truy cập Trợ năng, sau đó nhấn ghi lại để bắt đầu đếm ngược." }, - "autoCaptions": { - "button": "Phụ đề tự động", - "dialogTitle": "Phụ đề tự động", - "dialogDescription": "Chọn khoảng bao nhiêu từ mỗi phụ đề hiển thị cùng lúc. Thời gian được phân bổ cho các từ trong cụm từ đó.", - "minWords": "Số từ tối thiểu mỗi phụ đề", - "maxWords": "Số từ tối đa mỗi phụ đề", - "wordsCount": "{{count}} từ", - "generate": "Tạo", - "dialogCancel": "Hủy", - "generating": "Đang tạo phụ đề từ âm thanh…", - "loadingModel": "Đang tải mô hình giọng nói (lần đầu sử dụng sẽ tải ~75 MB)…", - "transcribing": "Đang chuyển lời nói thành văn bản…", - "busy": "Việc tạo phụ đề đang được tiến hành.", - "done": "Đã thêm {{count}} phụ đề.", - "noneHeard": "Không phát hiện thấy lời nói.", - "noAudio": "Video này không có âm thanh dùng được để chuyển thành văn bản.", - "failed": "Không thể tạo phụ đề.", - "truncated": "Chỉ {{minutes}} phút đầu tiên được chuyển thành văn bản." + "captions": { + "generating": "Đang tạo phụ đề...", + "generated": "Đã tạo {{count}} phụ đề.", + "skippedNoAudio": "Không tìm thấy bản âm thanh; đã bỏ qua phụ đề.", + "unavailable": "Mô hình phụ đề cục bộ không khả dụng.", + "failed": "Tạo phụ đề thất bại." }, "emptyState": { "title": "Không có dự án nào được mở", diff --git a/src/i18n/locales/vi/launch.json b/src/i18n/locales/vi/launch.json index efbaad5d4d..55838c9bc5 100644 --- a/src/i18n/locales/vi/launch.json +++ b/src/i18n/locales/vi/launch.json @@ -9,7 +9,8 @@ "openVideoFile": "Mở tệp video", "openProject": "Mở dự án", "useVerticalTray": "Dùng khay dọc", - "useHorizontalTray": "Dùng khay ngang" + "useHorizontalTray": "Dùng khay ngang", + "openStudio": "Open Studio" }, "audio": { "enableSystemAudio": "Bật âm thanh hệ thống", @@ -26,10 +27,17 @@ "noneFound": "Không tìm thấy máy ảnh", "unavailable": "Máy ảnh không khả dụng" }, + "cursor": { + "useEditableCursor": "Dùng con trỏ có thể chỉnh sửa", + "useSystemCursor": "Dùng con trỏ hệ thống" + }, "sourceSelector": { "loading": "Đang tải nguồn...", "screens": "Màn hình ({{count}})", "windows": "Cửa sổ ({{count}})", + "emptyTitle": "No screens or windows found", + "emptyDescription": "If you just granted screen recording permission, reload this picker. On macOS you may need to reopen OpenScreen.", + "loadFailedDescription": "OpenScreen could not load capture sources. Reload this picker and try again.", "defaultSourceName": "Màn hình" }, "recording": { @@ -41,9 +49,5 @@ "description": "Chúng tôi phát hiện {{language}} là ngôn ngữ hệ thống của bạn. Bạn có muốn chuyển OpenScreen sang {{language}} không?", "switch": "Chuyển sang {{language}}", "keepDefault": "Giữ ngôn ngữ hiện tại" - }, - "cursor": { - "useEditableCursor": "Dùng con trỏ có thể chỉnh sửa", - "useSystemCursor": "Dùng con trỏ hệ thống" } } diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json index f86e4851c7..d087b03061 100644 --- a/src/i18n/locales/vi/settings.json +++ b/src/i18n/locales/vi/settings.json @@ -2,20 +2,15 @@ "zoom": { "previewHold": "Giữ để xem trước hiệu ứng phóng to", "level": "Mức độ thu phóng", + "customScale": "Thu phóng tùy chỉnh", "selectRegion": "Chọn vùng thu phóng để điều chỉnh", "deleteZoom": "Xóa thu phóng", "focusMode": { "title": "Chế độ lấy nét", "manual": "Thủ công", "auto": "Tự động", - "autoDescription": "Máy ảnh đi theo vị trí con trỏ đã ghi" - }, - "customScale": "Thu phóng tùy chỉnh", - "position": { - "title": "Vị trí tiêu điểm", - "x": "X (%)", - "y": "Y (%)", - "hint": "0 = ngoài cùng trái / trên, 100 = ngoài cùng phải / dưới" + "autoDescription": "Máy ảnh đi theo vị trí con trỏ đã ghi", + "lockedDisclaimer": "Controlled by the global Auto-Focus toggle in the timeline. Turn it off to set focus mode per zoom." }, "threeD": { "title": "Xoay 3D", @@ -24,6 +19,12 @@ "left": "Trái", "right": "Phải" } + }, + "position": { + "title": "Vị trí tiêu điểm", + "x": "X (%)", + "y": "Y (%)", + "hint": "0 = ngoài cùng trái / trên, 100 = ngoài cùng phải / dưới" } }, "speed": { @@ -43,9 +44,9 @@ "pictureInPicture": "Hình trong hình", "verticalStack": "Xếp chồng dọc", "dualFrame": "Khung kép", + "noWebcam": "Không có webcam", "webcamShape": "Hình dạng máy ảnh", "webcamSize": "Kích thước Webcam", - "noWebcam": "Không có webcam", "mirrorWebcam": "Lật webcam", "reactiveWebcam": "Thu nhỏ khi phóng to", "reactiveWebcamDescription": "Camera thu nhỏ mượt mà khi video được phóng to, để không che khuất." @@ -55,10 +56,10 @@ "blurBg": "Làm mờ nền", "motionBlur": "Làm mờ chuyển động", "off": "tắt", + "on": "bật", "shadow": "Bóng đổ", "roundness": "Độ bo tròn", - "padding": "Phần đệm", - "on": "bật" + "padding": "Phần đệm" }, "background": { "title": "Nền", @@ -67,8 +68,8 @@ "gradient": "Dải màu", "uploadCustom": "Tải lên tùy chỉnh", "gradientLabel": "Dải màu {{index}}", - "colorPalette": "Bảng màu", - "colorWheel": "Vòng màu" + "colorWheel": "Vòng màu", + "colorPalette": "Bảng màu" }, "crop": { "title": "Cắt xén", @@ -132,17 +133,19 @@ "textPlaceholder": "Nhập văn bản của bạn...", "fontStyle": "Kiểu phông chữ", "selectStyle": "Chọn kiểu", - "textAnimation": "Hoạt ảnh văn bản", - "selectAnimation": "Chọn hoạt ảnh", "size": "Kích thước", "customFonts": "Phông chữ tùy chỉnh", "textColor": "Màu văn bản", "background": "Nền", "none": "Không có", "color": "Màu sắc", + "colorWheel": "Vòng màu", + "colorPalette": "Bảng màu", "clearBackground": "Xóa nền", "uploadImage": "Tải lên hình ảnh", "supportedFormats": "Định dạng hỗ trợ: JPG, PNG, GIF, WebP", + "stickerPresets": "Mẫu nhãn dán", + "stickerPresetsDescription": "Nhấp vào nhãn dán để dùng làm chú thích hình ảnh này.", "arrowDirection": "Hướng mũi tên", "strokeWidth": "Độ dày nét: {{width}}px", "arrowColor": "Màu mũi tên", @@ -166,9 +169,7 @@ "invalidImageType": "Loại tệp không hợp lệ", "imageFormatsOnly": "Vui lòng tải lên tệp hình ảnh JPG, PNG, GIF hoặc WebP.", "imageUploadSuccess": "Tải lên hình ảnh thành công!", - "failedImageUpload": "Tải lên hình ảnh thất bại", - "colorPalette": "Bảng màu", - "colorWheel": "Vòng màu" + "failedImageUpload": "Tải lên hình ảnh thất bại" }, "fontStyles": { "classic": "Cổ điển", @@ -180,6 +181,17 @@ "modern": "Hiện đại", "clean": "Sạch sẽ" }, + "textAnimation": { + "title": "Text Animation", + "selectAnimation": "Select animation", + "none": "None", + "fade": "Fade", + "rise": "Rise", + "pop": "Pop", + "slideLeft": "Slide Left", + "typewriter": "Typewriter", + "pulse": "Pulse" + }, "customFont": { "dialogTitle": "Thêm Google Font", "urlLabel": "URL nhập Google Fonts", @@ -207,7 +219,8 @@ "smoothing": "Làm mượt", "motionBlur": "Làm mờ chuyển động", "clickBounce": "Nảy khi nhấp", - "clipToBounds": "Cắt theo khung" + "clipToBounds": "Cắt theo khung", + "clipToBoundsDescription": "Keeps the cursor inside the video frame. Turn off to let the cursor extend past the edges - useful when zoomed in or panned." }, "language": { "title": "Ngôn ngữ" diff --git a/src/i18n/locales/vi/timeline.json b/src/i18n/locales/vi/timeline.json index a01dfe4522..1cad7369b2 100644 --- a/src/i18n/locales/vi/timeline.json +++ b/src/i18n/locales/vi/timeline.json @@ -2,6 +2,10 @@ "buttons": { "addZoom": "Thêm Thu phóng (Z)", "suggestZooms": "Đề xuất Thu phóng từ Con trỏ", + "autoZoomOn": "Auto zoom suggestions on — click to remove suggested zooms", + "autoZoomOff": "Auto zoom suggestions off — click to suggest zooms from cursor", + "autoFocusAllOn": "Auto-Focus on for all zooms — click to switch all to manual", + "autoFocusAllOff": "Auto-Focus all zooms (camera follows the cursor)", "addTrim": "Thêm Cắt (T)", "addAnnotation": "Thêm Chú thích (A)", "addBlur": "Thêm Làm mờ (B)", diff --git a/src/i18n/locales/zh-CN/dialogs.json b/src/i18n/locales/zh-CN/dialogs.json index 246ea4859b..da75806f23 100644 --- a/src/i18n/locales/zh-CN/dialogs.json +++ b/src/i18n/locales/zh-CN/dialogs.json @@ -57,6 +57,8 @@ "detailLoadProject": "是否在加载其他项目前保存当前项目?", "saveAndLoadProject": "保存并加载项目", "discardAndLoadProject": "放弃并加载项目", + "newProject": "New Project", + "importVideo": "Import Video File…", "loadProject": "加载项目…", "saveProject": "保存项目…", "saveProjectAs": "项目另存为…" diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json index 58f6ae27b0..9d7da5256b 100644 --- a/src/i18n/locales/zh-CN/editor.json +++ b/src/i18n/locales/zh-CN/editor.json @@ -44,24 +44,12 @@ "permissionDenied": "录屏权限被拒绝。请允许屏幕录制。", "accessibilityAllowAndRetry": "允许 OpenScreen 使用辅助功能权限,然后再次按录制以开始倒计时。" }, - "autoCaptions": { - "button": "自动字幕", - "dialogTitle": "自动字幕", - "dialogDescription": "大致选择每条字幕一次显示多少个字词。时间会在该语句内的字词之间分配。", - "minWords": "每条字幕的最少字数", - "maxWords": "每条字幕的最多字数", - "wordsCount": "{{count}} 个词", - "generate": "生成", - "dialogCancel": "取消", - "generating": "正在从音频生成字幕…", - "loadingModel": "正在加载语音模型(首次使用将下载约 75 MB)…", - "transcribing": "正在转写语音…", - "busy": "字幕生成已在进行中。", - "done": "已添加 {{count}} 条字幕。", - "noneHeard": "未检测到语音。", - "noAudio": "此视频没有可用于转写的音频。", - "failed": "无法生成字幕。", - "truncated": "仅转写了最前 {{minutes}} 分钟。" + "captions": { + "generating": "正在生成字幕...", + "generated": "已生成 {{count}} 条字幕。", + "skippedNoAudio": "未找到音轨,已跳过字幕生成。", + "unavailable": "本地字幕模型不可用。", + "failed": "字幕生成失败。" }, "emptyState": { "title": "未打开任何项目", diff --git a/src/i18n/locales/zh-CN/launch.json b/src/i18n/locales/zh-CN/launch.json index c089965856..0469364c78 100644 --- a/src/i18n/locales/zh-CN/launch.json +++ b/src/i18n/locales/zh-CN/launch.json @@ -9,7 +9,8 @@ "openVideoFile": "打开视频文件", "openProject": "打开项目", "useVerticalTray": "使用竖向托盘", - "useHorizontalTray": "使用横向托盘" + "useHorizontalTray": "使用横向托盘", + "openStudio": "Open Studio" }, "audio": { "enableSystemAudio": "启用系统音频", @@ -34,6 +35,9 @@ "loading": "正在加载源...", "screens": "屏幕 ({{count}})", "windows": "窗口 ({{count}})", + "emptyTitle": "No screens or windows found", + "emptyDescription": "If you just granted screen recording permission, reload this picker. On macOS you may need to reopen OpenScreen.", + "loadFailedDescription": "OpenScreen could not load capture sources. Reload this picker and try again.", "defaultSourceName": "屏幕" }, "recording": { diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index dab8540779..63de36d479 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -2,13 +2,15 @@ "zoom": { "previewHold": "按住预览放大效果", "level": "缩放级别", + "customScale": "自定义缩放", "selectRegion": "选择要调整的缩放区域", "deleteZoom": "删除缩放", "focusMode": { "title": "对焦模式", "manual": "手动", "auto": "自动", - "autoDescription": "摄像头跟随录制时的光标位置" + "autoDescription": "摄像头跟随录制时的光标位置", + "lockedDisclaimer": "Controlled by the global Auto-Focus toggle in the timeline. Turn it off to set focus mode per zoom." }, "threeD": { "title": "3D 旋转", @@ -18,7 +20,6 @@ "right": "右" } }, - "customScale": "自定义缩放", "position": { "title": "焦点位置", "x": "X (%)", @@ -43,9 +44,9 @@ "pictureInPicture": "画中画", "verticalStack": "垂直堆叠", "dualFrame": "双画框", + "noWebcam": "无摄像头", "webcamShape": "摄像头形状", "webcamSize": "摄像头大小", - "noWebcam": "无摄像头", "mirrorWebcam": "镜像摄像头", "reactiveWebcam": "缩放时缩小", "reactiveWebcamDescription": "放大视频时摄像头会平滑缩小,以免遮挡内容。" @@ -55,10 +56,10 @@ "blurBg": "模糊背景", "motionBlur": "运动模糊", "off": "关", + "on": "开", "shadow": "阴影", "roundness": "圆角", - "padding": "内边距", - "on": "开" + "padding": "内边距" }, "background": { "title": "背景", @@ -132,8 +133,6 @@ "textPlaceholder": "输入您的文本...", "fontStyle": "字体样式", "selectStyle": "选择样式", - "textAnimation": "文本动画", - "selectAnimation": "选择动画", "size": "大小", "customFonts": "自定义字体", "textColor": "文本颜色", @@ -145,11 +144,20 @@ "clearBackground": "清除背景", "uploadImage": "上传图片", "supportedFormats": "支持的格式:JPG、PNG、GIF、WebP", + "stickerPresets": "内置贴纸", + "stickerPresetsDescription": "点击贴纸即可用作当前图片标注。", "arrowDirection": "箭头方向", "strokeWidth": "描边宽度:{{width}}px", "arrowColor": "箭头颜色", + "blurType": "模糊类型", + "blurTypeBlur": "模糊", + "blurTypeMosaic": "马赛克模糊", + "blurColor": "模糊颜色", + "blurColorWhite": "白色", + "blurColorBlack": "黑色", "blurShape": "模糊形状", "blurIntensity": "模糊强度", + "mosaicBlockSize": "马赛克块大小", "blurShapeRectangle": "矩形", "blurShapeOval": "椭圆", "blurShapeFreehand": "自由手绘", @@ -161,14 +169,7 @@ "invalidImageType": "无效的文件类型", "imageFormatsOnly": "请上传 JPG、PNG、GIF 或 WebP 格式的图片文件。", "imageUploadSuccess": "图片上传成功!", - "failedImageUpload": "上传图片失败", - "blurColor": "模糊颜色", - "blurColorBlack": "黑色", - "blurColorWhite": "白色", - "blurType": "模糊类型", - "blurTypeBlur": "模糊", - "blurTypeMosaic": "马赛克模糊", - "mosaicBlockSize": "马赛克块大小" + "failedImageUpload": "上传图片失败" }, "fontStyles": { "classic": "经典", @@ -180,6 +181,17 @@ "modern": "现代", "clean": "简洁" }, + "textAnimation": { + "title": "Text Animation", + "selectAnimation": "Select animation", + "none": "None", + "fade": "Fade", + "rise": "Rise", + "pop": "Pop", + "slideLeft": "Slide Left", + "typewriter": "Typewriter", + "pulse": "Pulse" + }, "customFont": { "dialogTitle": "添加 Google 字体", "urlLabel": "Google Fonts 导入 URL", @@ -207,7 +219,8 @@ "smoothing": "平滑", "motionBlur": "运动模糊", "clickBounce": "点击弹跳", - "clipToBounds": "裁剪到画布" + "clipToBounds": "裁剪到画布", + "clipToBoundsDescription": "Keeps the cursor inside the video frame. Turn off to let the cursor extend past the edges - useful when zoomed in or panned." }, "language": { "title": "语言" diff --git a/src/i18n/locales/zh-CN/timeline.json b/src/i18n/locales/zh-CN/timeline.json index 783fef60f2..98854e5ae1 100644 --- a/src/i18n/locales/zh-CN/timeline.json +++ b/src/i18n/locales/zh-CN/timeline.json @@ -2,17 +2,21 @@ "buttons": { "addZoom": "添加缩放 (Z)", "suggestZooms": "根据光标建议缩放", + "autoZoomOn": "Auto zoom suggestions on — click to remove suggested zooms", + "autoZoomOff": "Auto zoom suggestions off — click to suggest zooms from cursor", + "autoFocusAllOn": "Auto-Focus on for all zooms — click to switch all to manual", + "autoFocusAllOff": "Auto-Focus all zooms (camera follows the cursor)", "addTrim": "添加剪辑 (T)", "addAnnotation": "添加标注 (A)", - "addSpeed": "添加速度 (S)", - "addBlur": "添加模糊 (B)" + "addBlur": "添加模糊 (B)", + "addSpeed": "添加速度 (S)" }, "hints": { "pressZoom": "按 Z 添加缩放", "pressTrim": "按 T 添加剪辑", "pressAnnotation": "按 A 添加标注", - "pressSpeed": "按 S 添加速度", - "pressBlur": "按 B 添加模糊区域" + "pressBlur": "按 B 添加模糊区域", + "pressSpeed": "按 S 添加速度" }, "labels": { "pan": "平移", @@ -23,9 +27,9 @@ "trimItem": "剪辑 {{index}}", "speedItem": "速度 {{index}}", "annotationItem": "标注", + "blurItem": "模糊 {{index}}", "imageItem": "图片", - "emptyText": "空文本", - "blurItem": "模糊 {{index}}" + "emptyText": "空文本" }, "emptyState": { "noVideo": "未加载视频", diff --git a/src/i18n/locales/zh-TW/dialogs.json b/src/i18n/locales/zh-TW/dialogs.json index f5ba7f9a4c..2127462a05 100644 --- a/src/i18n/locales/zh-TW/dialogs.json +++ b/src/i18n/locales/zh-TW/dialogs.json @@ -57,6 +57,8 @@ "detailLoadProject": "是否在載入其他專案前儲存目前的專案?", "saveAndLoadProject": "儲存並載入專案", "discardAndLoadProject": "捨棄並載入專案", + "newProject": "New Project", + "importVideo": "Import Video File…", "loadProject": "載入專案…", "saveProject": "儲存專案…", "saveProjectAs": "專案另存新檔…" diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json index 8a64854093..8a951fdd41 100644 --- a/src/i18n/locales/zh-TW/editor.json +++ b/src/i18n/locales/zh-TW/editor.json @@ -39,29 +39,17 @@ "systemAudioUnavailable": "系統音訊不可用。將在無系統音訊的情況下錄製。", "microphoneDenied": "麥克風權限被拒絕。錄製將繼續,但不包含音訊。", "cameraDenied": "攝影機權限被拒絕。錄製將繼續,但不包含攝影機畫面。", - "permissionDenied": "錄影權限被拒絕。請允許螢幕錄製。", "cameraDisconnected": "網路攝影機已中斷連線。", "cameraNotFound": "找不到攝影機。", + "permissionDenied": "錄影權限被拒絕。請允許螢幕錄製。", "accessibilityAllowAndRetry": "允許 OpenScreen 使用輔助使用權限,然後再次按下錄製以開始倒數。" }, - "autoCaptions": { - "button": "自動字幕", - "dialogTitle": "自動字幕", - "dialogDescription": "大致選擇每條字幕一次顯示多少字詞。時間會在該語句內的字詞之間分配。", - "minWords": "每條字幕的最少字數", - "maxWords": "每條字幕的最多字數", - "wordsCount": "{{count}} 個詞", - "generate": "產生", - "dialogCancel": "取消", - "generating": "正在從音訊產生字幕…", - "loadingModel": "正在載入語音模型(首次使用將下載約 75 MB)…", - "transcribing": "正在轉錄語音…", - "busy": "字幕產生已在進行中。", - "done": "已新增 {{count}} 條字幕。", - "noneHeard": "未偵測到語音。", - "noAudio": "此影片沒有可用於轉寫的音訊。", - "failed": "無法產生字幕。", - "truncated": "僅轉寫了最前 {{minutes}} 分鐘。" + "captions": { + "generating": "正在產生字幕...", + "generated": "已產生 {{count}} 條字幕。", + "skippedNoAudio": "找不到音軌,已略過字幕產生。", + "unavailable": "本機字幕模型不可用。", + "failed": "字幕產生失敗。" }, "emptyState": { "title": "未開啟任何專案", diff --git a/src/i18n/locales/zh-TW/launch.json b/src/i18n/locales/zh-TW/launch.json index d0b529ebe9..47cf0fb32f 100644 --- a/src/i18n/locales/zh-TW/launch.json +++ b/src/i18n/locales/zh-TW/launch.json @@ -9,7 +9,8 @@ "openVideoFile": "開啟影片檔案", "openProject": "開啟專案", "useVerticalTray": "使用直向托盤", - "useHorizontalTray": "使用橫向托盤" + "useHorizontalTray": "使用橫向托盤", + "openStudio": "Open Studio" }, "audio": { "enableSystemAudio": "啟用系統音訊", @@ -34,6 +35,9 @@ "loading": "正在載入來源...", "screens": "螢幕 ({{count}})", "windows": "視窗 ({{count}})", + "emptyTitle": "No screens or windows found", + "emptyDescription": "If you just granted screen recording permission, reload this picker. On macOS you may need to reopen OpenScreen.", + "loadFailedDescription": "OpenScreen could not load capture sources. Reload this picker and try again.", "defaultSourceName": "螢幕" }, "recording": { diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index b5fbcda8c9..7c40c3e216 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -2,15 +2,16 @@ "zoom": { "previewHold": "按住預覽放大效果", "level": "縮放級別", + "customScale": "自訂縮放", "selectRegion": "選擇要調整的縮放區域", "deleteZoom": "刪除縮放", "focusMode": { "title": "對焦模式", "manual": "手動", "auto": "自動", - "autoDescription": "攝影機跟隨錄製時的游標位置" + "autoDescription": "攝影機跟隨錄製時的游標位置", + "lockedDisclaimer": "Controlled by the global Auto-Focus toggle in the timeline. Turn it off to set focus mode per zoom." }, - "speed": {}, "threeD": { "title": "3D 旋轉", "preset": { @@ -19,7 +20,6 @@ "right": "右" } }, - "customScale": "自訂縮放", "position": { "title": "焦點位置", "x": "X (%)", @@ -44,9 +44,9 @@ "pictureInPicture": "子母畫面", "verticalStack": "垂直堆疊", "dualFrame": "雙畫框", + "noWebcam": "無網路攝影機", "webcamShape": "攝影機形狀", "webcamSize": "攝影機大小", - "noWebcam": "無網路攝影機", "mirrorWebcam": "鏡像攝影機", "reactiveWebcam": "縮放時縮小", "reactiveWebcamDescription": "放大影片時鏡頭會平滑縮小,以免遮擋內容。" @@ -56,10 +56,10 @@ "blurBg": "模糊背景", "motionBlur": "動態模糊", "off": "關", + "on": "開", "shadow": "陰影", "roundness": "圓角", - "padding": "內邊距", - "on": "開" + "padding": "內邊距" }, "background": { "title": "背景", @@ -133,8 +133,6 @@ "textPlaceholder": "輸入您的文字...", "fontStyle": "字體樣式", "selectStyle": "選擇樣式", - "textAnimation": "文字動畫", - "selectAnimation": "選擇動畫", "size": "大小", "customFonts": "自訂字體", "textColor": "文字顏色", @@ -146,11 +144,20 @@ "clearBackground": "清除背景", "uploadImage": "上傳圖片", "supportedFormats": "支援的格式:JPG、PNG、GIF、WebP", + "stickerPresets": "內建貼紙", + "stickerPresetsDescription": "點擊貼紙即可用作目前圖片標註。", "arrowDirection": "箭頭方向", "strokeWidth": "描邊寬度:{{width}}px", "arrowColor": "箭頭顏色", + "blurType": "模糊類型", + "blurTypeBlur": "模糊", + "blurTypeMosaic": "馬賽克模糊", + "blurColor": "模糊顏色", + "blurColorWhite": "白色", + "blurColorBlack": "黑色", "blurShape": "模糊形狀", "blurIntensity": "模糊強度", + "mosaicBlockSize": "馬賽克區塊大小", "blurShapeRectangle": "矩形", "blurShapeOval": "橢圓", "blurShapeFreehand": "自由手繪", @@ -162,14 +169,7 @@ "invalidImageType": "無效的檔案類型", "imageFormatsOnly": "請上傳 JPG、PNG、GIF 或 WebP 格式的圖片檔案。", "imageUploadSuccess": "圖片上傳成功!", - "failedImageUpload": "上傳圖片失敗", - "blurColor": "模糊顏色", - "blurColorBlack": "黑色", - "blurColorWhite": "白色", - "blurType": "模糊類型", - "blurTypeBlur": "模糊", - "blurTypeMosaic": "馬賽克模糊", - "mosaicBlockSize": "馬賽克區塊大小" + "failedImageUpload": "上傳圖片失敗" }, "fontStyles": { "classic": "經典", @@ -181,6 +181,17 @@ "modern": "現代", "clean": "簡潔" }, + "textAnimation": { + "title": "Text Animation", + "selectAnimation": "Select animation", + "none": "None", + "fade": "Fade", + "rise": "Rise", + "pop": "Pop", + "slideLeft": "Slide Left", + "typewriter": "Typewriter", + "pulse": "Pulse" + }, "customFont": { "dialogTitle": "新增 Google 字體", "urlLabel": "Google Fonts 匯入 URL", @@ -208,7 +219,8 @@ "smoothing": "平滑", "motionBlur": "動態模糊", "clickBounce": "點擊彈跳", - "clipToBounds": "裁切至畫布" + "clipToBounds": "裁切至畫布", + "clipToBoundsDescription": "Keeps the cursor inside the video frame. Turn off to let the cursor extend past the edges - useful when zoomed in or panned." }, "language": { "title": "語言" diff --git a/src/i18n/locales/zh-TW/timeline.json b/src/i18n/locales/zh-TW/timeline.json index cd47b74784..51b9fe6741 100644 --- a/src/i18n/locales/zh-TW/timeline.json +++ b/src/i18n/locales/zh-TW/timeline.json @@ -2,17 +2,21 @@ "buttons": { "addZoom": "新增縮放 (Z)", "suggestZooms": "根據游標建議縮放", + "autoZoomOn": "Auto zoom suggestions on — click to remove suggested zooms", + "autoZoomOff": "Auto zoom suggestions off — click to suggest zooms from cursor", + "autoFocusAllOn": "Auto-Focus on for all zooms — click to switch all to manual", + "autoFocusAllOff": "Auto-Focus all zooms (camera follows the cursor)", "addTrim": "新增剪輯 (T)", "addAnnotation": "新增標註 (A)", - "addSpeed": "新增速度 (S)", - "addBlur": "新增模糊 (B)" + "addBlur": "新增模糊 (B)", + "addSpeed": "新增速度 (S)" }, "hints": { "pressZoom": "按 Z 新增縮放", "pressTrim": "按 T 新增剪輯", "pressAnnotation": "按 A 新增標註", - "pressSpeed": "按 S 新增速度", - "pressBlur": "按 B 新增模糊區域" + "pressBlur": "按 B 新增模糊區域", + "pressSpeed": "按 S 新增速度" }, "labels": { "pan": "平移", @@ -23,9 +27,9 @@ "trimItem": "剪輯 {{index}}", "speedItem": "速度 {{index}}", "annotationItem": "標註", + "blurItem": "模糊 {{index}}", "imageItem": "圖片", - "emptyText": "空文字", - "blurItem": "模糊 {{index}}" + "emptyText": "空文字" }, "emptyState": { "noVideo": "未載入影片", diff --git a/src/lib/captioning/annotationsFromCaptions.test.ts b/src/lib/captioning/annotationsFromCaptions.test.ts deleted file mode 100644 index bbf26fed29..0000000000 --- a/src/lib/captioning/annotationsFromCaptions.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - captionSegmentsToAnnotationRegions, - groupPhraseCaptionSegmentsIntoLines, - groupTimedCaptionWordsIntoLines, - reconcileAutoCaptionTimelineGaps, -} from "./annotationsFromCaptions"; - -describe("groupPhraseCaptionSegmentsIntoLines", () => { - it("preserves phrase boundaries when formatting phrase-timestamp captions", () => { - const lines = groupPhraseCaptionSegmentsIntoLines( - [ - { startSec: 0, endSec: 0.5, text: "alpha beta" }, - { startSec: 0.62, endSec: 1.6, text: "gamma delta" }, - ], - 2, - 2, - ); - - expect(lines).toHaveLength(2); - expect(lines[0]).toMatchObject({ text: "alpha beta", startSec: 0 }); - expect(lines[1]).toMatchObject({ text: "gamma delta", startSec: 0.62 }); - expect(lines[0]!.endSec).toBeLessThanOrEqual(0.62); - }); - - it("slices a single merged phrase into timed caption lines by word bounds", () => { - const lines = groupPhraseCaptionSegmentsIntoLines( - [{ startSec: 0, endSec: 1, text: "alpha beta gamma delta" }], - 2, - 2, - ); - - expect(lines).toHaveLength(2); - expect(lines[0]).toMatchObject({ - startSec: 0, - endSec: 0.5, - text: "alpha beta", - }); - expect(lines[1]).toMatchObject({ - startSec: 0.5, - endSec: 1, - text: "gamma delta", - }); - }); -}); - -describe("captionSegmentsToAnnotationRegions", () => { - it("uses raw phrase timing instead of shifting caption boundaries", () => { - const { regions } = captionSegmentsToAnnotationRegions( - [ - { startSec: 0, endSec: 0.5, text: "first second" }, - { startSec: 0.62, endSec: 1.2, text: "third fourth" }, - ], - 1, - 1, - { minWordsPerCaption: 2, maxWordsPerCaption: 2, timestampGranularity: "phrase" }, - ); - - expect(regions).toHaveLength(2); - expect(regions[0]).toMatchObject({ startMs: 0, endMs: 500 }); - expect(regions[1]).toMatchObject({ startMs: 620, endMs: 1200 }); - }); - - it("preserves empty timeline space when word timestamps contain a real pause", () => { - const lines = groupTimedCaptionWordsIntoLines( - [ - { startSec: 0, endSec: 0.12, text: "first" }, - { startSec: 0.13, endSec: 0.28, text: "caption" }, - { startSec: 0.7, endSec: 0.83, text: "second" }, - { startSec: 0.84, endSec: 0.98, text: "caption" }, - ], - 2, - 2, - ); - - expect(lines).toHaveLength(2); - expect(lines[0]).toMatchObject({ startSec: 0, endSec: 0.28, text: "first caption" }); - expect(lines[1]).toMatchObject({ startSec: 0.7, endSec: 0.98, text: "second caption" }); - }); - - it("preserves repeated words before grouping in word mode", () => { - const { regions } = captionSegmentsToAnnotationRegions( - [ - { startSec: 0, endSec: 0.12, text: "I" }, - { startSec: 0.13, endSec: 0.25, text: "I" }, - ], - 1, - 1, - { minWordsPerCaption: 2, maxWordsPerCaption: 2, timestampGranularity: "word" }, - ); - - expect(regions).toHaveLength(1); - expect(regions[0]).toMatchObject({ content: "I I" }); - }); -}); - -describe("reconcileAutoCaptionTimelineGaps", () => { - it("does not change regions when the minimum enforced gap is zero", () => { - const regions = reconcileAutoCaptionTimelineGaps([ - { - id: "annotation-1", - startMs: 0, - endMs: 120, - type: "text", - content: "one", - annotationSource: "auto-caption", - position: { x: 0, y: 0 }, - size: { width: 10, height: 10 }, - style: { - color: "#fff", - backgroundColor: "transparent", - fontSize: 24, - fontFamily: "Inter", - fontWeight: "normal", - fontStyle: "normal", - textDecoration: "none", - textAlign: "center", - }, - zIndex: 1, - }, - { - id: "manual-1", - startMs: 50, - endMs: 1000, - type: "text", - content: "manual", - position: { x: 10, y: 10 }, - size: { width: 10, height: 10 }, - style: { - color: "#fff", - backgroundColor: "transparent", - fontSize: 24, - fontFamily: "Inter", - fontWeight: "normal", - fontStyle: "normal", - textDecoration: "none", - textAlign: "center", - }, - zIndex: 2, - }, - { - id: "annotation-2", - startMs: 130, - endMs: 300, - type: "text", - content: "two", - annotationSource: "auto-caption", - position: { x: 0, y: 0 }, - size: { width: 10, height: 10 }, - style: { - color: "#fff", - backgroundColor: "transparent", - fontSize: 24, - fontFamily: "Inter", - fontWeight: "normal", - fontStyle: "normal", - textDecoration: "none", - textAlign: "center", - }, - zIndex: 3, - }, - ]); - - expect(regions.find((r) => r.id === "manual-1")).toMatchObject({ - startMs: 50, - endMs: 1000, - }); - expect(regions.find((r) => r.id === "annotation-1")).toMatchObject({ - startMs: 0, - endMs: 120, - }); - expect(regions.find((r) => r.id === "annotation-2")).toMatchObject({ - startMs: 130, - endMs: 300, - }); - }); -}); diff --git a/src/lib/captioning/annotationsFromCaptions.ts b/src/lib/captioning/annotationsFromCaptions.ts deleted file mode 100644 index c3e22b21d4..0000000000 --- a/src/lib/captioning/annotationsFromCaptions.ts +++ /dev/null @@ -1,604 +0,0 @@ -import type { AnnotationRegion, AnnotationTextStyle } from "@/components/video-editor/types"; - -import type { CaptionSegment } from "./transcribe"; - -/** Wide lower-third bar; `position.x` is top-left as % of container, so center with (100 - width) / 2. */ -const CAPTION_WIDTH = 92; -const CAPTION_HEIGHT = 12; -const CAPTION_BOTTOM_MARGIN = 2; - -const CAPTION_POSITION = { - x: (100 - CAPTION_WIDTH) / 2, - y: 100 - CAPTION_HEIGHT - CAPTION_BOTTOM_MARGIN, -}; - -const CAPTION_SIZE = { width: CAPTION_WIDTH, height: CAPTION_HEIGHT }; - -const CAPTION_STYLE: AnnotationTextStyle = { - color: "#ffffff", - backgroundColor: "rgba(255, 255, 255, 0)", - fontSize: 24, - fontFamily: "Inter", - fontWeight: "normal", - fontStyle: "normal", - textDecoration: "none", - textAlign: "center", -}; - -/** Nudge caption starts earlier (seconds); Whisper onsets run slightly late. Do not offset ends too, that pulls lines off-screen early. */ -const AUTO_CAPTION_START_BIAS_SEC = 0; - -/** Extra hold after Whisper's segment end (seconds); model end times run early vs trailing vowels. Separate from the start bias. */ -const AUTO_CAPTION_END_HOLD_SEC = 0; - -/** Inside one Whisper phrase, sub-lines can be shorter (do not steal time from neighbors). */ -const WORD_SPLIT_MIN_SPAN_SEC = 0.02; - -/** Brief linger after the last word in a line (seconds); trimmed if it would overlap the next line. */ -const CAPTION_LINE_END_TAIL_SEC = 0; - -/** A real silence between word-level timestamps should start a new caption run. */ -const WORD_RUN_BREAK_GAP_SEC = 0.24; - -/** Min time between consecutive caption regions (seconds); keeps a visible gap so blocks don't read as one clip. Small so short pauses survive. */ -const MIN_CAPTION_TIMELINE_GAP_SEC = 0; - -/** Same text again with almost no gap or overlap; common Whisper/chunk artifact. */ -const DEDUPE_SAME_TEXT_MAX_GAP_SEC = 0.55; - -export const SAME_CONTENT_ECHO_MAX_GAP_SEC = 1.15; - -function normalizeCaptionKey(text: string): string { - return text - .trim() - .replace(/\s+/g, " ") - .replace(/[\u2018\u2019]/g, "'") - .replace(/[\u201C\u201D]/g, '"') - .toLowerCase() - .replace(/[.!?,;:]+$/g, ""); -} - -/** Legacy echo-collapse helper kept for reference while phrase timing uses raw model spans. */ -export function collapseSameContentEchoes(segments: CaptionSegment[]): CaptionSegment[] { - const sorted = [...segments] - .filter((s) => s.text.trim()) - .sort((a, b) => a.startSec - b.startSec || a.endSec - b.endSec); - const out: CaptionSegment[] = []; - const lastIndexByKey = new Map(); - - for (const seg of sorted) { - const key = normalizeCaptionKey(seg.text); - const hit = lastIndexByKey.get(key); - if (hit !== undefined) { - const prev = out[hit]!; - if (seg.startSec < prev.endSec + SAME_CONTENT_ECHO_MAX_GAP_SEC) { - prev.startSec = Math.min(prev.startSec, seg.startSec); - prev.endSec = Math.max(prev.endSec, seg.endSec); - continue; - } - } - out.push({ - startSec: seg.startSec, - endSec: seg.endSec, - text: seg.text.trim(), - }); - lastIndexByKey.set(key, out.length - 1); - } - return out; -} - -/** - * Collapse adjacent duplicate lines (overlapping or tiny gap). Does not merge the same phrase - * repeated later in the video when separated by real silence. - */ -function dedupeAdjacentCaptionRepeats(segments: CaptionSegment[]): CaptionSegment[] { - const sorted = [...segments] - .filter((s) => s.text.trim()) - .sort((a, b) => a.startSec - b.startSec || a.endSec - b.endSec); - const out: CaptionSegment[] = []; - for (const seg of sorted) { - const t = seg.text.trim(); - const prev = out[out.length - 1]; - if (prev && normalizeCaptionKey(prev.text) === normalizeCaptionKey(t)) { - const overlap = prev.endSec - seg.startSec; - const gap = seg.startSec - prev.endSec; - if (overlap > 0.015 || gap < DEDUPE_SAME_TEXT_MAX_GAP_SEC) { - prev.startSec = Math.min(prev.startSec, seg.startSec); - prev.endSec = Math.max(prev.endSec, seg.endSec); - continue; - } - } - out.push({ startSec: seg.startSec, endSec: seg.endSec, text: t }); - } - return out; -} - -/** Trim only real overlaps. Avoid synthetic lead/lag so caption timing matches model output. */ -function finalizeCaptionSegmentsForPlayback(segments: CaptionSegment[]): CaptionSegment[] { - const OVERLAP_TRIM_SEC = 0.002; - - const sortedRaw = [...segments] - .filter((s) => s.text.trim()) - .sort((a, b) => a.startSec - b.startSec || a.endSec - b.endSec); - - const a = sortedRaw.map((seg) => { - let s = seg.startSec + AUTO_CAPTION_START_BIAS_SEC; - let e = seg.endSec + AUTO_CAPTION_END_HOLD_SEC; - s = Math.max(0, s); - if (e <= s) e = s + 0.02; - return { startSec: s, endSec: e, text: seg.text.trim() }; - }); - - for (let i = 1; i < a.length; i++) { - if (a[i].startSec < a[i - 1].endSec - OVERLAP_TRIM_SEC) { - a[i - 1].endSec = Math.max(a[i - 1].startSec + 1e-4, a[i].startSec); - } - } - - return a; -} - -/** Default min gap between auto-caption blocks on the timeline (ms); matches `MIN_CAPTION_TIMELINE_GAP_SEC`. */ -export const DEFAULT_AUTO_CAPTION_MIN_GAP_MS = Math.round(MIN_CAPTION_TIMELINE_GAP_SEC * 1000); - -/** - * Enforce a min gap between consecutive `auto-caption` regions (by start time). Shortens the previous - * region's end when possible, else shifts the following region later so blocks can't sit completely flush. - */ -export function reconcileAutoCaptionTimelineGaps( - regions: AnnotationRegion[], - minGapMs: number = DEFAULT_AUTO_CAPTION_MIN_GAP_MS, -): AnnotationRegion[] { - const gap = Math.max(0, Math.round(minGapMs)); - if (regions.length === 0 || gap === 0) return regions; - - const autoCandidates = regions.filter((r) => r.annotationSource === "auto-caption"); - if (autoCandidates.length <= 1) return regions; - - const sorted = [...autoCandidates].sort((a, b) => a.startMs - b.startMs || a.endMs - b.endMs); - const fixed: AnnotationRegion[] = []; - let prev = { ...sorted[0]! }; - fixed.push(prev); - - for (let i = 1; i < sorted.length; i++) { - let cur = { ...sorted[i]! }; - const minStart = prev.endMs + gap; - - if (cur.startMs < minStart) { - const newPrevEnd = cur.startMs - gap; - if (newPrevEnd >= prev.startMs + 1) { - prev = { ...prev, endMs: newPrevEnd }; - fixed[fixed.length - 1] = prev; - } else { - const dur = Math.max(1, cur.endMs - cur.startMs); - cur = { ...cur, startMs: minStart, endMs: minStart + dur }; - } - } - - fixed.push(cur); - prev = cur; - } - - const fixedById = new Map(fixed.map((r) => [r.id, r])); - return regions.map((r) => fixedById.get(r.id) ?? r); -} - -/** Join phrases that are close in time so the editor does not create dozens of separate overlays. */ -export function mergeAdjacentCaptionSegments( - segments: CaptionSegment[], - options?: { maxGapSec?: number; maxChars?: number; maxBlockDurationSec?: number }, -): CaptionSegment[] { - const maxGapSec = options?.maxGapSec ?? 1.35; - const maxChars = options?.maxChars ?? 320; - const maxBlockDurationSec = options?.maxBlockDurationSec ?? 12; - - const sorted = [...segments].sort((a, b) => a.startSec - b.startSec); - const out: CaptionSegment[] = []; - - for (const seg of sorted) { - const text = seg.text.trim(); - if (!text) continue; - - const prev = out[out.length - 1]; - if (!prev) { - out.push({ startSec: seg.startSec, endSec: seg.endSec, text }); - continue; - } - - const gap = seg.startSec - prev.endSec; - const mergedText = `${prev.text} ${text}`.trim(); - const mergedEnd = Math.max(prev.endSec, seg.endSec); - const wouldSpan = mergedEnd - prev.startSec; - if (gap <= maxGapSec && mergedText.length <= maxChars && wouldSpan <= maxBlockDurationSec) { - prev.endSec = mergedEnd; - prev.text = mergedText; - } else { - out.push({ startSec: seg.startSec, endSec: seg.endSec, text }); - } - } - - return out; -} - -function partitionPhraseCaptionSegments( - segments: CaptionSegment[], - options?: { maxGapSec?: number; maxChars?: number; maxBlockDurationSec?: number }, -): CaptionSegment[][] { - const maxGapSec = options?.maxGapSec ?? 0; - const maxChars = options?.maxChars ?? Number.POSITIVE_INFINITY; - const maxBlockDurationSec = options?.maxBlockDurationSec ?? Number.POSITIVE_INFINITY; - - const sorted = [...segments] - .filter((s) => s.text.trim()) - .sort((a, b) => a.startSec - b.startSec || a.endSec - b.endSec); - if (sorted.length === 0) return []; - - const groups: CaptionSegment[][] = []; - let current: CaptionSegment[] = []; - - for (const seg of sorted) { - const text = seg.text.trim(); - if (!text) continue; - - if (current.length === 0) { - current.push({ ...seg, text }); - continue; - } - - const prev = current[current.length - 1]!; - const groupStart = current[0]!.startSec; - const gap = seg.startSec - prev.endSec; - const currentChars = current.reduce((sum, item) => sum + item.text.length, 0); - const wouldChars = currentChars + 1 + text.length; - const wouldSpan = Math.max(prev.endSec, seg.endSec) - groupStart; - - if (gap <= maxGapSec && wouldChars <= maxChars && wouldSpan <= maxBlockDurationSec) { - current.push({ ...seg, text }); - continue; - } - - groups.push(current); - current = [{ ...seg, text }]; - } - - if (current.length > 0) { - groups.push(current); - } - - return groups; -} - -export interface CaptionSegmentLayoutOptions { - /** Lower bound on words per on-screen caption (default 2). */ - minWordsPerCaption?: number; - /** Upper bound on words per on-screen caption (default 7). */ - maxWordsPerCaption?: number; - /** - * `word`: each `CaptionSegment` is a single token with Whisper word timestamps (default). - * `phrase`: merged phrase spans; use proportional line splitting inside each span. - */ - timestampGranularity?: "word" | "phrase"; -} - -function computeCaptionLineIndexRanges( - wordCount: number, - minWords: number, - maxWords: number, -): Array<{ from: number; to: number }> { - const minW = Math.max(1, Math.min(Math.floor(minWords), Math.floor(maxWords))); - const maxW = Math.max(minW, Math.floor(maxWords)); - const sliceRanges: Array<{ from: number; to: number }> = []; - let i = 0; - while (i < wordCount) { - const remaining = wordCount - i; - if (remaining <= maxW) { - if (sliceRanges.length > 0 && remaining < minW) { - sliceRanges[sliceRanges.length - 1]!.to = wordCount; - } else { - sliceRanges.push({ from: i, to: wordCount }); - } - break; - } - - let take = maxW; - const after = remaining - take; - if (after > 0 && after < minW) { - take = remaining - minW; - if (take < minW) { - sliceRanges.push({ from: i, to: wordCount }); - break; - } - if (take > maxW) { - take = maxW; - } - } - sliceRanges.push({ from: i, to: i + take }); - i += take; - } - return sliceRanges; -} - -/** - * Groups per-word segments into on-screen lines using each token's Whisper timestamps - * (no proportional stretching across a long phrase span). - */ -export function groupTimedCaptionWordsIntoLines( - segments: CaptionSegment[], - minWords: number, - maxWords: number, -): CaptionSegment[] { - const words = [...segments] - .filter((s) => s.text.trim()) - .sort((a, b) => a.startSec - b.startSec || a.endSec - b.endSec); - if (words.length === 0) return []; - - const minW = Math.max(1, Math.min(Math.floor(minWords), Math.floor(maxWords))); - const maxW = Math.max(minW, Math.floor(maxWords)); - const out: CaptionSegment[] = []; - - let runStart = 0; - const flushRun = (runEndExclusive: number) => { - const run = words.slice(runStart, runEndExclusive); - if (run.length === 0) return; - const ranges = computeCaptionLineIndexRanges(run.length, minW, maxW); - for (const { from, to } of ranges) { - const slice = run.slice(from, to); - const s = slice[0]!.startSec; - const rawEnd = slice[slice.length - 1]!.endSec; - const e = Math.max(s + WORD_SPLIT_MIN_SPAN_SEC, rawEnd + CAPTION_LINE_END_TAIL_SEC); - out.push({ - startSec: s, - endSec: e, - text: slice.map((w) => w.text.trim()).join(" "), - }); - } - }; - - for (let i = 1; i < words.length; i++) { - const prev = words[i - 1]!; - const cur = words[i]!; - const gap = cur.startSec - prev.endSec; - if (gap >= WORD_RUN_BREAK_GAP_SEC) { - flushRun(i); - runStart = i; - } - } - flushRun(words.length); - - for (let i = 0; i < out.length - 1; i++) { - if (out[i]!.endSec > out[i + 1]!.startSec + 1e-3) { - out[i]!.endSec = Math.max( - out[i]!.startSec + WORD_SPLIT_MIN_SPAN_SEC, - out[i + 1]!.startSec - 1e-4, - ); - } - } - return out; -} - -/** - * Splits each merged transcription span into shorter captions with about - * `minWords`-`maxWords` words. Times are interpolated by character weight inside the span. - */ -export function splitMergedCaptionsByWordBounds( - merged: CaptionSegment[], - minWords: number, - maxWords: number, -): CaptionSegment[] { - const minW = Math.max(1, Math.min(Math.floor(minWords), Math.floor(maxWords))); - const maxW = Math.max(minW, Math.floor(maxWords)); - const out: CaptionSegment[] = []; - - for (const seg of merged) { - const words = seg.text.trim().split(/\s+/).filter(Boolean); - if (words.length === 0) continue; - - if (words.length <= maxW) { - out.push({ - startSec: seg.startSec, - endSec: seg.endSec, - text: words.join(" "), - }); - continue; - } - - out.push(...splitOneSegmentByWordBounds(seg.startSec, seg.endSec, words, minW, maxW)); - } - - return out; -} - -function wrapCaptionTextByWordBounds(text: string, minWords: number, maxWords: number): string { - const words = text.trim().split(/\s+/).filter(Boolean); - if (words.length === 0) return ""; - const minW = Math.max(1, Math.min(Math.floor(minWords), Math.floor(maxWords))); - const maxW = Math.max(minW, Math.floor(maxWords)); - const ranges = computeCaptionLineIndexRanges(words.length, minW, maxW); - return ranges.map(({ from, to }) => words.slice(from, to).join(" ")).join("\n"); -} - -function expandPhraseSegmentToPseudoWords(segment: CaptionSegment): CaptionSegment[] { - const words = segment.text.trim().split(/\s+/).filter(Boolean); - if (words.length === 0) return []; - if (words.length === 1) { - return [ - { - startSec: segment.startSec, - endSec: segment.endSec, - text: words[0]!, - }, - ]; - } - - return splitOneSegmentByWordBounds(segment.startSec, segment.endSec, words, 1, 1); -} - -export function groupPhraseCaptionSegmentsIntoLines( - segments: CaptionSegment[], - minWords: number, - maxWords: number, - options?: { maxGapSec?: number; maxChars?: number; maxBlockDurationSec?: number }, -): CaptionSegment[] { - const groups = partitionPhraseCaptionSegments(segments, options); - const out: CaptionSegment[] = []; - - for (const group of groups) { - if (group.length === 1) { - const only = group[0]!; - const wrapped = wrapCaptionTextByWordBounds(only.text, minWords, maxWords).trim(); - if (!wrapped) continue; - const lineTexts = wrapped - .split("\n") - .map((t) => t.trim()) - .filter(Boolean); - const n = lineTexts.length; - const rawDur = only.endSec - only.startSec; - if (n > 1 && rawDur < n * WORD_SPLIT_MIN_SPAN_SEC) { - out.push({ - startSec: only.startSec, - endSec: only.endSec, - text: lineTexts.join(" "), - }); - continue; - } - const dur = Math.max(rawDur, WORD_SPLIT_MIN_SPAN_SEC * n); - if (n <= 1) { - out.push({ - startSec: only.startSec, - endSec: only.endSec, - text: lineTexts[0] ?? wrapped, - }); - continue; - } - for (let i = 0; i < n; i++) { - const startSec = only.startSec + (dur * i) / n; - const boundary = only.startSec + (dur * (i + 1)) / n; - const endSec = - i === n - 1 ? only.endSec : Math.max(startSec + WORD_SPLIT_MIN_SPAN_SEC, boundary); - out.push({ - startSec, - endSec, - text: lineTexts[i]!, - }); - } - continue; - } - - const pseudoWords = group.flatMap(expandPhraseSegmentToPseudoWords); - out.push(...groupTimedCaptionWordsIntoLines(pseudoWords, minWords, maxWords)); - } - - return out; -} - -function splitOneSegmentByWordBounds( - startSec: number, - endSec: number, - words: string[], - minWords: number, - maxWords: number, -): CaptionSegment[] { - const sliceRanges = computeCaptionLineIndexRanges(words.length, minWords, maxWords); - - const dur = Math.max(endSec - startSec, 0.05); - const weights = words.map((w) => Math.max(1, w.length)); - const totalW = weights.reduce((a, b) => a + b, 0); - - const weightSum = (from: number, to: number) => { - let s = 0; - for (let k = from; k < to; k++) s += weights[k] ?? 0; - return s; - }; - - const result: CaptionSegment[] = []; - let prevEnd = startSec; - for (const { from, to } of sliceRanges) { - const wb = weightSum(0, from); - const ws = weightSum(from, to); - let s = startSec + (wb / totalW) * dur; - let e = startSec + ((wb + ws) / totalW) * dur; - s = Math.max(s, prevEnd); - e = Math.max(s + WORD_SPLIT_MIN_SPAN_SEC, e); - e = Math.min(e, endSec); - if (e <= s) { - e = Math.min(endSec, s + WORD_SPLIT_MIN_SPAN_SEC); - } - prevEnd = e; - result.push({ - startSec: s, - endSec: e, - text: words.slice(from, to).join(" "), - }); - } - if (result.length > 0) { - result[result.length - 1].endSec = endSec; - for (let i = 0; i < result.length - 1; i++) { - if (result[i].endSec > result[i + 1].startSec + 0.002) { - result[i].endSec = Math.max(result[i].startSec + 1e-4, result[i + 1].startSec); - } - } - } - return result; -} - -export function captionSegmentsToAnnotationRegions( - segments: CaptionSegment[], - startNumericId: number, - startZIndex: number, - layout?: CaptionSegmentLayoutOptions, -): { regions: AnnotationRegion[]; nextNumericId: number; nextZIndex: number } { - // Don't echo-collapse raw word tokens before grouping: repeated words ("I … I") share a - // normalized key and would merge spans while keeping only the first token's text. - const minW = layout?.minWordsPerCaption ?? 2; - const maxW = layout?.maxWordsPerCaption ?? 7; - const granularity = layout?.timestampGranularity ?? "word"; - - const grouped = - granularity === "phrase" - ? groupPhraseCaptionSegmentsIntoLines(segments, minW, maxW) - : groupTimedCaptionWordsIntoLines(segments, minW, maxW); - - const dedupedOut = dedupeAdjacentCaptionRepeats(grouped); - const finalized = finalizeCaptionSegmentsForPlayback(dedupedOut); - - let nid = startNumericId; - let z = startZIndex; - const regions: AnnotationRegion[] = []; - - for (const seg of finalized) { - const startMs = Math.round(seg.startSec * 1000); - const endMs = Math.max(Math.round(seg.endSec * 1000), startMs + 1); - regions.push({ - id: `annotation-${nid++}`, - startMs, - endMs, - type: "text", - content: seg.text, - annotationSource: "auto-caption", - position: { ...CAPTION_POSITION }, - size: { ...CAPTION_SIZE }, - style: { ...CAPTION_STYLE }, - zIndex: z++, - }); - } - - return { - regions: reconcileAutoCaptionTimelineGaps(regions), - nextNumericId: nid, - nextZIndex: z, - }; -} - -export function maxAnnotationNumericId(regions: AnnotationRegion[]): number { - let max = 0; - for (const r of regions) { - const m = /^annotation-(\d+)$/.exec(r.id); - if (m) max = Math.max(max, Number.parseInt(m[1], 10)); - } - return max; -} - -export function maxAnnotationZIndex(regions: AnnotationRegion[]): number { - if (regions.length === 0) return 0; - return Math.max(...regions.map((r) => r.zIndex)); -} diff --git a/src/lib/captioning/captionConstants.ts b/src/lib/captioning/captionConstants.ts deleted file mode 100644 index 1bacb7cc7a..0000000000 --- a/src/lib/captioning/captionConstants.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** Max audio length for auto-captions (decode + transcribe); keep demuxer read aligned with this. */ -export const MAX_CAPTION_AUDIO_SEC = 4 * 60 * 60; diff --git a/src/lib/captioning/extractMono16k.ts b/src/lib/captioning/extractMono16k.ts deleted file mode 100644 index bce9a9fd8f..0000000000 --- a/src/lib/captioning/extractMono16k.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { MAX_CAPTION_AUDIO_SEC } from "./captionConstants"; -import { extractMonoPcmViaWebDemuxer } from "./extractMono16kWebDemuxer"; - -export { MAX_CAPTION_AUDIO_SEC }; - -const FETCH_TIMEOUT_MS = 120_000; - -async function fetchWithTimeout(url: string, signal?: AbortSignal): Promise { - const ctrl = new AbortController(); - const timer = window.setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS); - const onAbort = () => ctrl.abort(); - if (signal) { - if (signal.aborted) ctrl.abort(); - else signal.addEventListener("abort", onAbort, { once: true }); - } - try { - return await fetch(url, { signal: ctrl.signal }); - } finally { - window.clearTimeout(timer); - if (signal) signal.removeEventListener("abort", onAbort); - } -} - -/** - * Load the editor video like `StreamingVideoDecoder`: Electron `readBinaryFile` - * for local paths (fetch(file://) is unreliable in the renderer), otherwise - * HTTP/blob/data URLs via fetch. - */ -async function loadSourceVideoFile(videoUrl: string, signal?: AbortSignal): Promise { - const isRemoteUrl = /^(https?:|blob:|data:)/i.test(videoUrl); - - if (!isRemoteUrl && window.electronAPI?.readBinaryFile) { - const result = await window.electronAPI.readBinaryFile(videoUrl); - if (!result.success || !result.data) { - throw new Error(result.message || result.error || "Failed to read source video"); - } - const filename = (result.path || videoUrl).split(/[\\/]/).pop() || "video"; - return new File([result.data], filename, { type: "video/webm" }); - } - - const response = await fetchWithTimeout(videoUrl, signal); - if (!response.ok) { - throw new Error(`Failed to load video for captions: ${response.status} ${response.statusText}`); - } - const blob = await response.blob(); - if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); - const filename = videoUrl.split("/").pop() || "video"; - return new File([blob], filename, { type: blob.type || "video/webm" }); -} - -function mixToMono(audioBuffer: AudioBuffer): Float32Array { - const { length, numberOfChannels } = audioBuffer; - const out = new Float32Array(length); - if (numberOfChannels === 0) return out; - for (let i = 0; i < length; i++) { - let sum = 0; - for (let c = 0; c < numberOfChannels; c++) { - sum += audioBuffer.getChannelData(c)[i]; - } - out[i] = sum / numberOfChannels; - } - return out; -} - -async function resampleMono( - mono: Float32Array, - fromRate: number, - toRate: number, - signal?: AbortSignal, -): Promise { - if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); - if (fromRate === toRate) return mono; - const durationSec = mono.length / fromRate; - const outLength = Math.max(1, Math.ceil(durationSec * toRate)); - const offline = new OfflineAudioContext(1, outLength, toRate); - const buf = offline.createBuffer(1, mono.length, fromRate); - buf.copyToChannel(Float32Array.from(mono), 0); - const src = offline.createBufferSource(); - src.buffer = buf; - src.connect(offline.destination); - src.start(0); - const rendered = await offline.startRendering(); - if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); - return rendered.getChannelData(0).slice(); -} - -async function truncateAndResampleTo16k( - mono: Float32Array, - fromRate: number, - durationSec: number, - signal?: AbortSignal, -): Promise<{ samples: Float32Array; truncated: boolean; durationSec: number }> { - let truncated = false; - let work = mono; - if (durationSec > MAX_CAPTION_AUDIO_SEC) { - const maxSamples = Math.floor(MAX_CAPTION_AUDIO_SEC * fromRate); - work = mono.subarray(0, Math.min(mono.length, maxSamples)); - truncated = true; - } - - const samples = await resampleMono(work, fromRate, 16_000, signal); - return { samples, truncated, durationSec: samples.length / 16_000 }; -} - -/** - * Decode the video's audio track to mono 16 kHz float samples (Whisper input). - * Prefers `decodeAudioData` when the container is supported, else the same - * web-demuxer + AudioDecoder path as export. - */ -export async function extractMono16kFromVideoUrl( - videoUrl: string, - options?: { signal?: AbortSignal }, -): Promise<{ samples: Float32Array; truncated: boolean; durationSec: number }> { - const file = await loadSourceVideoFile(videoUrl, options?.signal); - - /** When this returns null, use web-demuxer + AudioDecoder (same as export). */ - const tryDecodeAudioDataPath = async (): Promise<{ - samples: Float32Array; - truncated: boolean; - durationSec: number; - } | null> => { - const audioContext = new AudioContext(); - try { - const ab = await file.arrayBuffer(); - if (options?.signal?.aborted) throw new DOMException("Aborted", "AbortError"); - const audioBuffer = await audioContext.decodeAudioData(ab.slice(0)); - if ( - audioBuffer.numberOfChannels === 0 || - audioBuffer.length === 0 || - !Number.isFinite(audioBuffer.duration) || - audioBuffer.duration <= 0 - ) { - return null; - } - const durationSec = audioBuffer.duration; - const mono = mixToMono(audioBuffer); - const fromRate = audioBuffer.sampleRate; - const out = await truncateAndResampleTo16k(mono, fromRate, durationSec, options?.signal); - // decodeAudioData can resolve for some WebM/Matroska inputs yet yield almost no usable - // PCM, and captions only fall back to the demuxer path on throw, so return null to recover. - if (out.samples.length < 800) { - return null; - } - return out; - } catch { - return null; - } finally { - await audioContext.close().catch(() => undefined); - } - }; - - const primary = await tryDecodeAudioDataPath(); - if (primary) { - return primary; - } - - const pcm = await extractMonoPcmViaWebDemuxer(file, options?.signal); - return truncateAndResampleTo16k(pcm.mono, pcm.sampleRate, pcm.durationSec, options?.signal); -} diff --git a/src/lib/captioning/extractMono16kWebDemuxer.ts b/src/lib/captioning/extractMono16kWebDemuxer.ts deleted file mode 100644 index f86b6dc572..0000000000 --- a/src/lib/captioning/extractMono16kWebDemuxer.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { WebDemuxer } from "web-demuxer"; - -import { MAX_CAPTION_AUDIO_SEC } from "./captionConstants"; - -const DECODE_QUEUE_BACKPRESSURE = 20; -const SOURCE_LOAD_TIMEOUT_MS = 60_000; -const READ_END_PADDING_SEC = 0.5; - -function webDemuxerWasmUrl(): string { - return new URL("../exporter/wasm/web-demuxer.wasm", window.location.href).href; -} - -function audioDataFrameToMono(frame: AudioData): Float32Array { - const frames = frame.numberOfFrames; - const ch = frame.numberOfChannels; - const out = new Float32Array(frames); - const fmt = frame.format || ""; - const planar = fmt.includes("planar"); - - if (planar) { - const plane = new Float32Array(frames); - for (let c = 0; c < ch; c++) { - frame.copyTo(plane, { planeIndex: c }); - for (let i = 0; i < frames; i++) { - out[i] += plane[i]; - } - } - for (let i = 0; i < frames; i++) { - out[i] /= ch; - } - } else { - const interleaved = new Float32Array(frames * ch); - frame.copyTo(interleaved, { planeIndex: 0 }); - for (let i = 0; i < frames; i++) { - let sum = 0; - for (let c = 0; c < ch; c++) { - sum += interleaved[i * ch + c]; - } - out[i] = sum / ch; - } - } - return out; -} - -function mergeAndConsumeDecodedAudioToMonoLinear( - frames: AudioData[], - sampleRate: number, - durationSec: number, -): Float32Array { - const sorted = [...frames].sort((a, b) => a.timestamp - b.timestamp); - const totalSamples = Math.max(1, Math.ceil(durationSec * sampleRate)); - const acc = new Float32Array(totalSamples); - const weight = new Float32Array(totalSamples); - - for (const frame of sorted) { - const startSample = Math.round((frame.timestamp / 1e6) * sampleRate); - const slice = audioDataFrameToMono(frame); - for (let i = 0; i < slice.length; i++) { - const pos = startSample + i; - if (pos >= 0 && pos < totalSamples) { - acc[pos] += slice[i]; - weight[pos] += 1; - } - } - frame.close(); - } - - for (let i = 0; i < totalSamples; i++) { - if (weight[i] > 0) { - acc[i] /= weight[i]; - } - } - return acc; -} - -function withTimeout(promise: Promise, ms: number, message: string): Promise { - return new Promise((resolve, reject) => { - const id = window.setTimeout(() => reject(new Error(message)), ms); - promise - .then((v) => { - window.clearTimeout(id); - resolve(v); - }) - .catch((e) => { - window.clearTimeout(id); - reject(e instanceof Error ? e : new Error(String(e))); - }); - }); -} - -/** - * Demux + WebCodecs audio decode (same stack as export). Use when `decodeAudioData` - * can't handle the container (e.g. WebM with video). - */ -export async function extractMonoPcmViaWebDemuxer( - file: File, - signal?: AbortSignal, -): Promise<{ mono: Float32Array; sampleRate: number; durationSec: number }> { - const demuxer = new WebDemuxer({ wasmFilePath: webDemuxerWasmUrl() }); - await withTimeout( - demuxer.load(file), - SOURCE_LOAD_TIMEOUT_MS, - "Timed out while parsing the source video for captions.", - ); - - if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); - - const mediaInfo = await withTimeout( - demuxer.getMediaInfo(), - SOURCE_LOAD_TIMEOUT_MS, - "Timed out while reading media info for captions.", - ); - - const reportedDurationSec = - Number.isFinite(mediaInfo.duration) && mediaInfo.duration > 0 ? mediaInfo.duration : 0; - - let audioConfig: AudioDecoderConfig; - try { - audioConfig = await demuxer.getDecoderConfig("audio"); - } catch { - throw new Error("No audio track found in this video."); - } - - const codecCheck = await AudioDecoder.isConfigSupported(audioConfig); - if (!codecCheck.supported) { - throw new Error(`Audio codec not supported for captions: ${audioConfig.codec}`); - } - - const sampleRate = audioConfig.sampleRate || 48_000; - - // Many WebM/Matroska files report a too-short duration, so capping read at reported time stops - // demux early and clips everything past that. Read to the caption-decode ceiling instead; the - // demuxer stops when the track ends. - const readEndSec = MAX_CAPTION_AUDIO_SEC + READ_END_PADDING_SEC; - const decodedFrames: AudioData[] = []; - - const decoder = new AudioDecoder({ - output: (data: AudioData) => decodedFrames.push(data), - error: (e: DOMException) => console.error("[captioning] AudioDecoder error:", e), - }); - decoder.configure(audioConfig); - - const reader = demuxer.read("audio", 0, readEndSec).getReader(); - try { - while (!signal?.aborted) { - const { done, value: chunk } = await reader.read(); - if (done || !chunk) break; - decoder.decode(chunk); - while (decoder.decodeQueueSize > DECODE_QUEUE_BACKPRESSURE && !signal?.aborted) { - await new Promise((r) => setTimeout(r, 1)); - } - } - } finally { - try { - await reader.cancel(); - } catch { - /* already closed */ - } - } - - if (decoder.state === "configured") { - await decoder.flush(); - decoder.close(); - } - - if (signal?.aborted) { - for (const f of decodedFrames) f.close(); - throw new DOMException("Aborted", "AbortError"); - } - - if (decodedFrames.length === 0) { - throw new Error("Decoded zero audio frames from this video."); - } - - let maxEndUs = 0; - for (const f of decodedFrames) { - const end = f.timestamp + (f.duration ?? 0); - if (end > maxEndUs) maxEndUs = end; - } - const inferredDurationSec = maxEndUs / 1e6; - // Prefer extent implied by decoded frames (fixes bad container duration); fall back to reported - // metadata when frames lack duration. - const durationSec = inferredDurationSec > 0.02 ? inferredDurationSec : reportedDurationSec; - - const mono = mergeAndConsumeDecodedAudioToMonoLinear(decodedFrames, sampleRate, durationSec); - return { mono, sampleRate, durationSec }; -} diff --git a/src/lib/captioning/index.ts b/src/lib/captioning/index.ts deleted file mode 100644 index cc2e2a3a6e..0000000000 --- a/src/lib/captioning/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -export type { CaptionSegmentLayoutOptions } from "./annotationsFromCaptions"; -export { - captionSegmentsToAnnotationRegions, - DEFAULT_AUTO_CAPTION_MIN_GAP_MS, - groupTimedCaptionWordsIntoLines, - mergeAdjacentCaptionSegments, - reconcileAutoCaptionTimelineGaps, - splitMergedCaptionsByWordBounds, -} from "./annotationsFromCaptions"; -export { extractMono16kFromVideoUrl, MAX_CAPTION_AUDIO_SEC } from "./extractMono16k"; -export { shiftTrimRegionsMsForCaptionBuffer, trimLeadingSilenceMono16k } from "./leadingSilence"; -export type { - CaptionSegment, - CaptionTimestampGranularity, - TranscribeMono16kResult, -} from "./transcribe"; -export { transcribeMono16kToSegments } from "./transcribe"; diff --git a/src/lib/captioning/leadingSilence.ts b/src/lib/captioning/leadingSilence.ts deleted file mode 100644 index 4bd6a11aa3..0000000000 --- a/src/lib/captioning/leadingSilence.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** Caption path is always mono 16 kHz after `extractMono16kFromVideoUrl`. */ -import type { TrimRegion } from "@/components/video-editor/types"; - -const SAMPLE_RATE = 16_000; - -/** Window length for peak detection (~50 ms). */ -const WINDOW_SAMPLES = 800; - -/** Coarse hop so long intros scan quickly (~50 ms steps). */ -const HOP_SAMPLES = 800; - -/** Max |sample| in a window below this counts as silence (float PCM ~[-1, 1]). */ -const PEAK_THRESHOLD = 0.012; - -/** Keep a little audio before the first peak so word onsets are not clipped. */ -const PRE_ROLL_SEC = 0.12; - -/** Do not scan more than this much audio for leading silence (performance + pathological files). */ -const MAX_LEADING_SCAN_SEC = 15 * 60; - -/** - * Drops quiet audio at the beginning so Whisper is not fed a long silent prefix (which can skew - * the first phrase and wastes work). Returned `trimSec` must be added back to every segment time. - */ -export function trimLeadingSilenceMono16k(samples: Float32Array): { - samples: Float32Array; - trimSec: number; -} { - if (samples.length < WINDOW_SAMPLES) { - return { samples, trimSec: 0 }; - } - - const maxIndex = Math.min( - samples.length - WINDOW_SAMPLES, - Math.floor(MAX_LEADING_SCAN_SEC * SAMPLE_RATE), - ); - - let firstSpeechSample = -1; - for (let i = 0; i <= maxIndex; i += HOP_SAMPLES) { - let peak = 0; - for (let j = 0; j < WINDOW_SAMPLES; j++) { - peak = Math.max(peak, Math.abs(samples[i + j]!)); - } - if (peak > PEAK_THRESHOLD) { - firstSpeechSample = i; - break; - } - } - - if (firstSpeechSample <= 0) { - return { samples, trimSec: 0 }; - } - - const preRollSamples = Math.round(PRE_ROLL_SEC * SAMPLE_RATE); - const start = Math.max(0, firstSpeechSample - preRollSamples); - return { - samples: samples.subarray(start), - trimSec: start / SAMPLE_RATE, - }; -} - -/** - * When audio is trimmed from the front, Whisper times are relative to the shortened buffer. - * Shift trim regions by the same offset so `segmentOverlapsTrim` still uses consistent coordinates. - */ -export function shiftTrimRegionsMsForCaptionBuffer( - regions: TrimRegion[], - trimMs: number, -): TrimRegion[] { - if (trimMs <= 0) return regions; - return regions - .map((r) => ({ - ...r, - startMs: Math.max(0, r.startMs - trimMs), - endMs: Math.max(0, r.endMs - trimMs), - })) - .filter((r) => r.endMs > r.startMs); -} diff --git a/src/lib/captioning/transcribe.ts b/src/lib/captioning/transcribe.ts deleted file mode 100644 index a72a89673d..0000000000 --- a/src/lib/captioning/transcribe.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type { TrimRegion } from "@/components/video-editor/types"; - -export interface CaptionSegment { - startSec: number; - endSec: number; - text: string; -} - -/** How caption layout should interpret `CaptionSegment` times from `transcribeMono16kToSegments`. */ -export type CaptionTimestampGranularity = "word" | "phrase"; - -export interface TranscribeMono16kResult { - segments: CaptionSegment[]; - granularity: CaptionTimestampGranularity; -} - -/** Request payload posted from the renderer to the transcription worker. */ -export interface TranscribeWorkerRequest { - samples: Float32Array; - trimRegions: TrimRegion[]; - /** - * Load the Whisper model + ORT wasm from bundled `caption-assets` instead of remote CDNs. - * Required in the packaged app (runs from `file://` where remote fetches fail). The worker - * can't read `window.electronAPI`, so the renderer resolves this here. - */ - useLocalModels: boolean; - /** Base URL of bundled resources (packaged: resourcesPath file:// URL); used when `useLocalModels`. */ - assetBaseUrl?: string; -} - -/** Messages the transcription worker posts back to the renderer. */ -export type TranscribeWorkerResponse = - | { type: "status"; phase: "model" | "transcribe" } - | { type: "result"; segments: CaptionSegment[]; granularity: CaptionTimestampGranularity } - | { type: "error"; message: string }; - -/** - * Transcribes mono 16 kHz audio into timed caption segments using in-browser Whisper. - * - * Runs in a Web Worker so the editor's main thread stays responsive (WASM inference - * doesn't yield). First run downloads model weights. Aborting via `options.signal` - * terminates the worker, since load/inference can't be cooperatively cancelled. - */ -export function transcribeMono16kToSegments( - samples: Float32Array, - options?: { - trimRegions?: TrimRegion[]; - onStatus?: (phase: "model" | "transcribe") => void; - signal?: AbortSignal; - }, -): Promise { - if (options?.signal?.aborted) { - return Promise.reject(new DOMException("Aborted", "AbortError")); - } - - return new Promise((resolve, reject) => { - const worker = new Worker(new URL("./transcribe.worker.ts", import.meta.url), { - type: "module", - }); - - let settled = false; - const finish = (fn: () => void) => { - if (settled) return; - settled = true; - options?.signal?.removeEventListener("abort", onAbort); - worker.terminate(); - fn(); - }; - - const onAbort = () => finish(() => reject(new DOMException("Aborted", "AbortError"))); - options?.signal?.addEventListener("abort", onAbort, { once: true }); - - worker.onmessage = (e: MessageEvent) => { - const msg = e.data; - if (msg.type === "status") { - options?.onStatus?.(msg.phase); - return; - } - if (msg.type === "result") { - finish(() => resolve({ segments: msg.segments, granularity: msg.granularity })); - return; - } - finish(() => reject(new Error(msg.message))); - }; - - worker.onerror = (e) => { - finish(() => reject(new Error(e.message || "Caption transcription worker failed"))); - }; - - // Packaged app runs from file:// (remote fetches fail), so load bundled assets. - // Dev runs from http://localhost where the remote path works. - const useLocalModels = typeof window !== "undefined" && window.location?.protocol === "file:"; - const assetBaseUrl = - typeof window !== "undefined" ? window.electronAPI?.assetBaseUrl : undefined; - - // Structured-clone copy, not a transfer: the caller may reuse `samples` for the - // full-buffer retry pass, so the buffer must stay valid here. - const request: TranscribeWorkerRequest = { - samples, - trimRegions: options?.trimRegions ?? [], - useLocalModels, - assetBaseUrl, - }; - worker.postMessage(request); - }); -} diff --git a/src/lib/captioning/transcribe.worker.ts b/src/lib/captioning/transcribe.worker.ts deleted file mode 100644 index ab65b2eeaa..0000000000 --- a/src/lib/captioning/transcribe.worker.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Web Worker running in-browser Whisper transcription off the renderer's main - * thread so the editor UI never blocks during model load or transcription. - * - * Input: { samples: Float32Array; trimRegions: TrimRegion[] } - * Output (see `TranscribeWorkerResponse`): status / result / error messages. - * - * The caller terminates this worker to abort (model load and inference can't be - * cooperatively cancelled), so there is no in-worker abort handling. - */ - -import type { TranscribeWorkerRequest, TranscribeWorkerResponse } from "./transcribe"; -import { runTranscription, type TranscriberFn } from "./transcribeCore"; - -function post(message: TranscribeWorkerResponse): void { - (self as unknown as Worker).postMessage(message); -} - -/** - * ONNX Runtime's wasm bundle treats `process.versions.node` (which can leak into - * an Electron worker) as Node and tries `require("fs")`, which Vite doesn't - * support. Mask it only while Transformers/ORT run. No-op when `process` is - * undefined (the usual case in a Web Worker). - */ -function withoutNodeVersion(fn: () => Promise): Promise { - const versions = - typeof process !== "undefined" && process.versions && typeof process.versions === "object" - ? process.versions - : null; - const hadNode = versions !== null && "node" in versions; - const savedNode = hadNode ? (versions as { node?: string }).node : undefined; - if (hadNode && versions) { - try { - Reflect.deleteProperty(versions, "node"); - } catch { - (versions as { node?: string }).node = undefined; - } - } - return fn().finally(() => { - if (hadNode && versions && savedNode !== undefined) { - (versions as { node: string }).node = savedNode; - } - }); -} - -async function loadTranscriber(opts: { - useLocalModels: boolean; - assetBaseUrl?: string; -}): Promise { - return withoutNodeVersion(async () => { - const { pipeline, env } = await import("@xenova/transformers"); - if (opts.useLocalModels && opts.assetBaseUrl) { - // Packaged app: load the bundled model and ORT wasm from disk so transcription - // needs no network and works under file:// (remote HuggingFace/CDN fetches fail there). - const base = new URL("caption-assets/", opts.assetBaseUrl).href; - env.allowLocalModels = true; - env.allowRemoteModels = false; - env.localModelPath = new URL("models/", base).href; - env.backends.onnx.wasm.wasmPaths = new URL("ort/", base).href; - // Non-threaded wasm: SharedArrayBuffer isn't available under file:// (no cross-origin isolation). - env.backends.onnx.wasm.numThreads = 1; - } else { - // Dev (http://localhost): fetch from the remote CDN, which works there. - env.allowLocalModels = false; - } - // Default tiny weights only: the `output_attentions` revision regresses inference in - // some environments (empty chunks, thrown errors) while phrase mode works on this model. - const transcriber = (await pipeline( - "automatic-speech-recognition", - "Xenova/whisper-tiny", - )) as unknown as TranscriberFn; - return transcriber; - }); -} - -self.onmessage = async (event: MessageEvent) => { - const { samples, trimRegions, useLocalModels, assetBaseUrl } = event.data; - try { - post({ type: "status", phase: "model" }); - const transcriber = await loadTranscriber({ useLocalModels, assetBaseUrl }); - - post({ type: "status", phase: "transcribe" }); - const { segments, granularity } = await runTranscription( - transcriber, - samples, - trimRegions ?? [], - ); - - post({ type: "result", segments, granularity }); - } catch (e) { - post({ type: "error", message: e instanceof Error ? e.message : String(e) }); - } -}; diff --git a/src/lib/captioning/transcribeCore.ts b/src/lib/captioning/transcribeCore.ts deleted file mode 100644 index 9834e36541..0000000000 --- a/src/lib/captioning/transcribeCore.ts +++ /dev/null @@ -1,268 +0,0 @@ -import type { TrimRegion } from "@/components/video-editor/types"; -import type { CaptionSegment, TranscribeMono16kResult } from "./transcribe"; - -/** - * Pure transcription algorithm for the captioning Web Worker: takes a built Whisper - * `transcriber` and turns mono 16 kHz audio into timed caption segments. No DOM or - * Transformers.js imports so it runs in a worker and unit-tests in isolation. - */ - -/** A Transformers.js automatic-speech-recognition pipeline call. */ -export type TranscriberFn = ( - audio: Float32Array, - opts: Record, -) => Promise; - -function segmentOverlapsTrim(startMs: number, endMs: number, trims: TrimRegion[]): boolean { - return trims.some((t) => startMs < t.endMs && endMs > t.startMs); -} - -/** Same trim-out rule as {@link segmentsFromTranscriberChunks}; for retry passes that used empty trims. */ -function dropSegmentsOverlappingTrimRegions( - segments: CaptionSegment[], - trimRegions: TrimRegion[], -): CaptionSegment[] { - if (trimRegions.length === 0) return segments; - return segments.filter((s) => { - const startMs = Math.round(s.startSec * 1000); - const endMs = Math.round(s.endSec * 1000); - return !segmentOverlapsTrim(startMs, endMs, trimRegions); - }); -} - -/** Whisper runs with internal 30s chunks; keep each forward pass bounded for WASM memory. */ -const TRANSCRIBE_SLICE_SAMPLES = 12 * 60 * 16_000; - -/** Very short slices are skipped in the multi-slice loop unless padded (see `padTailSliceForTranscribe`). */ -const MIN_TRANSCRIBE_SLICE_SAMPLES = 800; - -/** - * Pad a short tail slice so Whisper still runs; timestamps are clamped with `realDurationSec` so - * padding does not extend perceived audio on the timeline. - */ -function padTailSliceForTranscribe(samples: Float32Array): { - slice: Float32Array; - realDurationSec: number; -} { - const realDurationSec = samples.length / 16_000; - if (samples.length >= MIN_TRANSCRIBE_SLICE_SAMPLES) { - return { slice: samples, realDurationSec }; - } - const padded = new Float32Array(MIN_TRANSCRIBE_SLICE_SAMPLES); - padded.set(samples); - return { slice: padded, realDurationSec }; -} - -/** Converts raw Whisper chunk output into sorted, deduped, trim-filtered caption segments. */ -function segmentsFromTranscriberChunks( - chunks: Array<{ timestamp?: [number | null, number | null]; text?: unknown }>, - timeOffsetSec: number, - trims: TrimRegion[], - audioDurationSec: number, -): CaptionSegment[] { - const sorted = [...chunks].sort((x, y) => { - const ax = x.timestamp?.[0]; - const ay = y.timestamp?.[0]; - const na = typeof ax === "number" ? ax : -1; - const nb = typeof ay === "number" ? ay : -1; - return na - nb; - }); - - const segments: CaptionSegment[] = []; - - for (let idx = 0; idx < sorted.length; idx++) { - const c = sorted[idx]!; - const ts = c.timestamp as [number | null, number | null] | undefined; - if (!ts) continue; - let a = ts[0]; - let b = ts[1]; - if (a == null) a = 0; - a = Math.max(0, a); - if (b == null) { - let nextStart: number | null = null; - for (let j = idx + 1; j < sorted.length; j++) { - const na = sorted[j]?.timestamp?.[0]; - if (typeof na === "number") { - nextStart = na; - break; - } - } - b = nextStart ?? audioDurationSec; - } - if (b <= a) { - b = Math.min(a + 0.25, audioDurationSec); - } - b = Math.min(b, audioDurationSec); - - const text = String(c.text ?? "") - .replace(/\s+/g, " ") - .trim(); - if (!text) continue; - - const startSec = a + timeOffsetSec; - const sliceEnd = timeOffsetSec + audioDurationSec; - const endSec = Math.min(Math.max(startSec + 0.08, b + timeOffsetSec), sliceEnd); - const startMs = Math.round(startSec * 1000); - const endMs = Math.round(endSec * 1000); - if (segmentOverlapsTrim(startMs, endMs, trims)) continue; - - segments.push({ startSec, endSec, text }); - } - - segments.sort((u, v) => u.startSec - v.startSec || u.endSec - v.endSec); - const rawDeduped: CaptionSegment[] = []; - for (const seg of segments) { - const prev = rawDeduped[rawDeduped.length - 1]; - if (prev && prev.text === seg.text && seg.startSec <= prev.endSec) { - prev.endSec = Math.max(prev.endSec, seg.endSec); - prev.startSec = Math.min(prev.startSec, seg.startSec); - continue; - } - rawDeduped.push(seg); - } - return rawDeduped; -} - -/** Runs the transcriber on one audio slice, chunking only long clips. */ -async function runTranscriberOnSlice( - transcriber: TranscriberFn, - samples: Float32Array, - opts: { forceFullSequences: boolean; timestampMode: "word" | "phrase" }, -): Promise { - const durationSec = samples.length / 16_000; - // Only chunk long clips; short-audio chunking regressed some Whisper.js runs (empty chunks). - const chunking = durationSec > 30 ? { chunk_length_s: 30, stride_length_s: 5 } : {}; - return transcriber(samples, { - return_timestamps: opts.timestampMode === "word" ? "word" : true, - force_full_sequences: opts.forceFullSequences, - ...chunking, - }); -} - -/** Flattens the various shapes a Transformers.js ASR result can take into a chunk list. */ -function getChunksFromTranscriberResult(result: unknown): Array<{ - timestamp?: [number | null, number | null]; - text?: unknown; -}> { - if (result == null) return []; - if (Array.isArray(result)) { - const out: Array<{ timestamp?: [number | null, number | null]; text?: unknown }> = []; - for (const item of result) { - const chunks = (item as { chunks?: unknown })?.chunks; - if (Array.isArray(chunks)) out.push(...chunks); - } - return out; - } - const chunks = (result as { chunks?: unknown })?.chunks; - return Array.isArray(chunks) ? chunks : []; -} - -/** Prefer `chunks`; if the model only returned top-level `text`, synthesize one span for timing. */ -function extractChunksFromAsrResult(result: unknown): Array<{ - timestamp?: [number | null, number | null]; - text?: unknown; -}> { - const fromChunks = getChunksFromTranscriberResult(result); - if (fromChunks.length > 0) return fromChunks; - const single = Array.isArray(result) ? result[0] : result; - const text = - typeof (single as { text?: unknown })?.text === "string" - ? String((single as { text: string }).text).trim() - : ""; - if (text) { - return [{ timestamp: [0, null], text }]; - } - return []; -} - -/** - * Drives Whisper over (possibly sliced) mono 16 kHz audio and returns timed segments. - * Long audio is split so one pass doesn't exhaust WASM memory; timestamps are shifted - * back onto the full timeline. Tries word- then phrase-level timestamps, with a - * trim-ignoring retry, before giving up. - */ -export async function runTranscription( - transcriber: TranscriberFn, - samples: Float32Array, - trims: TrimRegion[], -): Promise { - const transcribeOne = async ( - ignoreTrims: boolean, - forceFullSequences: boolean, - timestampMode: "word" | "phrase", - ): Promise => { - try { - const activeTrims = ignoreTrims ? [] : trims; - if (samples.length <= TRANSCRIBE_SLICE_SAMPLES) { - const { slice, realDurationSec } = padTailSliceForTranscribe(samples); - const result = await runTranscriberOnSlice(transcriber, slice, { - forceFullSequences, - timestampMode, - }); - return segmentsFromTranscriberChunks( - extractChunksFromAsrResult(result), - 0, - activeTrims, - realDurationSec, - ); - } - - const all: CaptionSegment[] = []; - for (let offset = 0; offset < samples.length; offset += TRANSCRIBE_SLICE_SAMPLES) { - const end = Math.min(offset + TRANSCRIBE_SLICE_SAMPLES, samples.length); - const sliceRaw = samples.subarray(offset, end); - const isFinalSlice = end >= samples.length; - if (sliceRaw.length === 0) continue; - if (sliceRaw.length < MIN_TRANSCRIBE_SLICE_SAMPLES && !isFinalSlice) continue; - - const { slice, realDurationSec } = - sliceRaw.length < MIN_TRANSCRIBE_SLICE_SAMPLES && isFinalSlice - ? padTailSliceForTranscribe(sliceRaw) - : { slice: sliceRaw, realDurationSec: sliceRaw.length / 16_000 }; - - const result = await runTranscriberOnSlice(transcriber, slice, { - forceFullSequences, - timestampMode, - }); - const tOff = offset / 16_000; - all.push( - ...segmentsFromTranscriberChunks( - extractChunksFromAsrResult(result), - tOff, - activeTrims, - realDurationSec, - ), - ); - } - return all; - } catch (e) { - console.warn("[captioning] Whisper pass failed:", e); - return []; - } - }; - - const attemptModes: Array<"word" | "phrase"> = ["word", "phrase"]; - for (const timestampMode of attemptModes) { - let segments = await transcribeOne(false, true, timestampMode); - if (segments.length === 0) { - segments = await transcribeOne(false, false, timestampMode); - } - if (segments.length === 0 && trims.length > 0) { - segments = dropSegmentsOverlappingTrimRegions( - await transcribeOne(true, true, timestampMode), - trims, - ); - if (segments.length === 0) { - segments = dropSegmentsOverlappingTrimRegions( - await transcribeOne(true, false, timestampMode), - trims, - ); - } - } - if (segments.length > 0) { - return { segments, granularity: timestampMode }; - } - } - - return { segments: [], granularity: "phrase" }; -} diff --git a/src/lib/captions.test.ts b/src/lib/captions.test.ts new file mode 100644 index 0000000000..52cc91f1fe --- /dev/null +++ b/src/lib/captions.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import { + getWhisperLanguageForLocale, + MIN_CAPTION_DURATION_MS, + normalizeCaptionSegments, + parseWhisperJsonOutput, +} from "./captions"; + +describe("parseWhisperJsonOutput", () => { + it("parses whisper.cpp transcription entries that use offsets in milliseconds", () => { + const output = JSON.stringify({ + transcription: [ + { + offsets: { from: 1200, to: 3450 }, + text: " Hello world ", + }, + ], + }); + + expect(parseWhisperJsonOutput(output)).toEqual([ + { id: "caption-1", startMs: 1200, endMs: 3450, text: "Hello world" }, + ]); + }); + + it("parses whisper.cpp timestamp strings", () => { + const output = JSON.stringify({ + transcription: [ + { + timestamps: { from: "00:01:02.500", to: "00:01:04.000" }, + text: "timestamp text", + }, + ], + }); + + expect(parseWhisperJsonOutput(output)).toEqual([ + { id: "caption-1", startMs: 62500, endMs: 64000, text: "timestamp text" }, + ]); + }); + + it("parses common segment arrays that use seconds", () => { + const output = JSON.stringify({ + segments: [{ start: 1.25, end: 2.5, text: "segment text" }], + }); + + expect(parseWhisperJsonOutput(output)).toEqual([ + { id: "caption-1", startMs: 1250, endMs: 2500, text: "segment text" }, + ]); + }); + + it("returns an empty list for invalid JSON", () => { + expect(parseWhisperJsonOutput("{not-json")).toEqual([]); + }); +}); + +describe("normalizeCaptionSegments", () => { + it("drops empty text and invalid timing", () => { + expect( + normalizeCaptionSegments([ + { startMs: 0, endMs: 1000, text: " " }, + { startMs: 1000, endMs: 900, text: "reversed" }, + { startMs: Number.NaN, endMs: 2000, text: "nan" }, + { startMs: 2000, endMs: 3000, text: "valid" }, + ]), + ).toEqual([{ id: "caption-1", startMs: 2000, endMs: 3000, text: "valid" }]); + }); + + it("sorts by start time and clamps very short captions", () => { + const segments = normalizeCaptionSegments([ + { startMs: 2000, endMs: 2100, text: "second" }, + { startMs: 1000, endMs: 1200, text: "first" }, + ]); + + expect(segments).toEqual([ + { + id: "caption-1", + startMs: 1000, + endMs: 1000 + MIN_CAPTION_DURATION_MS, + text: "first", + }, + { + id: "caption-2", + startMs: 2000, + endMs: 2000 + MIN_CAPTION_DURATION_MS, + text: "second", + }, + ]); + }); +}); + +describe("getWhisperLanguageForLocale", () => { + it("maps supported app locales to whisper language codes", () => { + expect(getWhisperLanguageForLocale("zh-CN")).toBe("zh"); + expect(getWhisperLanguageForLocale("zh-TW")).toBe("zh"); + expect(getWhisperLanguageForLocale("ja-JP")).toBe("ja"); + expect(getWhisperLanguageForLocale("ko-KR")).toBe("ko"); + expect(getWhisperLanguageForLocale("fr")).toBe("fr"); + }); + + it("returns undefined for empty or invalid locale values", () => { + expect(getWhisperLanguageForLocale("")).toBeUndefined(); + expect(getWhisperLanguageForLocale(" ")).toBeUndefined(); + }); +}); diff --git a/src/lib/captions.ts b/src/lib/captions.ts new file mode 100644 index 0000000000..43bed0e4bf --- /dev/null +++ b/src/lib/captions.ts @@ -0,0 +1,167 @@ +export const MIN_CAPTION_DURATION_MS = 500; + +export type CaptionGenerationStatus = "success" | "skipped" | "unavailable" | "error" | "cancelled"; + +export interface CaptionSegment { + id: string; + startMs: number; + endMs: number; + text: string; +} + +export interface CaptionGenerationOptions { + jobId?: string; + language?: string; +} + +export interface CaptionGenerationResult { + jobId: string; + status: CaptionGenerationStatus; + segments: CaptionSegment[]; + message?: string; + error?: string; +} + +const WHISPER_LANGUAGE_BY_LOCALE: Record = { + ar: "ar", + en: "en", + es: "es", + fr: "fr", + it: "it", + "ja-jp": "ja", + "ko-kr": "ko", + ru: "ru", + tr: "tr", + vi: "vi", + "zh-cn": "zh", + "zh-tw": "zh", +}; + +interface RawCaptionSegment { + startMs: number; + endMs: number; + text: string; +} + +export function getWhisperLanguageForLocale(locale: string): string | undefined { + const normalizedLocale = locale.trim().toLowerCase(); + if (!normalizedLocale) { + return undefined; + } + + return WHISPER_LANGUAGE_BY_LOCALE[normalizedLocale]; +} + +export function normalizeCaptionSegments(segments: RawCaptionSegment[]): CaptionSegment[] { + return segments + .map((segment) => ({ + startMs: segment.startMs, + endMs: segment.endMs, + text: segment.text.trim(), + })) + .filter( + (segment) => + segment.text.length > 0 && + Number.isFinite(segment.startMs) && + Number.isFinite(segment.endMs) && + segment.endMs >= segment.startMs, + ) + .sort((a, b) => a.startMs - b.startMs || a.endMs - b.endMs) + .map((segment, index) => ({ + id: `caption-${index + 1}`, + startMs: segment.startMs, + endMs: Math.max(segment.endMs, segment.startMs + MIN_CAPTION_DURATION_MS), + text: segment.text, + })); +} + +export function parseWhisperJsonOutput(output: string): CaptionSegment[] { + let parsed: unknown; + + try { + parsed = JSON.parse(output); + } catch { + return []; + } + + if (!isRecord(parsed)) { + return []; + } + + if (Array.isArray(parsed.transcription)) { + return normalizeCaptionSegments(parsed.transcription.map(parseTranscriptionEntry)); + } + + if (Array.isArray(parsed.segments)) { + return normalizeCaptionSegments(parsed.segments.map(parseSecondsSegment)); + } + + return []; +} + +function parseTranscriptionEntry(entry: unknown): RawCaptionSegment { + if (!isRecord(entry)) { + return invalidCaptionSegment(); + } + + const text = typeof entry.text === "string" ? entry.text : ""; + + if (isRecord(entry.offsets)) { + return { + startMs: numberOrNaN(entry.offsets.from), + endMs: numberOrNaN(entry.offsets.to), + text, + }; + } + + if (isRecord(entry.timestamps)) { + return { + startMs: parseTimestampMs(entry.timestamps.from), + endMs: parseTimestampMs(entry.timestamps.to), + text, + }; + } + + return invalidCaptionSegment(); +} + +function parseSecondsSegment(entry: unknown): RawCaptionSegment { + if (!isRecord(entry)) { + return invalidCaptionSegment(); + } + + return { + startMs: numberOrNaN(entry.start) * 1000, + endMs: numberOrNaN(entry.end) * 1000, + text: typeof entry.text === "string" ? entry.text : "", + }; +} + +function parseTimestampMs(value: unknown): number { + if (typeof value !== "string") { + return Number.NaN; + } + + const match = value.trim().match(/^(\d+):(\d{2}):(\d{2})(?:\.(\d{1,3}))?$/); + if (!match) { + return Number.NaN; + } + + const [, hours, minutes, seconds, milliseconds = "0"] = match; + return ( + (Number(hours) * 60 * 60 + Number(minutes) * 60 + Number(seconds)) * 1000 + + Number(milliseconds.padEnd(3, "0")) + ); +} + +function numberOrNaN(value: unknown): number { + return typeof value === "number" ? value : Number.NaN; +} + +function invalidCaptionSegment(): RawCaptionSegment { + return { startMs: Number.NaN, endMs: Number.NaN, text: "" }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/src/lib/vite-stubs/empty-node-module.ts b/src/lib/vite-stubs/empty-node-module.ts deleted file mode 100644 index 00d8207f79..0000000000 --- a/src/lib/vite-stubs/empty-node-module.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Empty default export, used as the Vite alias target for Node builtins that - * @xenova/transformers imports. Its env.js reads an empty object as "no filesystem" - * and stays on the browser/remote paths. - */ -const empty = Object.create(null) as Record; -export default empty; diff --git a/src/lib/vite-stubs/onnxruntime-node-stub.ts b/src/lib/vite-stubs/onnxruntime-node-stub.ts deleted file mode 100644 index f13969c226..0000000000 --- a/src/lib/vite-stubs/onnxruntime-node-stub.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Transformers imports `onnxruntime-node`, then picks web vs node from - * `process.release.name`, which is often `"node"` in Electron's renderer even - * though we need the WASM build. The real `onnxruntime-node` is aliased away (it - * pulls `fs`), so re-export `onnxruntime-web` to give the node branch a working ORT. - */ -import * as ortWeb from "onnxruntime-web"; - -const ort = (ortWeb as { default?: typeof ortWeb }).default ?? ortWeb; -export default ort; diff --git a/src/main.tsx b/src/main.tsx index 365bdc7b2e..445622531a 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -8,7 +8,8 @@ const windowType = new URLSearchParams(window.location.search).get("windowType") if ( windowType === "hud-overlay" || windowType === "source-selector" || - windowType === "countdown-overlay" + windowType === "countdown-overlay" || + windowType === "recording-annotation-overlay" ) { document.body.style.background = "transparent"; document.documentElement.style.background = "transparent"; diff --git a/vite.config.ts b/vite.config.ts index 213e447115..0779e1358a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -28,22 +28,8 @@ export default defineConfig({ resolve: { alias: { "@": path.resolve(__dirname, "src"), - // @xenova/transformers: env.js statically imports fs/path/url; onnx.js imports - // onnxruntime-node (must not be bundled in the renderer — it requires fs). - fs: path.resolve(__dirname, "src/lib/vite-stubs/empty-node-module.ts"), - path: path.resolve(__dirname, "src/lib/vite-stubs/empty-node-module.ts"), - url: path.resolve(__dirname, "src/lib/vite-stubs/empty-node-module.ts"), - "onnxruntime-node": path.resolve(__dirname, "src/lib/vite-stubs/onnxruntime-node-stub.ts"), // re-exports web ORT }, }, - optimizeDeps: { - exclude: ["@xenova/transformers"], - }, - // The captioning worker dynamically imports @xenova/transformers, which makes the - // worker bundle code-split — unsupported by the default "iife" worker format. - worker: { - format: "es", - }, build: { target: "esnext", minify: "terser",