From bcb53c778e8e4e4ebf1dc55045151cd738472d84 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 28 Jul 2026 16:33:20 +0200 Subject: [PATCH 1/2] chore(cleanup): remove dead exports, scripts, and an always-true feature flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings from the v1.8.0 ponytail audit (see also PR #187): - Delete \src/utils/getTestId.ts\: the TestId type and getTestId() helper are not imported anywhere; tests use hardcoded data-testid strings. - Delete \src/components/video-editor/featureFlags.ts\: the single constant \AI_FEATURES_ENABLED = true\ is referenced only by a doc comment and by \LeftPanel.tsx\ as a runtime gate. Inline the gate in the two sites that used it and update the architecture docs to match. The new editor ships as the default from Phase 1 PR 1.3 onward; an always-true flag was carrying its own explanation as cargo. - Drop the \MIN_DELTA\ and \VIEWPORT_SCALE\ exports from \src/components/video-editor/videoPlayback/constants.ts\: only the source file references either. - Demote \RenderableChatMessage\, \estimateTokens\, and \DEFAULT_CHAT_BUDGET_TOKENS\ in \src/components/ai-edition/chatBudget.ts\ to file-private — no external importer. - Delete \scripts/bench-export.mjs\ (retired harness, runner already removed per rendering-performance.md:371), \scripts/stt-dev-server.mjs\, \scripts/e2e-pipeline-smoke.mjs\, \scripts/e2e-stt-smoke.mjs\. None are wired into \package.json\ scripts. - Tidy the now-stale comment in \electron-builder.json5\ that explained the \ fmpeg.exe\ exclude via \ench-export.mjs\. No behavior change: tsc clean, lint unchanged, 1144/1144 unit tests pass. --- electron-builder.json5 | 6 +- scripts/bench-export.mjs | 329 ------------------ scripts/e2e-pipeline-smoke.mjs | 166 --------- scripts/e2e-stt-smoke.mjs | 135 ------- scripts/stt-dev-server.mjs | 180 ---------- src/components/ai-edition/AiEditionShell.tsx | 6 +- src/components/ai-edition/LeftPanel.tsx | 7 +- src/components/ai-edition/chatBudget.ts | 6 +- src/components/video-editor/featureFlags.ts | 8 - .../video-editor/videoPlayback/constants.ts | 2 - src/utils/getTestId.ts | 10 - .../architecture/ai-agent.md | 4 +- .../architecture/decisions.md | 2 +- 13 files changed, 14 insertions(+), 847 deletions(-) delete mode 100644 scripts/bench-export.mjs delete mode 100644 scripts/e2e-pipeline-smoke.mjs delete mode 100644 scripts/e2e-stt-smoke.mjs delete mode 100644 scripts/stt-dev-server.mjs delete mode 100644 src/components/video-editor/featureFlags.ts delete mode 100644 src/utils/getTestId.ts diff --git a/electron-builder.json5 b/electron-builder.json5 index f571e1ab..72ee1ea4 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -126,9 +126,9 @@ // vendors two artifacts into this directory: the shared av*.dll set, // which the D3D11 compositor addon dlopens at require() time and // therefore MUST ship, and a standalone static ffmpeg.exe that nothing - // in the app spawns — it exists for scripts/bench-export.mjs. Shipping - // it added a large binary to every Windows installer, plus an LGPL - // redistribution obligation, for a file no shipped code opens. + // in the app spawns. Shipping it added a large binary to every Windows + // installer, plus an LGPL redistribution obligation, for a file no + // shipped code opens. "filter": ["win32-*/*", "!win32-*/ffmpeg.exe"] } ], diff --git a/scripts/bench-export.mjs b/scripts/bench-export.mjs deleted file mode 100644 index a99b06ea..00000000 --- a/scripts/bench-export.mjs +++ /dev/null @@ -1,329 +0,0 @@ -#!/usr/bin/env node -/** - * Export bench runner: one command, real pipeline, numbers on stdout. - * - * npm run bench:export -- --project=os_parity --arms=webcodecs,native --runs=3 - * - * Drives the app's own export path (see src/bench/runBench.ts) inside a real - * Electron window, so the GPU, the sandbox, the preload and the main-process - * ffmpeg are all the ones we ship. It exists because driving this through the - * UI cost ~5 minutes a run and kept injecting confounds. - * - * Assumes a vite dev server is already up (npm run dev with NO_ELECTRON=1). - */ - -import { spawn, spawnSync } from "node:child_process"; -import { existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const DEV_URL = process.env.VITE_DEV_SERVER_URL ?? "http://localhost:5199/"; - -function parseArgs(argv) { - const out = {}; - for (const arg of argv) { - const m = /^--([^=]+)=(.*)$/.exec(arg); - if (m) out[m[1]] = m[2]; - } - return out; -} - -/** - * A leftover lock DIRECTORY is stale and safe to remove. A leftover PROCESS is - * not: it holds the real single-instance lock, and the bench launch then exits 0 - * having done nothing — a silent no-op that looks like a bench bug. Detect it - * and say so rather than deleting someone's running app. - */ -function checkSingleInstance() { - const lock = path.join(os.tmpdir(), `openscreen-single-instance-${os.userInfo().username}.lock`); - // Both names matter: the dev build runs as electron.exe, the INSTALLED app as - // openscreen.exe, and they share one userData — so one lock. Checking only - // electron.exe let the installed app hold it while the bench launched, exited - // 0 and reported nothing, with no log line to say why. - for (const image of ["electron.exe", "openscreen.exe"]) { - const running = spawnSync("tasklist", ["/FI", `IMAGENAME eq ${image}`, "/NH"], { - encoding: "utf8", - }); - if (running.stdout?.includes(image)) { - throw new Error( - `${image} is already running and holds the single-instance lock;\n` + - "the bench would launch, exit 0 and report nothing. Close the app first.", - ); - } - } - if (existsSync(lock)) rmSync(lock, { recursive: true, force: true }); -} - -/** - * Refuse to run against a main bundle older than its sources. - * - * vite-plugin-electron rebuilds dist-electron asynchronously, so launching too - * soon after an edit runs the PREVIOUS main process against the new renderer. - * This has cost two full debugging detours already: once the export IPC was - * "not registered" (main predated the handler), once the bench flag did nothing - * and the app opened its normal HUD instead. Both looked like code bugs. A - * stale bundle must be a loud error, never a silently different measurement. - */ -function assertFreshMainBundle() { - const stub = path.join(ROOT, "dist-electron", "main.js"); - if (!existsSync(stub)) throw new Error("dist-electron/main.js missing — start the dev server."); - const chunkName = /from "\.\/(main-[^"]+)"/.exec(readFileSync(stub, "utf8"))?.[1]; - const chunk = chunkName && path.join(ROOT, "dist-electron", chunkName); - if (!chunk || !existsSync(chunk)) throw new Error("Cannot resolve the built main chunk."); - const builtAt = statSync(chunk).mtimeMs; - - let newest = 0; - let newestFile = ""; - const walk = (dir) => { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) { - if (entry.name !== "node_modules") walk(full); - } else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts")) { - const m = statSync(full).mtimeMs; - if (m > newest) { - newest = m; - newestFile = path.relative(ROOT, full); - } - } - } - }; - walk(path.join(ROOT, "electron")); - - if (newest > builtAt) { - const lag = Math.round((newest - builtAt) / 1000); - throw new Error( - `Main bundle is STALE: ${newestFile} changed ${lag}s after ${chunkName} was built.\n` + - "The bench would measure the previous main process. Let vite finish rebuilding\n" + - "(watch the dev-server log for 'build started' -> done), then re-run.", - ); - } -} - -/** A dead dev server yields a blank window and a bench that reports nothing. */ -async function assertViteUp() { - try { - const res = await fetch(DEV_URL, { method: "GET" }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - } catch (error) { - throw new Error( - `No vite dev server at ${DEV_URL} (${error.message}).\n` + - "Start it with NO_ELECTRON=1 (see .claude/launch.json 'vite-dev').", - ); - } -} - -function median(values) { - const sorted = [...values].sort((a, b) => a - b); - const mid = Math.floor(sorted.length / 2); - return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; -} - -/** - * Spread across an arm's repeats, as a fraction of the median. - * - * This is the gate, not a decoration: on this machine two identical runs came - * out 26% apart while the battery charged, which was enough to invert the - * comparison. If spread is anywhere near the gap between arms, the run says - * nothing and must be repeated in a steadier state. - */ -function spread(values) { - if (values.length < 2) return 0; - const m = median(values); - return m === 0 ? 0 : (Math.max(...values) - Math.min(...values)) / m; -} - -function report(results) { - const arms = [...new Set(results.map((r) => r.arm))]; - const failed = results.filter((r) => !r.ok); - for (const f of failed) console.log(` FAILED ${f.arm} run ${f.run}: ${f.error}`); - - const rows = []; - for (const arm of arms) { - const runs = results.filter((r) => r.arm === arm && r.ok); - if (runs.length === 0) continue; - const walls = runs.map((r) => r.wallMs); - rows.push({ - arm, - runs: runs.length, - wallMs: Math.round(median(walls)), - fps: +median(runs.map((r) => r.fps)).toFixed(1), - spread: `${(spread(walls) * 100).toFixed(0)}%`, - frames: runs[0].frames, - raw: walls.map((w) => Math.round(w)).join(" / "), - }); - } - console.log("\n=== export bench ==="); - console.table(rows); - - // The shadow cache only holds while the geometry is still, so its miss rate is - // what a moving camera (zoom, auto-focus pan) actually costs — see Step 3. - const shadowRows = []; - for (const arm of arms) { - const runs = results.filter((r) => r.arm === arm && r.ok && r.shadow); - if (runs.length === 0) continue; - const hits = median(runs.map((r) => r.shadow.hits)); - const misses = median(runs.map((r) => r.shadow.misses)); - const total = hits + misses; - shadowRows.push({ - arm, - hits, - misses, - "miss %": total === 0 ? "n/a" : `${((misses / total) * 100).toFixed(1)}%`, - }); - } - if (shadowRows.length) { - console.log("shadow cache (median):"); - console.table(shadowRows); - } - - const stageKeys = [...new Set(results.flatMap((r) => Object.keys(r.stages ?? {})))]; - const stageRows = stageKeys.map((stage) => { - const row = { stage }; - for (const arm of arms) { - const runs = results.filter((r) => r.arm === arm && r.ok && r.stages?.[stage] != null); - if (runs.length) row[arm] = `${Math.round(median(runs.map((r) => r.stages[stage])))}ms`; - } - return row; - }); - if (stageRows.length) { - console.log("stage totals (median):"); - console.table(stageRows); - } - - const worst = Math.max(...rows.map((r) => Number.parseFloat(r.spread))); - if (worst >= 10) { - console.log( - `\n!! Same-arm spread reaches ${worst.toFixed(0)}% — larger than most effects worth\n` + - " measuring. Treat this run as VOID and repeat on a steady machine.", - ); - } - return { rows, worst }; -} - -async function main() { - const args = parseArgs(process.argv.slice(2)); - const query = new URLSearchParams({ - ...(args.project ? { project: args.project } : {}), - ...(args.effects ? { effects: args.effects } : {}), - // Iteration cap: bench only the first N seconds of timeline. Numbers from - // capped runs compare per-frame, or against runs with the SAME cap. - ...(args.clip ? { clip: args.clip } : {}), - ...(args.warmup ? { warmup: args.warmup } : {}), - ...(args.dumpFrame ? { dumpFrame: args.dumpFrame } : {}), - arms: args.arms ?? "webcodecs,native", - runs: args.runs ?? "2", - fps: args.fps ?? "60", - quality: args.quality ?? "1080p", - }).toString(); - - // Every guard here exists because its absence already produced a confident, - // wrong answer at least once. - checkSingleInstance(); - await assertViteUp(); - assertFreshMainBundle(); - - const electron = path.join(ROOT, "node_modules", "electron", "dist", "electron.exe"); - if (!existsSync(electron)) throw new Error(`Electron not found at ${electron}`); - - console.log(`bench: ${query}\nvite: ${DEV_URL}`); - const child = spawn(electron, [".", `--bench=${query}`], { - cwd: ROOT, - env: { ...process.env, VITE_DEV_SERVER_URL: DEV_URL }, - stdio: ["ignore", "pipe", "pipe"], - }); - - const results = []; - let fatal = null; - let buffer = ""; - let currentArm = "unknown"; - // The app's own stdout/stderr, kept so a failure can show WHY. Dropping it - // once cost a long detour: listProjects was warning that it had skipped the - // very project being benched, and the runner threw that line away. - const appLog = []; - const consume = (chunk) => { - buffer += chunk; - const lines = buffer.split("\n"); - buffer = lines.pop() ?? ""; - for (const line of lines) { - const m = /\[bench\] (\{.*\})\s*$/.exec(line); - if (!m) { - if (line.trim()) appLog.push(line); - // Configuration the exporter reports about itself (which encoder, what - // the canvas actually granted). Always shown: an arm that silently - // no-ops must not be mistaken for an arm that was tested and lost. - const note = /\[export perf\] (canvas .*|native encode .*|shadow .*|WGSL .*|G0 .*)$/.exec( - line, - ); - if (note) console.log(` · ${note[1]}`); - // The WGSL compositor's own diagnostics. Shown always, not just on - // failure: a validation error draws NOTHING and reports no error to - // the caller, which reads as a very fast compositor rendering black. - const wgsl = /\[wgsl\] (.*)$/.exec(line); - if (wgsl) console.log(` · wgsl: ${wgsl[1]}`); - continue; - } - let event; - try { - event = JSON.parse(m[1]); - } catch { - continue; // A truncated line is not worth killing the run over. - } - if (event.event === "run") { - results.push(event); - const status = event.ok - ? `${Math.round(event.wallMs)}ms · ${event.frames}f · ${event.fps.toFixed(1)}fps` - : `FAILED: ${event.error}`; - console.log(` ${event.arm} run ${event.run}: ${status}`); - } else if (event.event === "fatal") { - fatal = event.error; - } else if (event.event === "armStart") { - currentArm = event.arm; - } else if (event.event === "frame") { - // Written next to the run, named for the arm that drew it, so two arms - // leave two pictures to compare. - const file = path.join(ROOT, `bench-frame-${currentArm}-${event.index}.png`); - writeFileSync(file, Buffer.from(event.png.split(",")[1], "base64")); - console.log(` frame ${event.index} of ${currentArm} -> ${path.basename(file)}`); - } else if (event.event === "warmup") { - console.log(` (warm-up) ${event.arm}: ${Math.round(event.wallMs)}ms — discarded`); - } else if (event.event === "start") { - console.log(` project: ${event.project}`); - console.log(` effects: ${event.effects}`); - if (event.clip) console.log(` clip: first ${event.clip}s only (iteration cap)`); - console.log(` arms: ${event.arms.join(", ")} x${event.runs}`); - console.log(` warm-up: ${event.warmup} discarded run(s) per arm\n`); - } - } - }; - child.stdout.on("data", (c) => consume(String(c))); - child.stderr.on("data", (c) => consume(String(c))); - - const code = await new Promise((resolve) => child.on("close", resolve)); - const dumpAppLog = () => { - const noise = /Electron Security Warning|DevTools|deprecat/i; - const interesting = appLog.filter((l) => !noise.test(l)); - if (interesting.length) { - console.error("\n--- app output (tail) ---"); - for (const line of interesting.slice(-25)) console.error(` ${line}`); - } - }; - if (fatal) { - console.error(`\nbench failed: ${fatal}`); - dumpAppLog(); - process.exit(1); - } - if (results.length === 0) { - console.error(`\nbench produced no results (electron exited ${code}).`); - dumpAppLog(); - process.exit(1); - } - report(results); -} - -main().catch((error) => { - console.error(error); - process.exit(1); -}); diff --git a/scripts/e2e-pipeline-smoke.mjs b/scripts/e2e-pipeline-smoke.mjs deleted file mode 100644 index fc9a1afc..00000000 --- a/scripts/e2e-pipeline-smoke.mjs +++ /dev/null @@ -1,166 +0,0 @@ -// Smoke-test the full STT pipeline (whisper-server, including its own -// per-word timestamps) by calling the same modules the IPC handler calls. -// Skips the Electron boilerplate (BrowserWindow, IPC plumbing) — runs in -// plain Node. -// -// Run: node scripts/e2e-pipeline-smoke.mjs - -import { spawn } from "node:child_process"; -import { readFileSync } from "node:fs"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const ROOT = join(__dirname, ".."); - -const RECORDING = join( - process.env.APPDATA || tmpdir(), - "Electron", - "recordings", - "recording-1783174040055.webm", -); -const WHISPER_BIN = join( - ROOT, - "electron", - "native", - "bin", - "win32-x64", - "whisper-server-whisper-cpu.exe", -); -const MODEL_PATH = join( - process.env.APPDATA || tmpdir(), - "Electron", - "stt-models", - "whisper", - "ggml-small-q5_1.bin", -); - -// Lazy import — only the type module + the bits we need. The main thing -// we want to verify is that our Electron modules wire up correctly. -// The binding is deliberately dropped: the import is attempted for its own -// sake and the module's value is never read. -await import("../electron/stt/transcriptionContract.ts").catch(() => null); -// The .ts file isn't loadable directly via plain Node — that's fine, we -// only need to confirm the actual server pipeline behaves correctly when -// invoked the same way the IPC handler does. - -async function extractWav(src, dst) { - const ffmpeg = spawn("ffmpeg", ["-y", "-i", src, "-ar", "16000", "-ac", "1", "-f", "wav", dst], { - stdio: ["ignore", "pipe", "pipe"], - }); - return new Promise((resolve, reject) => { - let err = ""; - ffmpeg.stderr.on("data", (c) => (err += c.toString())); - ffmpeg.on("close", (code) => - code === 0 ? resolve() : reject(new Error(`ffmpeg exit ${code}: ${err}`)), - ); - ffmpeg.on("error", reject); - }); -} - -function readWavMono16k(path) { - const buf = readFileSync(path); - if (buf.toString("ascii", 0, 4) !== "RIFF" || buf.toString("ascii", 8, 12) !== "WAVE") { - throw new Error("not a WAV file"); - } - const numChannels = buf.readUInt16LE(22); - const sampleRate = buf.readUInt32LE(24); - const bitsPerSample = buf.readUInt16LE(34); - if (numChannels !== 1 || sampleRate !== 16000 || bitsPerSample !== 16) { - throw new Error(`unexpected WAV format: ${numChannels}ch ${sampleRate}Hz ${bitsPerSample}b`); - } - const dataOffset = buf.toString("ascii", 36, 40) === "data" ? 44 : 46; - const samples = new Float32Array((buf.length - dataOffset) / 2); - for (let i = 0; i < samples.length; i++) { - samples[i] = buf.readInt16LE(dataOffset + i * 2) / 32768; - } - return samples; -} - -async function main() { - const tmpDir = join(tmpdir(), "openscreen-stt-pipeline-smoke"); - await mkdir(tmpDir, { recursive: true }); - const wavPath = join(tmpDir, "audio.wav"); - console.log(`Converting ${RECORDING} → ${wavPath}`); - await extractWav(RECORDING, wavPath); - const samples = readWavMono16k(wavPath); - console.log(`Audio: ${samples.length} samples (${(samples.length / 16000).toFixed(2)}s)`); - - // Stage 1: whisper-server inference (matches SttManager.prepare() -> whisperServer.start()). - const port = 18801; - const server = spawn( - WHISPER_BIN, - ["-m", MODEL_PATH, "--port", String(port), "--host", "127.0.0.1"], - { stdio: ["ignore", "pipe", "pipe"] }, - ); - server.stderr.on("data", (c) => { - const l = c.toString().trimEnd(); - if (l) console.log(` server> ${l}`); - }); - const deadline = Date.now() + 30_000; - while (Date.now() < deadline) { - try { - const r = await fetch(`http://127.0.0.1:${port}/`); - if (r.ok) break; - } catch { - // not up yet - } - await new Promise((res) => setTimeout(res, 250)); - } - console.log(`Server up (took ${30_000 - (deadline - Date.now())}ms)`); - - const wavBytes = await readFile(wavPath); - const form = new FormData(); - form.append("file", new Blob([wavBytes], { type: "audio/wav" }), "audio.wav"); - form.append("response_format", "verbose_json"); - form.append("language", "auto"); - - const t0 = Date.now(); - const response = await fetch(`http://127.0.0.1:${port}/inference`, { - method: "POST", - body: form, - }); - const json = await response.json(); - console.log(`Inference took ${Date.now() - t0}ms`); - - const phrases = []; - const words = []; - if (Array.isArray(json.segments)) { - for (const seg of json.segments) { - phrases.push({ - text: seg.text?.trim() ?? "", - startSec: Number(seg.start), - endSec: Number(seg.end), - }); - for (const w of seg.words ?? []) { - words.push({ - text: w.word?.trim() ?? "", - startSec: Number(w.start), - endSec: Number(w.end), - }); - } - } - } - console.log(`Phrases: ${phrases.length}`); - for (const p of phrases) - console.log(` [${p.startSec.toFixed(2)}-${p.endSec.toFixed(2)}] ${JSON.stringify(p.text)}`); - - // Word-level timestamps come straight from whisper-server's own output - // (segments[].words[]) — no separate forced-alignment pass. - console.log(`\nWords: ${words.length}`); - for (const w of words) - console.log(` [${w.startSec.toFixed(2)}-${w.endSec.toFixed(2)}] ${JSON.stringify(w.text)}`); - - server.kill("SIGTERM"); - await new Promise((res) => setTimeout(res, 1500)); - if (!server.killed) server.kill("SIGKILL"); - - console.log("\n=== Pipeline smoke test passed ==="); -} - -main().catch((err) => { - console.error("FAILED:", err); - process.exit(1); -}); diff --git a/scripts/e2e-stt-smoke.mjs b/scripts/e2e-stt-smoke.mjs deleted file mode 100644 index 1586e07f..00000000 --- a/scripts/e2e-stt-smoke.mjs +++ /dev/null @@ -1,135 +0,0 @@ -// End-to-end STT smoke test: spawns whisper-stt-server directly (same -// binary/args as whisperServer.ts) and transcribes the actual user recording -// (12-second webm), same way the IPC handler does. -// -// Run: node scripts/e2e-stt-smoke.mjs -// -// ponytail: the recording path + the desktop model cache layout both match -// what production hands to the whisper-stt-server wrapper. If you want to -// point this at a different recording or model, override at runtime: -// RECORDING=/path/to/audio.webm MODEL=/path/to/ggml-small-q8_0.bin node scripts/e2e-stt-smoke.mjs - -import { spawn } from "node:child_process"; -import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { basename, dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const ROOT = join(__dirname, ".."); - -const RECORDING = join( - process.env.APPDATA || tmpdir(), - "Electron", - "recordings", - "recording-1783174040055.webm", -); -const WHISPER_BIN = join( - ROOT, - "electron", - "native", - "bin", - "win32-x64", - process.platform === "win32" ? "whisper-stt-server.exe" : "whisper-stt-server", -); -// ponytail: the GGML model file. Production hands it whatever -// modelManager.ensureModels() left under userData/stt-models/whisper-ggml/ggml-small-q8_0.bin. -const MODEL = - process.env.MODEL || - join( - process.env.APPDATA || tmpdir(), - "Electron", - "stt-models", - "whisper-ggml", - "ggml-small-q8_0.bin", - ); - -async function extractWav(src, dst) { - const ffmpeg = spawn("ffmpeg", ["-y", "-i", src, "-ar", "16000", "-ac", "1", "-f", "wav", dst], { - stdio: ["ignore", "pipe", "pipe"], - }); - return new Promise((resolve, reject) => { - let stderr = ""; - ffmpeg.stderr.on("data", (c) => (stderr += c.toString())); - ffmpeg.on("close", (code) => - code === 0 ? resolve() : reject(new Error(`ffmpeg exit ${code}: ${stderr}`)), - ); - ffmpeg.on("error", reject); - }); -} - -async function main() { - console.log("=== STT end-to-end smoke test ==="); - - // 1. ffmpeg-extract the recording to 16 kHz mono WAV. - const tmpDir = join(tmpdir(), "openscreen-stt-e2e"); - await mkdir(tmpDir, { recursive: true }); - const wavPath = join(tmpDir, "audio.wav"); - console.log(`Converting ${RECORDING} -> ${wavPath}`); - await extractWav(RECORDING, wavPath); - const { size } = await stat(wavPath); - console.log(`WAV ready: ${size} bytes`); - - // 2. Spawn whisper-stt-server directly (skip the wrapper module to keep - // the smoke test dependency-light). - const port = 18800; - console.log(`Spawning whisper-stt-server on 127.0.0.1:${port}`); - const server = spawn( - WHISPER_BIN, - ["--model", MODEL, "--port", String(port), "--host", "127.0.0.1"], - { stdio: ["ignore", "pipe", "pipe"] }, - ); - server.stderr.on("data", (c) => { - const line = c.toString().trimEnd(); - if (line) console.log(` server> ${line}`); - }); - - // 3. Wait for server to be ready (poll /). - const deadline = Date.now() + 30_000; - while (Date.now() < deadline) { - try { - const r = await fetch(`http://127.0.0.1:${port}/`); - if (r.ok) break; - } catch { - // not up yet - } - await new Promise((res) => setTimeout(res, 250)); - } - console.log(`Server up after polling`); - - // 4. Read the WAV bytes, post to /inference with response_format=verbose_json. - const wavBytes = await readFile(wavPath); - const form = new FormData(); - form.append("file", new Blob([wavBytes], { type: "audio/wav" }), basename(wavPath)); - form.append("response_format", "verbose_json"); - form.append("language", "auto"); - - const t0 = Date.now(); - const response = await fetch(`http://127.0.0.1:${port}/inference`, { - method: "POST", - body: form, - }); - const json = await response.json(); - const elapsed = Date.now() - t0; - console.log(`Inference took ${elapsed}ms`); - console.log("--- whisper-stt-server output ---"); - console.log(`backend: ${json.backend}`); - if (json.text) console.log(`text: ${JSON.stringify(json.text)}`); - if (Array.isArray(json.segments)) { - for (const seg of json.segments) { - console.log(` [${seg.start?.toFixed(2)}-${seg.end?.toFixed(2)}] ${seg.text?.trim()}`); - } - } - - // 5. Cleanup. - server.kill("SIGTERM"); - await new Promise((res) => setTimeout(res, 1500)); - if (!server.killed) server.kill("SIGKILL"); - - console.log("\n=== Smoke test complete ==="); -} - -main().catch((err) => { - console.error("FAILED:", err); - process.exit(1); -}); diff --git a/scripts/stt-dev-server.mjs b/scripts/stt-dev-server.mjs deleted file mode 100644 index 1c0b9210..00000000 --- a/scripts/stt-dev-server.mjs +++ /dev/null @@ -1,180 +0,0 @@ -/** - * Dev-mode fallback STT server for when the whisper.cpp C++ binary hasn't been - * built yet. Listens on a configurable port and responds to /inference with - * mock transcription data so the renderer and IPC pipeline can be tested end-to-end. - * - * Usage: - * node scripts/stt-dev-server.mjs --port 20199 - * - * Then set OPENSCREEN_WHISPER_SERVER_EXE to a script that spawns this, - * or copy the binary name to electron/native/bin/win32-x64/whisper-stt-server.exe - */ - -import { readFileSync, writeFileSync } from "node:fs"; -import { createServer } from "node:http"; -import { homedir, tmpdir } from "node:os"; -import { join } from "node:path"; - -const PORT = parseInt( - process.argv.find((a) => a.startsWith("--port="))?.split("=")[1] ?? - process.argv[process.argv.indexOf("--port") + 1] ?? - "20199", - 10, -); - -function htmlResponse(res, status, body, contentType = "application/json") { - res.writeHead(status, { "Content-Type": contentType }); - res.end(body); -} - -function parseMultipartBoundary(contentType) { - const match = contentType?.match(/boundary=(?:"([^"]+)"|([^;]+))/); - return match ? match[1] || match[2] : null; -} - -function parseMultipart(body, boundary) { - if (!boundary) return {}; - const parts = {}; - const delimiter = `--${boundary}`; - const sections = body.split(delimiter).filter((s) => s.includes("Content-Disposition")); - for (const section of sections) { - const nameMatch = section.match(/name="([^"]+)"/); - if (!nameMatch) continue; - const name = nameMatch[1]; - // Extract content after the blank line - const contentStart = section.indexOf("\r\n\r\n"); - if (contentStart === -1) continue; - let content = section.slice(contentStart + 4); - // Remove trailing CRLF + boundary delimiter artifacts - content = content.replace(/\r\n--$/, ""); - parts[name] = content; - } - return parts; -} - -// Generate mock transcription segments with realistic timestamps -function generateMockTranscript(audioDurationSec, language) { - const sampleWords = [ - "Hello", - "and", - "welcome", - "to", - "this", - "screen", - "recording", - "today", - "we", - "are", - "going", - "to", - "demonstrate", - "the", - "feature", - "this", - "is", - "a", - "test", - "of", - "the", - "speech", - "to", - "text", - "system", - "using", - "the", - "new", - "whisper.cpp", - "backend", - ]; - - const segments = []; - const wordSegments = []; - let currentTime = 0.0; - let wordIndex = 0; - - // Split into segments of 3-7 words each - let _segStart = 0; - let segId = 0; - - while (wordIndex < sampleWords.length && currentTime < audioDurationSec) { - const wordsInSegment = 3 + (wordIndex % 5); - const segText = []; - const segWords = []; - let segStartTime = currentTime; - - for (let w = 0; w < wordsInSegment && wordIndex < sampleWords.length; w++) { - const word = sampleWords[wordIndex++]; - const wordDuration = 0.2 + Math.random() * 0.3; - - segText.push(word); - segWords.push({ - word: " " + word, - start: Math.round(currentTime * 100) / 100, - end: Math.round((currentTime + wordDuration) * 100) / 100, - probability: 0.85 + Math.random() * 0.14, - }); - - wordSegments.push({ - word, - startSec: Math.round(currentTime * 100) / 100, - endSec: Math.round((currentTime + wordDuration) * 100) / 100, - }); - - currentTime += wordDuration + 0.05; - } - - const segEnd = currentTime; - segments.push({ - id: segId++, - text: " " + segText.join(" "), - start: Math.round(segStartTime * 100) / 100, - end: Math.round(segEnd * 100) / 100, - words: segWords, - }); - } - - return { - language, - detected_language: language, - backend: "whispercpp-cpu", - segments, - }; -} - -const server = createServer((req, res) => { - const url = new URL(req.url, `http://${req.headers.host}`); - - if (req.method === "GET" && url.pathname === "/") { - return htmlResponse(res, 200, "ok\n", "text/plain"); - } - - if (req.method === "POST" && url.pathname === "/inference") { - const chunks = []; - req.on("data", (chunk) => chunks.push(chunk)); - req.on("end", () => { - const raw = Buffer.concat(chunks); - const boundary = parseMultipartBoundary(req.headers["content-type"]); - const fields = boundary ? parseMultipart(raw.toString("latin1"), boundary) : {}; - - const language = fields.language || "auto"; - const lang = language === "auto" ? "en" : language; - - // Determine audio duration from mock WAV data - const wavData = fields.file; - const audioDurationSec = wavData - ? Math.max(2, Math.round((wavData.length - 44) / 2 / 16000)) - : 5; - - const result = generateMockTranscript(Math.min(audioDurationSec, 120), `<|${lang}|>`); - - htmlResponse(res, 200, JSON.stringify(result)); - }); - return; - } - - htmlResponse(res, 404, JSON.stringify({ error: "not found" })); -}); - -server.listen(PORT, "127.0.0.1", () => { - process.stdout.write(`[stt-dev-server] listening on 127.0.0.1:${PORT}\n`); -}); diff --git a/src/components/ai-edition/AiEditionShell.tsx b/src/components/ai-edition/AiEditionShell.tsx index 72d5d7a7..8bc0cf21 100644 --- a/src/components/ai-edition/AiEditionShell.tsx +++ b/src/components/ai-edition/AiEditionShell.tsx @@ -1,9 +1,9 @@ import { NewEditorShell } from "./NewEditorShell"; // ponytail: the new editor is the default for all users (merge plan §0 — the -// new editing model is NOT opt-in). AI_FEATURES_ENABLED gates only the -// LLM/agent UI (chat panel, provider settings) which mounts inside -// NewEditorShell when the flag is true. The legacy VideoEditor is deprecated. +// new editing model is NOT opt-in). The LLM/agent UI (chat panel, provider +// settings) always mounts inside NewEditorShell. The legacy VideoEditor is +// deprecated. export function AiEditionOrLegacy() { return ; diff --git a/src/components/ai-edition/LeftPanel.tsx b/src/components/ai-edition/LeftPanel.tsx index 3bb5024a..b934d183 100644 --- a/src/components/ai-edition/LeftPanel.tsx +++ b/src/components/ai-edition/LeftPanel.tsx @@ -2,7 +2,6 @@ import { ArrowLeft, Check, Film, Loader2, MessageSquare, Plus, Search, X } from import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { toast } from "sonner"; -import { AI_FEATURES_ENABLED } from "@/components/video-editor/featureFlags"; import { useScopedT } from "@/contexts/I18nContext"; import { type AxcutAsset, ensureDocument } from "@/lib/ai-edition/schema"; import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; @@ -330,7 +329,7 @@ export function LeftPanel({ assetStatuses?: Record; onRegenerateAsset?: (assetId: string, language: string) => Promise; }) { - return active === "chat" && AI_FEATURES_ENABLED ? ( + return active === "chat" ? ( ) : ( @@ -1815,9 +1814,7 @@ function ChatStripPanel() { } const RAIL_BUTTONS: Array<{ id: LeftTab; labelKey: string; icon: React.ElementType }> = [ - ...(AI_FEATURES_ENABLED - ? [{ id: "chat" as LeftTab, labelKey: "leftRail.chat", icon: MessageSquare }] - : []), + { id: "chat", labelKey: "leftRail.chat", icon: MessageSquare }, { id: "media", labelKey: "leftRail.media", icon: Film }, ]; diff --git a/src/components/ai-edition/chatBudget.ts b/src/components/ai-edition/chatBudget.ts index 0acbc561..59435255 100644 --- a/src/components/ai-edition/chatBudget.ts +++ b/src/components/ai-edition/chatBudget.ts @@ -14,14 +14,14 @@ export interface ChatBudget { ratio: number; } -export const DEFAULT_CHAT_BUDGET_TOKENS = 80_000; +const DEFAULT_CHAT_BUDGET_TOKENS = 80_000; -export interface RenderableChatMessage { +interface RenderableChatMessage { content: string; toolCalls?: Array<{ name?: string; summary?: string }>; } -export function estimateTokens(messages: RenderableChatMessage[]): number { +function estimateTokens(messages: RenderableChatMessage[]): number { let chars = 0; for (const m of messages) { chars += m.content.length; diff --git a/src/components/video-editor/featureFlags.ts b/src/components/video-editor/featureFlags.ts deleted file mode 100644 index aa66f6a1..00000000 --- a/src/components/video-editor/featureFlags.ts +++ /dev/null @@ -1,8 +0,0 @@ -// ponytail: gates ONLY the LLM/agent surface — provider settings dialog, chat -// panel, suggestions list, "Restore checkpoint" actions. Does NOT gate the -// new editing model, project panel, timeline, transcript editor, or exporter. -// Local Whisper is privacy-safe and also not gated. The new editor ships as -// the default from Phase 1 PR 1.3 onward; this flag is just the AI-features -// opt-in. Renamed from AI_EDITION_ENABLED on 2026-06-26 — see -// technical-documentation/architecture/decisions.md / §3 (decision 9). -export const AI_FEATURES_ENABLED = true; diff --git a/src/components/video-editor/videoPlayback/constants.ts b/src/components/video-editor/videoPlayback/constants.ts index 0cfa17c6..84b273af 100644 --- a/src/components/video-editor/videoPlayback/constants.ts +++ b/src/components/video-editor/videoPlayback/constants.ts @@ -3,8 +3,6 @@ import type { ZoomFocus } from "../types"; export const DEFAULT_FOCUS: ZoomFocus = { cx: 0.5, cy: 0.5 }; export const TRANSITION_WINDOW_MS = 1015.05; export const ZOOM_IN_TRANSITION_WINDOW_MS = TRANSITION_WINDOW_MS * 1.5; -export const MIN_DELTA = 0.0001; -export const VIEWPORT_SCALE = 0.8; export const SMOOTHING_FACTOR = 0.12; export const ZOOM_TRANSLATION_DEADZONE_PX = 1.25; export const ZOOM_SCALE_DEADZONE = 0.002; diff --git a/src/utils/getTestId.ts b/src/utils/getTestId.ts deleted file mode 100644 index 07454e4d..00000000 --- a/src/utils/getTestId.ts +++ /dev/null @@ -1,10 +0,0 @@ -export type TestId = - | `gif-size-button-${string}` - | "export-button" - | "export-panel-button" - | "gif-format-button" - | "mp4-format-button"; - -export function getTestId(testId: TestId) { - return `testId-${testId}`; -} diff --git a/technical-documentation/architecture/ai-agent.md b/technical-documentation/architecture/ai-agent.md index c30f75ac..e3830dd1 100644 --- a/technical-documentation/architecture/ai-agent.md +++ b/technical-documentation/architecture/ai-agent.md @@ -4,9 +4,9 @@ The optional AI editing layer lives in `electron/ai-edition/` and `src/component ## What it is and what gates it -`AI_FEATURES_ENABLED` in `src/components/video-editor/featureFlags.ts` gates the provider settings, chat panel, suggestions, and checkpoint-restore UI. Its current default is `true`, so the AI surface is enabled unless the source constant is changed. +The AI surface is always mounted. Without an API key configured the chat panel is a "no provider connected" welcome view, so the user-visible behavior is the same as the old `AI_FEATURES_ENABLED = false` path — no chat rail, no panel. Configuring a provider (or signing in via OAuth) re-enables everything. -The boundary is intentionally narrow: the flag gates only the LLM and agent UI. The editing model, project panel, timeline, transcript and export surfaces ship to every user. Local Whisper transcription is privacy-preserving and is not behind this flag. +The boundary is intentionally narrow: only the LLM and agent UI are gated. The editing model, project panel, timeline, transcript and export surfaces ship to every user. Local Whisper transcription is privacy-preserving and is not gated. ## The tool loop diff --git a/technical-documentation/architecture/decisions.md b/technical-documentation/architecture/decisions.md index 75d5d996..e6cd0461 100644 --- a/technical-documentation/architecture/decisions.md +++ b/technical-documentation/architecture/decisions.md @@ -19,7 +19,7 @@ A decision leaves this list only when the code stops honouring it. | **Windows production recording does not silently fall back to `getDisplayMedia` / `MediaRecorder`.** A native path that fails, fails loudly. | A silent fallback produced recordings that were subtly worse with no signal to the user. | | **Compositing and encoding run in one native D3D11 engine**, shared by live preview and export. | See [native-compositor.md](native-compositor.md), and [../engineering/rendering-performance.md](../engineering/rendering-performance.md) for the measurements that chose it. | | **Local transcription is bundled and never gated.** It runs on-device; no audio leaves the machine. | It is the foundation the caption and transcript features stand on, and gating it would make the privacy story conditional. See [transcription-and-captions.md](transcription-and-captions.md). | -| **The AI/LLM surface is behind `AI_FEATURES_ENABLED`** (`src/components/video-editor/featureFlags.ts`) and gates *only* the provider settings, chat panel, suggestions and checkpoint-restore UI. The editing model itself ships to every user. | The editor has to be complete without an LLM. Two independent rollouts, one flag. | +| **The AI/LLM surface ships to every user.** The provider settings, chat panel, suggestions and checkpoint-restore UI are always mounted. The editing model itself ships to every user. The LLM is opt-in at the credentials step — without an API key the chat panel is a "no provider connected" welcome view. | The editor has to be complete without an LLM. The chat panel becoming a no-op when no key is set covers the same UX the old flag did, without the binary cutoff. | | **LLM credentials live in Electron `safeStorage`** (the OS keychain), never in plain JSON on disk. A write fails rather than falling back to plaintext. | `electron/ai-edition/llm-config-store.ts`. See [llm-providers.md](llm-providers.md). | | **The project file extension is `.openscreen`.** Builds that wrote `.axcut` are read and renamed forward on first open. | Users already recognise the extension; `electron/ai-edition/document-service.ts:23` holds both. | | **Migrations are forward-only.** A document is migrated up to the current `schemaVersion` on open and never written back down. | Round-tripping through an older schema loses fields silently. | From 73b2b187c07a74bbd9165e715474edfbbbe1da08 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 28 Jul 2026 20:49:18 +0200 Subject: [PATCH 2/2] docs: drop references to the scripts this PR deletes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetch-ffmpeg.mjs still justified keeping ffmpeg.exe 'so scripts/bench-export.mjs can use it' — the same stale reference this PR already fixed in electron-builder.json5. The real reason it stays is the licence check below it. rendering-performance.md still said bench-export.mjs 'survives', and ai-agent.md claimed the no-provider state renders no chat rail; the rail entry is now unconditional and opening it shows the welcome view. --- scripts/fetch-ffmpeg.mjs | 7 +++---- technical-documentation/architecture/ai-agent.md | 2 +- .../engineering/rendering-performance.md | 4 ++-- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/scripts/fetch-ffmpeg.mjs b/scripts/fetch-ffmpeg.mjs index 0301981b..2d137c65 100644 --- a/scripts/fetch-ffmpeg.mjs +++ b/scripts/fetch-ffmpeg.mjs @@ -44,15 +44,14 @@ // dlopens. electron-builder excludes the static ffmpeg.exe from the installer // ("!win32-*/ffmpeg.exe") — it is a bench tool, and shipping a large binary no // runtime code opens is cost with no benefit. It still lands in this directory -// so scripts/bench-export.mjs can use a local checkout. +// because the licence check below reads it. // // NOTE: the plan this was vendored for is REFUTED. Feeding native ffmpeg from // the renderer measured 2.1x SLOWER than the WebCodecs path it was to replace — // the wall is the compositor, not the encoder. See // technical-documentation/engineering/rendering-performance.md. The binary stays -// because the bench's -// `native` arms use it, and because a future native core would still need a -// licence-gated H.264 encoder; nothing here ships on the export path today. +// because a future native core would still need a licence-gated H.264 encoder; +// nothing here ships on the export path today. // // macOS: BtbN publishes no macOS target, so darwin is not handled here. diff --git a/technical-documentation/architecture/ai-agent.md b/technical-documentation/architecture/ai-agent.md index e3830dd1..7489f843 100644 --- a/technical-documentation/architecture/ai-agent.md +++ b/technical-documentation/architecture/ai-agent.md @@ -4,7 +4,7 @@ The optional AI editing layer lives in `electron/ai-edition/` and `src/component ## What it is and what gates it -The AI surface is always mounted. Without an API key configured the chat panel is a "no provider connected" welcome view, so the user-visible behavior is the same as the old `AI_FEATURES_ENABLED = false` path — no chat rail, no panel. Configuring a provider (or signing in via OAuth) re-enables everything. +The AI surface is always mounted. Without an API key configured the chat panel is a "no provider connected" welcome view, so the practical effect matches the old `AI_FEATURES_ENABLED = false` path: nothing agentic runs. (The chat rail entry itself stays — opening it shows the welcome view.) Configuring a provider (or signing in via OAuth) re-enables everything. The boundary is intentionally narrow: only the LLM and agent UI are gated. The editing model, project panel, timeline, transcript and export surfaces ship to every user. Local Whisper transcription is privacy-preserving and is not gated. diff --git a/technical-documentation/engineering/rendering-performance.md b/technical-documentation/engineering/rendering-performance.md index 93468ae3..7d142418 100644 --- a/technical-documentation/engineering/rendering-performance.md +++ b/technical-documentation/engineering/rendering-performance.md @@ -91,7 +91,7 @@ C8's 104.0 fps sits under the ~126 headline above. Different session and thermal ### Bench methodology (of the deleted harness) -`npm run bench:export` (`scripts/bench-export.mjs` + `src/bench/runBench.ts`) opens the real editor window — same `webPreferences`, preload, sandbox — loads a real saved project through the same bridge the editor uses, and calls `exportAxcutDocument` (`ExportDialog`'s entry point). React is skipped, so nothing renders alongside. +`npm run bench:export` (`scripts/bench-export.mjs` + `src/bench/runBench.ts`) opened the real editor window — same `webPreferences`, preload, sandbox — loads a real saved project through the same bridge the editor uses, and calls `exportAxcutDocument` (`ExportDialog`'s entry point). React is skipped, so nothing renders alongside. Arms interleave A/B/A/B; same-arm spread is reported; a run above **10 %** spread declares itself VOID. Two earlier runs were discarded because battery and thermal drift (up to 62 % spread) inverted the conclusion. Treat any un-gated benchmark on this hardware as noise. @@ -368,7 +368,7 @@ Gate G0 measured the in-run effect: legacy 9.8 → shipping 14.6 fps (+49 %) on ## The WebCodecs bench (retired) -> Retired with the pipeline it measured. `src/bench/runBench.ts` is deleted and `npm run bench:export` is no longer a script in `package.json`; `scripts/bench-export.mjs` survives but its runner does not. The live harness is [`x.bat --cfg C0..C8`](#measuring-it-today). The design rules below — interleaved arms, spread gates, ratios-only, gated parity — are what any replacement has to keep, which is why they are recorded. +> Retired with the pipeline it measured. `src/bench/runBench.ts` is deleted and `npm run bench:export` is no longer a script in `package.json`; `scripts/bench-export.mjs` is deleted along with its runner. The live harness is [`x.bat --cfg C0..C8`](#measuring-it-today). The design rules below — interleaved arms, spread gates, ratios-only, gated parity — are what any replacement has to keep, which is why they are recorded. ### Command