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/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/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 b8e15808..d411c746 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";
@@ -329,7 +328,7 @@ export function LeftPanel({
assetStatuses?: Record;
onRegenerateAsset?: (assetId: string, language: string) => Promise;
}) {
- return active === "chat" && AI_FEATURES_ENABLED ? (
+ return active === "chat" ? (
) : (
@@ -1969,9 +1968,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..7489f843 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 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: 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. |
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