Status: written 2026-10-14 from a brainstorming session with Dan and a five-reviewer design
council the same day (§27). Every decision is dated and attributed in the decision log (§28).
Things that could not be verified while designing are listed in §29; each one is checked in the
milestone that first depends on it.
vid2sub turns a video, a local file or a YouTube URL, into subtitle files: it extracts the
audio, transcribes it with timestamps in the video’s language, optionally translates it, and
writes SRT (and optionally ASS) next to the video. A browser UI plays the video with the
subtitles and edits them. All the work runs on the user’s own machine, or on models the user
runs themselves, such as Dan’s DGX Spark; OpenAI is an opt-in fallback.
Contents
Purpose and scope
Users, languages and platforms
Architecture
Repository layout and packaging
Configuration
Security model
Privacy
Media pipeline
Transcription engines
Detect, suggest, confirm
Model catalog
Building cues
Translation
The sidecar and data model
Subtitle files
Glossary
Jobs and the queue
CLI
The local web app
The editor
The Pages editor
Testing
CI, releases and distribution
Development conventions
Milestones
Items for other repos
Council review, 2026-10-14
Decision log
Verify during the build
1. Purpose and scope
Goal (Dan, 2026-10-14). Better closed captions, or a good starting point for them, for
YouTube videos and TV shows. Point the app at a video; the user’s machine downloads it if
needed, extracts the audio, transcribes it into subtitles in the video’s language
(auto-detected, overridable), optionally translates them, and writes SRT or ASS.
Principles.
The user’s machine does the work. There is no vid2sub server. vid2sub is a Python CLI
application with a local web app over the same core (§3.1). Both run commands on the user’s own
computer, against models on that computer, on a machine they run (a DGX Spark reached through
an SSH tunnel), or, only when they opt in for a run, a cloud API.
The app stores no state of its own. Settings live in a file the user writes (§5); keys
live in the user’s environment; results live next to the video (§14, §15). Caches are
disposable (§8.9).
Never overwrite an artifact. The sidecar is append-only; subtitle files are projections of
it and are only ever rewritten when vid2sub wrote them (§14, §15). This is the rule Dan’s
audio-router and recordings follow.
Cross-platform. macOS, Windows and Linux, each with README setup that covers the
modelling parts (§2.3, §23.5).
In scope for v1. Everything in §3–§24, delivered in the order of §25.
Not in v1 (the Later list in §25 says when each might come): the Pages UI driving a local
engine (“B”, §3.3); importing subtitle tracks embedded in MKV files; embedding YouTube’s player;
speaker labels; a vid2sub compare model comparison; dedicated translation models
(TranslateGemma, HY-MT); mlx-whisper, Parakeet and local Qwen3-ASR on CUDA as in-process
engines; a version-history UI; sidecar compaction.
2. Users, languages and platforms
2.1 Users
Dan first, on a Mac with the DGX Spark at home. The app and README are written so another person
can install it on their own machine with their own models or an OpenAI key.
2.2 Languages (Dan, 2026-10-14)
Input: Mandarin, Cantonese, Japanese, English.
Output: mainly English. English in → English captions out is a real case too.
Not one model for everything. Each source language gets the model that is best at it
(§11), suggested after the audio is sampled and the user can override (§10).
2.3 Platforms
Platform
Supported
Local transcription engine in v1
macOS 14+ on Apple Silicon
yes
mlx-qwen3-asr in-process (§9.4)
Windows 10/11 x64
yes
faster-whisper (CPU, or NVIDIA through the CUDA 12 cuBLAS runtime)
Linux x64 / aarch64
yes
faster-whisper (CPU or NVIDIA)
Intel Macs
no
onnxruntime, needed by the VAD (§8.6), has no Intel macOS wheel
Any platform can use an OpenAI-compatible endpoint instead (§9.3): the Spark, OpenAI, a local
whisper.cpp server, or mlx-qwen3-asr serve.
Python ≥3.12. The floor comes from the engines’ wheels, not from Pyodide (recordings needs 3.14
and still ships Pyodide’s 3.12); the pure core is tested on 3.12 and 3.14 (§22).
3. Architecture
3.1 Three ways to run it
flowchart LR
subgraph user["User's machine"]
term["Terminal<br/>vid2sub run · detect · translate"]
browser["Browser<br/>React UI"]
web["vid2sub serve<br/>FastAPI + Shiny on 127.0.0.1"]
core["vid2sub core library<br/>media · engines · subs · translate"]
tools["ffmpeg · ffprobe · yt-dlp + deno"]
local["In-process engines<br/>mlx-qwen3-asr · faster-whisper"]
files[("Video folder<br/>video · .vid2sub.jsonl · .xx.srt")]
end
spark["DGX Spark via SSH tunnel<br/>127.0.0.1:9100/v1<br/>whisper.cpp · Gemma 4"]
cloud["OpenAI<br/>opt-in per run"]
pages["GitHub Pages<br/>Shinylive editor"]
term --> core
browser <-->|"websocket + token cookie"| web
web --> core
core --> tools
core --> local
core <--> files
core -->|"chunks, cue text"| spark
core -.->|"only with allow_cloud"| cloud
pages -->|"File System Access"| files
The CLI (vid2sub run, detect, translate, …; §18): a Python command-line application
for the terminal and for batch work, such as a season, a folder tree, a list of files, a script
or a scheduled job. It needs no browser and no Node. It is installed as a uv tool, or editable
from a clone (§23.5). Dan, 2026-10-14: vid2sub is a CLI application, not only a web app.
The local web app (vid2sub serve): the same core behind a Python engine on 127.0.0.1
that serves the React UI, runs jobs, and reads and writes files next to videos.
The Pages editor (chendaniely.github.io/vid2sub/): the same UI and the same pure Python
subtitle code running in the browser through Shinylive. It opens a folder on the user’s disk,
plays the video, edits and saves subtitles. It cannot generate subtitles (§21).
Generating subtitles happens only in the core, through the CLI or vid2sub serve; the two share
every behaviour (§18.3).
3.2 Core library with a CLI and the app on top (Dan, 2026-10-14)
All behaviour lives in vid2sub’s core: plain functions and small classes for probing,
extracting, chunking, transcribing, building cues, translating and writing. The CLI (§18) and the
web app (§19) call the same core. The subtitle model (vid2sub.subs) and the catalog
(vid2sub.catalog) are pure: standard library, pydantic, pysubs2, opencc-purepy and
charset-normalizer only, no I/O beyond what callers hand them, so they run unchanged in Pyodide.
An import-graph test enforces it (§22).
3.3 “B”, later: the Pages UI driving the local engine
Dan wants B if it can be made to work (2026-10-14). It is not in v1, but v1 is shaped for it:
The Shiny server talks to a Backend interface whose requests and responses are versioned,
JSON-serialisable types keyed by item id. LocalBackend (the engine) and BrowserBackend
(Pages) implement it. B adds a small versioned JSON API over LocalBackend; it never exposes
the Shiny websocket cross-origin (§6.10).
B’s own security rules are decided now (§6.10).
4. Repository layout and packaging
4.1 Layout
pyproject.toml one distributable package: vid2sub
src/vid2sub/
subs/ PURE — model.py (Word, Cue, Track…), build.py (cues from words, §12),
edit.py (split/merge/nudge/insert/delete), lint.py, text.py (EAW width,
kinsoku, canonical text), formats.py (SRT/ASS via pysubs2, §15),
sidecar.py (records, heads, merge, §14), naming.py (§15.1),
scripts.py (OpenCC Traditional/Simplified), translate_shape.py
(batch schemas + partition validator, §13)
catalog.py PURE — the model catalog (§11)
parse/ PURE — engine response parsers → normalised words (§9.2)
media/ probe.py, identity.py, decode.py, vad.py (Silero v6 ONNX), chunk.py,
preview.py, youtube.py, tools.py (resolving and running ffmpeg/yt-dlp)
engines/ base.py (Engine protocol), endpoint.py, qwen_mlx.py,
faster_whisper.py, clients.py (the one client factory, §7.2)
translate/ chatlas provider factory, batching, staleness
pipeline.py one job = ordered stages, progress events, cancellation
jobs.py the in-memory queue (§17)
config.py vid2sub.toml loading and validation (§5)
security.py launch token, safe paths and writes, subprocess env (§6)
cli.py `vid2sub …` (§18)
web/
app.py FastAPI shell: /auth, /healthz, /media/{token}; Shiny mounted at /
guards.py HostGuard, WriteGuard (copied from recordings), TokenGuard, CSP
server.py the shinyreact server (ReactApp(server) is called in this file)
backend.py Backend protocol, LocalBackend, BrowserBackend
www/ ui.js + ui.css (build output), fonts
frontend/ Vite + React 19 + TypeScript + Tailwind v4 + shadcn (radix-nova)
pages/ the Shinylive entry (app.py, requirements.txt)
demo/ a public-domain clip, its canned sidecar and subtitles, CREDITS.md
scripts/ build_pages.py, brand_css.py, shinylive_local.py, rangeserver.py,
build_index.py (copied from recordings where noted, §4.4)
tests/ _brand.yml Makefile CLAUDE.md README.md vid2sub.example.toml
4.2 Dependencies
Core: pydantic (≥2.10, the Pyodide floor is 2.10.5), pysubs2 (≥1.9), opencc-purepy
(≥1.4, behind subs/scripts.py so it can be swapped), charset-normalizer, fastapi, uvicorn,
shiny and shinyreact (pinned exactly, matching recordings), chatlas (≥0.23), openai,
httpx, numpy, onnxruntime, yt-dlp[default] (lower bound only; a stale yt-dlp breaks on
YouTube), platformdirs.
Extras:
[mac]: mlx-qwen3-asr[aligner] (≥0.4.4; macOS 14+ on Apple Silicon).
[whisper]: faster-whisper (all platforms, CPU).
[nvidia]: faster-whisper plus the CUDA 12 cuBLAS runtime wheels (Windows and Linux).
Not an extra:qwen-asr. It hard-pins transformers and accelerate, pulls in gradio and
flask, is unsatisfiable together with mlx-audio>=0.5, and PyPI’s torch is CPU-only on Windows
(council, 2026-10-14). Qwen3-ASR runs on the Mac through mlx-qwen3-asr and on the Spark
through a service local-ai builds (§26.1, item 7).
[tool.uv] conflicts declares any extras that can’t resolve together.
System tools, checked by vid2sub doctor (§18): ffmpeg and ffprobe (≥6.0), deno (≥2.3;
yt-dlp’s only default JavaScript runtime for YouTube).
4.3 Versions and pins
Exact pins for shiny, shinyreact, shinylive and the npm @posit-dev/shinyreact types package,
upgraded together by the procedure in recordings’ CLAUDE.md (§24). Lower bounds for engines
and yt-dlp. uv.lock committed; CI runs with UV_FROZEN=1.
4.4 Code copied from Dan’s other repos
Copied files carry a header naming the source repo, path and commit, and are relicensed under
this repo’s licence where Dan owns both. Planned copies:
recordings: hosts.py (HostGuard, WriteGuard), the media route, scripts/build_pages.py,
brand_css.py, shinylive_local.py, rangeserver.py, _brand.yml, the frontend’s
Player.tsx, TranscriptTab.tsx patterns, lib/transcript.ts, lib/theme.ts, ThemeSwitch,
sr.ts, vite.config.js, and media.py’s decode (which already carries audio-router’s
provenance, so decode has one lineage).
audio-router: vocabulary_prompt and prompt_sha (stages/transcribe.py), the
version-string recipe (worker.py).
meanwhile: FolderPicker and media/folder.ts, changed to mode: 'readwrite' (§21).
faster-whisper (MIT, third party): silero_vad_v6.onnx and the torch-free
get_speech_timestamps, with its licence (§8.6).
vid2sub does not import recordings or audio-router: recordings core needs Python 3.14 and
pins pydantic; audio-router’s engine set is closed. They share a data contract instead (§14.4).
5. Configuration
5.1 vid2sub.toml (Dan, 2026-10-14)
Settings live in one TOML file the user writes. The app never writes it, and it refuses
key values: only the names of environment variables that hold keys. Found at --config PATH,
else VID2SUB_CONFIG, else the platform config directory (~/.config/vid2sub/vid2sub.toml on
macOS and Linux, %APPDATA%\vid2sub\vid2sub.toml on Windows). vid2sub config init prints a
starter file to stdout. vid2sub.example.toml in the repo is the template (named .toml, not
.env, and never holding a secret).
[server]port=8765open_browser=true[privacy]local_hosts=["127.0.0.1","localhost","::1"]# where a `local = true` model may live[endpoints.spark]base_url="http://127.0.0.1:9100/v1"# make tunnel (local-ai)key_env="SPARK_API_KEY"# VID2SUB_SPARK_API_KEY once local-ai Phase 3 issues one[endpoints.openai]base_url="https://api.openai.com/v1"key_env="OPENAI_API_KEY"allow_typed_key=true# a key may be typed into the UI for this endpoint (§5.3)[models.spark-turbo]catalog="whisper-large-v3-turbo"# catalog entry: descriptions, languages (§11)endpoint="spark"model="whisper-large-v3-turbo"# the name the server knowsparser="whisper.cpp"# response shape (§9.2)local=true# valid only because spark's host is in local_hosts[models.spark-gemma]catalog="gemma-4-26b-a4b"endpoint="spark"model="gemma-4-26b-a4b"task="translate"local=trueconcurrency=1request_extra={chat_template_kwargs={enable_thinking=false}}[models.mac-qwen]catalog="qwen3-asr-1.7b"engine="mlx-qwen3-asr"# in-process; local by definition[models.openai-whisper]catalog="openai-whisper-1"endpoint="openai"model="whisper-1"parser="openai"[defaults]targets=["en"]formats=["srt"]youtube_dir="~/Movies/vid2sub"cache_max_gb=20
5.2 Validation
Unknown keys anywhere are refused at load, naming the key and the line (an audio-router
lesson).
A key-looking value (sk-…, or any *_key/*_token field holding a value rather than an env
var name) is refused, naming only the field.
local = true is refused unless the model is in-process or its endpoint’s host is in
privacy.local_hosts.
A key is sent over plain http only to a loopback address or a Tailscale address
(100.64.0.0/10, *.ts.net); otherwise the endpoint must be https.
Every config key has a test proving its value reaches the code that uses it (an
audio-router lesson).
5.3 Keys
From the environment, by name, at call time. On Dan’s Mac ~/.secrets exports them through
.zshrc; on Windows, setx or a PowerShell profile. A long-running engine keeps the
environment it started with, so the 401 hint says to restart vid2sub from a shell that exports
the key.
Typed into the UI (Dan, 2026-10-14): allowed only for an endpoint declared with
allow_typed_key = true. The key is held in the engine’s memory for that run, scoped to that
endpoint’s origin, never written, logged or sent back to the UI (which shows only “key entered
for this session”), and dropped on exit.
A key goes only to the origin its endpoint declared (§6.4).
5.4 Precedence
CLI flags (per run) > vid2sub.toml > built-in defaults. UI changes are per session and never
written; Settings → Copy settings gives the current session’s settings as TOML to paste into
the file, never including a key. A model entry’s task defaults to transcribe.
6. Security model
The engine is a local RPC server that lists directories, reads and writes files, runs
subprocesses and sends requests carrying API keys, and it runs on other people’s machines. The
council’s red team (§27) found three blockers in the first draft; the rules below fix them.
6.1 Binding and the launch token
The engine binds 127.0.0.1 only.
Per-launch token, as Jupyter does. On start the engine makes secrets.token_urlsafe(32).
It opens the browser through a redirect file (mode 0600, in a private temp directory) that
sends the browser to http://127.0.0.1:<port>/auth#t=<token>; the token is in the fragment,
so it never reaches the server’s logs or the command line (ps). The /auth page’s script
(served as a file, no inline script) posts the token to /auth and receives an HttpOnly,
SameSite=Strict cookie whose name includes the port (vid2sub-8765), then replaces the URL.
The CLI also prints that URL for opening by hand.
Every route and the websocket handshake require the cookie, except /auth and its script,
and /healthz, which returns only {"ok": true}.
6.2 Origin and write guards
Copied from recordings (hosts.py), and kept because other localhost ports count as same-site:
HostGuard: HTTP requests with a Host outside the allowlist get 400. A websocket is closed
(1008) before accept unless the Host is allowed, the Origin is exactly http://<Host> with the
port, and Sec-Fetch-Site is empty, same-origin or none.
WriteGuard: every response gets Cross-Origin-Resource-Policy: same-origin,
Cross-Origin-Opener-Policy: same-origin, X-Frame-Options: DENY,
Referrer-Policy: same-origin. Any method other than GET/HEAD/OPTIONS needs a same-origin
request with Content-Type: application/json.
No GET request writes anything or starts a process. Shiny bookmarking stays off.
6.3 Content-Security-Policy and untrusted text
Any script on the app’s origin controls everything, so nothing hostile may run there.
CSP on every page:default-src 'self'; script-src 'self'; connect-src 'self';
object-src 'none'; base-uri 'none'; frame-ancestors 'none'; img-src 'self' data: blob:;
media-src 'self' blob:. Style rules are whatever Shiny and shinyreact need, verified in M2
(§29); never unsafe-eval.
Untrusted text is rendered as plain text in React: subtitle text (SRT <font> tags, ASS
{\…} overrides), MKV tags, YouTube titles, file names, LLM output, error bodies, /v1/models
lists. The caption overlay renders <i> only, through a whitelist; never
dangerouslySetInnerHTML.
Fonts are served locally. The app makes no third-party requests.
/media/ responses send Content-Security-Policy: sandbox, X-Content-Type-Options: nosniff
and Cache-Control: no-store.
Hostile sample files (§22.4) are part of the test suite.
6.4 Endpoints and keys
Endpoints, key env var names, local flags, the YouTube output folder and the list of
models are fixed at launch by vid2sub.toml. The UI only chooses among them. A URL can’t be
typed into the UI.
A key goes only to the origin it was declared with. Every client gets api_key and
base_url passed explicitly (never read from OPENAI_API_KEY/OPENAI_BASE_URL by the SDK),
with redirects refused (follow_redirects=False), so a 307 can’t re-send audio or a key
elsewhere.
One client factory (engines/clients.py) creates every openai, httpx and chatlas client.
A test fails if any other module constructs one (§22.4).
6.5 Paths
Reads: only files the user added this launch (typed, browsed to, or in a folder they added)
and files vid2sub derives from them (sidecar, glossary, subtitles next to the video).
Writes: only (a) the sidecar, subtitle projections and the glossary next to an added video,
(b) the YouTube output folder from vid2sub.toml, (c) the cache (§8.9), (d) per-job scratch.
The in-app file browser lists home and mounted volumes (/Volumes, drive letters,
/media, /mnt), shows only folders, video and subtitle files, and hides dotfiles and
credential folders (.ssh, .aws, .gnupg, .config/gh, …).
Accepted paths are fully qualified. UNC paths only when the user typed or browsed to them,
never when read from a file’s contents (a UNC path leaks the NTLM hash).
safe_component() builds every file name vid2sub writes: Unicode NFC, control and bidi
characters stripped, : and other reserved characters replaced, Windows reserved names
(CON, NUL, COM1… even with an extension) refused, trailing dots and spaces stripped,
≤255 bytes. YouTube titles go through it.
Media tokens:secrets.token_urlsafe(32), bound to the session, recording the file’s
(dev, inode) so a swapped file isn’t served.
6.6 Writes
safe_write() is the only way vid2sub writes a file:
write a temporary file in the target directory with O_EXCL | O_NOFOLLOW (on Windows, the
equivalent create-new flags), fsync, then os.replace;
refuse a target that exists and isn’t a regular file (symlinks included);
never overwrite a file vid2sub didn’t write (one with no projection record in the sidecar,
§15.3); the UI asks per file, the CLI needs --overwrite-foreign;
each Save carries the sidecar hash it started from (compare-and-swap, §14.6), so two tabs or a
tab and a job can’t overwrite each other;
on Windows, a replace that fails with a sharing violation is retried with backoff, then fails
with “Plex or antivirus has the file open”.
6.7 Subprocesses
media/tools.py runs every child process:
absolute executable paths, resolved by doctor’s rules (shutil.which, then known install
locations such as /opt/homebrew/bin), never the current directory;
argument lists, no shell; cwd = the job’s scratch directory, created by mkdtemp
(mode 0700);
a minimal environment from an allowlist (PATH, HOME/USERPROFILE, TMP/TEMP,
LANG, SYSTEMROOT, …), so children never inherit keys;
timeouts proportional to duration.
ffmpeg/ffprobe:-nostdin -protocol_whitelist file -format_whitelist <the demuxers vid2sub
accepts>, inputs as file: + absolute path. A playlist disguised as .mp4 can then neither
fetch URLs nor read other files.
yt-dlp: run from the engine’s own environment with `–ignore-config –no-plugin-dirs
–use-extractors youtube –no-playlist -f bestaudio/best -P -o "%(id)s.%(ext)s" --
`. The URL's host must be `youtube.com`, `www.youtube.com`, `m.youtube.com`,
`music.youtube.com` or `youtu.be`, and the video id is validated (ids can start with `-`, hence
the `--`). Never `--exec`, `--cookies-from-browser`, `-U` or unsafe-extension options. The
downloaded file is renamed to `safe_component(" []").` in the YouTube output
folder.
### 6.8 Logs and errors
- vid2sub's own logger records job ids, stage names, durations and error kinds, **never titles,
paths, tokens or keys** (the `audio-router` `TITLE_KEYS` rule). Titles reach only an
interactive terminal, never `--json` output or logs.
- The `openai`, `httpx` and `chatlas` loggers are fixed at WARNING; chatlas runs with
`echo="none"`; uvicorn's access log is off; `OPENAI_LOG` and `CHATLAS_LOG` are ignored.
- `redact()` removes every configured key value and typed key from any exception text before it
is shown or logged.
### 6.9 The Pages origin
The Pages editor shares `chendaniely.github.io` with `recordings` and `meanwhile`, and Pages can't
send `frame-ancestors`. So the editor refuses to run inside a frame (checked in JS), never
persists folder handles, and touches no network beyond its own files.
### 6.10 Rules for B, decided now
- B gets its own origin (a custom subdomain), not `chendaniely.github.io`.
- Pairing is approved on the local machine (the engine shows a code; the user confirms in the
terminal or the local UI).
- The bearer token travels in a header, never a cookie.
- CORS allows exactly B's origin, never `*` or `null`.
- The engine answers the Private Network Access preflight
(`Access-Control-Allow-Private-Network`), and the docs expect Chrome's Local Network Access
prompt. Safari is expected not to work.
- B uses the versioned JSON API over the Backend interface (§3.3), never the Shiny websocket.
## 7. Privacy
### 7.1 What leaves the machine
| Route | Sends | Never sends |
|---|---|---|
| Spark / any `local = true` model | audio chunks as `chunk-0003.flac` with metadata stripped; cue text and glossary for translation | file names, titles, paths |
| Cloud model (opt-in per run) | the same | the same |
| YouTube | the video id | anything else |
### 7.2 Cloud is opt-in, per run
- A model is local only if its entry says `local = true` and its endpoint's host is in
`privacy.local_hosts`, or it runs in-process (§5.2). llama-swap or Ollama can pass a request to
a hosted model on `localhost`, which is why the flag is per model, as in `recordings`.
- Everything else is cloud. A run may use a cloud model only when **that run** sets
`allow_cloud`: the `--allow-cloud` flag, or the checkbox on the Review screen, which starts
unticked every time. **No config key or environment variable can preset it** (audio-router's
rule: "no config key can make the exception a default").
- `clients.make_client(model, run)` raises `CloudNotAllowed` otherwise. It is the only factory
(§6.4), so the check can't be skipped.
- Never fall back to another model or to the cloud when a model fails (§17.4).
### 7.3 Uploads
- Chunks are uploaded as `("chunk-0003.flac", bytes, "audio/flac")`, never as a path object (the
SDK would send the real file name).
- Chunks are encoded with `-map_metadata -1 -fflags +bitexact`. ffmpeg otherwise copies an MKV's
title into the FLAC, and the Spark's whisper-server (with `--convert`) logs ffmpeg's report,
metadata tags included. A test checks the tags are empty (§22).
## 8. Media pipeline
### 8.1 Resolve
A local path (must exist and be a regular file), or a YouTube URL downloaded by yt-dlp (§6.7) into
the job's scratch directory and then moved, under a safe name, into the YouTube output folder:
audio only by default; the whole video when **keep video** is ticked (Dan, 2026-10-14). The
output folder is `defaults.youtube_dir`, or the platform's Videos folder + `vid2sub` when unset. A bot-check failure says to upgrade yt-dlp (`uv tool upgrade vid2sub
--upgrade-package yt-dlp`).
### 8.2 Probe
`ffprobe -show_format -show_streams -of json`: duration, `format.start_time`, video codec, every
audio stream (index, codec, channels, language tag, `disposition.default`, `start_time`).
**Browser playability** comes from the probe plus the browser's own `canPlayType`, reported by
the UI. Audio in AC-3, E-AC-3, DTS or TrueHD counts as unplayable even when the video codec plays
(silent playback is worse than none).
### 8.3 Video identity
Hashing a multi-GB file over SMB on every open is impractical. A video is identified by
`{size, quick_sha256, duration_ms}`, where `quick_sha256` hashes the size, the first MiB and the
last MiB. The full SHA-256 is computed by streaming during a job and appended to the sidecar as an
identity record (§14.2). On a mismatch the app warns with the difference rather than discarding
anything; it can re-link an orphaned sidecar after a rename by identity. Two videos with the same
stem in one folder are refused, because their sidecars and subtitles would collide.
### 8.4 Pick the audio track
The track whose language tag matches the chosen source language, else the file's default track,
else the first. The Review screen lists every track (language, codec, channels) for override
(Dan, 2026-10-14).
### 8.5 Decode and the timeline offset
Decode the chosen track to 16 kHz mono PCM WAV in scratch (`recordings`' `media.py` decode, §4.4).
Cue times are on the **player's timeline** (0 = start of playback). If the audio stream starts
later than the earliest stream (an MKV audio delay, an MPEG-TS `start_time`), the difference is
recorded as `timeline_offset_ms` and added to every word time. Tests use clips generated with an
audio delay (§22).
### 8.6 Voice-activity detection
Silero VAD v6 (ONNX) on onnxruntime with numpy, using the model file and the torch-free
`get_speech_timestamps` vendored from `faster-whisper` (MIT). ffmpeg's `silencedetect` is
energy-only and fails under music; the `silero-vad` PyPI package needs torch (council). Speech
regions are kept in the transcript's `meta` so the cue builder can drop words that fall in
non-speech (§12.5).
### 8.7 Chunking
- Each engine declares `max_chunk_s` (§9.1); for the endpoint engine it comes from the model's
catalog entry, since one endpoint engine serves several model families. 300 s for
Whisper-family models, 180 s for Qwen3-ASR (its aligner re-chunks above 180 s), whether
in-process or behind `mlx-qwen3-asr serve`.
- Speech regions are grouped into chunks whose **core** is at most `max_chunk_s − 2 × pad`
(pad 0.5 s), cut at the midpoint of a silence.
- Inside a chunk, non-speech spans longer than 2 s are replaced with silence, which cuts Whisper's
invented lines over music.
- A chunk's offset is the time of the first sample sent, padding included. A word belongs to the
chunk whose core contains its midpoint.
- **The language is fixed per job** (chosen at Review, §10) and passed with every chunk. Each
chunk's detected language is recorded in `meta` only.
- **Prompt per chunk:** the glossary's terms (§16), then the previous chunk's last ~200
characters, unless that chunk was flagged as hallucinated. Whisper prompts are truncated to fit
its 224-token prompt window, glossary first.
### 8.8 Preview copy
For an unplayable video, the editor plays a playback audio file (AAC stereo `.m4a`, made on
demand) straight away, and offers **Make preview copy**: H.264 480p with AAC stereo in MP4, using a
hardware encoder when available (VideoToolbox, NVENC, QSV), else `libx264 -preset veryfast`
(Dan, 2026-10-14).
### 8.9 Cache and scratch
- **Scratch:** a `mkdtemp` directory (0700) per job, holding the WAV and chunks, deleted when the
job ends. Chunk results are kept in memory for the engine's lifetime so a resumed job doesn't
redo finished chunks.
- **Cache:** the platform cache directory (`~/Library/Caches/vid2sub`,
`%LOCALAPPDATA%\vid2sub\Cache`, `~/.cache/vid2sub`), keyed by video identity, holding playback
audio and preview copies. Disposable, capped by `defaults.cache_max_gb` with least-recently-used
eviction, cleared by `vid2sub cache clear`.
### 8.10 Keeping the machine awake
While the queue runs: `caffeinate -i -w ` on macOS, `SetThreadExecutionState` on Windows,
`systemd-inhibit` on Linux when available.
## 9. Transcription engines
### 9.1 The Engine protocol
```python
class Engine(Protocol):
name: str # "whisper.cpp" | "openai" | "qwen3-asr" | "faster-whisper"
max_chunk_s: float
supports_prompt: bool
def transcribe(self, chunk: bytes, *, language: str, prompt: str) -> RawResult: ...
def detect_language(self, sample: bytes) -> dict[str, float]: ... # ISO code → probability
def stamp(self) -> ModelStamp: ... # model + revision ("unknown" when not knowable)
```
Engine names follow `audio-router` and `recordings`: `whisper.cpp`, `openai`, `qwen3-asr`,
`faster-whisper` (later `mlx-whisper`, `parakeet`). Heavy imports happen inside functions
(`audio-router`: mlx imports cost ~23 s), and in-process models are cached on the engine object
and released when the queue goes idle.
### 9.2 Normalised output and parsers
Every engine's response goes through a pure parser in `vid2sub.parse` into one shape:
- **Words follow Whisper's convention:** a leading space marks a word boundary, and
`"".join(w.text for w in words) == segment.text`. CJK text has no spaces; Cantonese–English
code-switching keeps the English words' leading spaces.
- **Times in integer milliseconds,** rounded once (chunk-offset sums otherwise produce values like
`300.1000000000002`).
- **Untimed words** follow `recordings`' rule: start = end = the previous word's end, counted in
`meta.untimed_words`. A segment whose words all lack times (Whisper's temperature fallback) is
**unaligned** and keeps `words: []`.
Parsers, each with recorded fixtures (§22.3):
- `whisper.cpp` v1.9.4 `verbose_json`: `segments[].words` are BPE tokens, merged only to complete
UTF-8 characters, so the parser merges them into words on the leading space. The top-level
`language` is a full name ("japanese"); the ISO code comes from `language_probabilities`.
`t_dtw` (when the server runs with DTW, §26.1) is in raw 10 ms units and is preferred over
start/end when present. A token containing U+FFFD falls back to segment timing.
Segments wrap at 60 characters unless `max_len` is set.
- OpenAI `whisper-1` `verbose_json` with `timestamp_granularities[]=word,segment`: words at the top
level, assigned to segments by time.
- `mlx-qwen3-asr serve` `verbose_json`: top-level aligner words. The aligner's text has no
punctuation (assumed until §29 confirms), so a pure `attach_punctuation(text, units)` maps the
ASR text's punctuation onto aligned units.
- `faster-whisper` segments with `word_timestamps=True`.
### 9.3 The endpoint engine
One engine for every OpenAI-shaped `/v1/audio/transcriptions` server: the Spark's whisper.cpp
(through llama-swap), OpenAI, a local whisper.cpp server, or `mlx-qwen3-asr serve`. The `parser`
field (§5.1) picks the response parser. Requests send `language`, `prompt`,
`response_format=verbose_json` and, for OpenAI, `timestamp_granularities[]`. Responses' model
headers are recorded (§9.6).
### 9.4 `mlx-qwen3-asr` in-process (Mac)
Qwen3-ASR-1.7B with the Qwen3-ForcedAligner-0.6B on MLX, through `mlx-qwen3-asr` (≥0.4.4, native
MLX aligner, `--context` for vocabulary). `max_chunk_s = 180`. Qwen3-ASR **does** accept a
vocabulary context (council correction, 2026-10-14), so the glossary is passed as `context`.
### 9.5 `faster-whisper` in-process (Windows, Linux)
CTranslate2 conversions of Whisper large-v3 and large-v3-turbo, `word_timestamps=True`,
`vad_filter=False` (vid2sub's own VAD runs first). CUDA on Windows needs the cuBLAS 12 DLLs on
`PATH`; `doctor` checks and says how to fix it. CPU uses int8.
### 9.6 Version stamps
A transcript's `version` is `@<rev12|unknown>+v+p`:
- `rev12`: the Hugging Face snapshot hash for in-process models (`audio-router`'s
`model_revision`). For the Spark it is `unknown` today: llama-swap's `/v1/models` lists names
only and whisper-server reports no model. When local-ai's Phase 3 sends `x-spark-model`, that
header is recorded, and a header naming a different model from the one requested fails the job.
- `prompt_sha8`: `audio-router`'s `prompt_sha` of the glossary-derived prompt.
- `pipeline8`: a hash of the VAD model and settings, chunker version and prompt policy, so a
pipeline change mints a new version.
## 10. Detect, suggest, confirm
Dan, 2026-10-14: the model picker comes after the audio is sampled and the language detected;
vid2sub suggests and the user overrides.
1. **Add.** Files or a folder (a season, sorted by `SxxEyy` when present) are probed in seconds.
2. **Sample.** For the first two files of each folder, in the background: decode, run VAD, take
three ~20 s windows of speech at about 10%, 50% and 80% of the speech found (skipping theme
songs and intros).
3. **Detect.** Each window is language-identified by the best available detector: Qwen3-ASR when
it's available (97.9% average accuracy in Qwen's benchmark, against Whisper large-v3's 94.1%),
otherwise Whisper through the endpoint (`language_probabilities`). The votes give a confidence
and flag mixed-language content. **Whisper often labels Cantonese as `zh`,** so when Whisper
says `zh` the row asks "Mandarin or Cantonese?".
4. **Suggest.** The catalog's routing (§11.2) picks a transcription model and a translation
model. Models unavailable now are shown greyed with the reason ("Spark not reachable",
"install `vid2sub[mac]`").
5. **Review.** One row per file; the rest of a folder inherits the sampled files' choices; the
user overrides anything, with "apply to all".
6. **Start.** The queue runs unattended. Each job re-checks its first chunk's language and flags a
disagreement in the editor rather than stopping.
## 11. Model catalog
### 11.1 Entries
`vid2sub.catalog` is data: one entry per model family with its languages, strengths, weak spots,
timestamp method, where it runs and how to install it. The UI's model pickers and Models page show
it; the README's model table is generated from it. `vid2sub.toml`'s `[models.*]` entries point at
catalog ids; catalog models with no configured entry are shown greyed with "add it to
vid2sub.toml".
| Catalog id | Good for | Weak spots | Runs on |
|---|---|---|---|
| `qwen3-asr-1.7b` (+ ForcedAligner-0.6B) | Mandarin, Cantonese (Qwen's numbers: WenetSpeech-Yue long 8.85 vs Whisper large-v3's 46.64; Cantonese dialogue 4.12 vs 31.04); strong English; Hong Kong and Guangdong accents; vocabulary context | Japanese supported, no published benchmark | Mac (`mlx-qwen3-asr`); the Spark once local-ai serves it (§26.1) |
| `qwen3-asr-0.6b` | the same, lighter | less accurate | Mac |
| `whisper-large-v3` | Japanese (until compared with Qwen3-ASR), 99 languages, vocabulary prompt | slower than turbo; poor on Cantonese | Windows/Linux (`faster-whisper`); the Spark if local-ai adds it |
| `whisper-large-v3-turbo` | English → English captions, quick drafts | weaker on Cantonese and tonal languages; no translate task | the Spark (resident); Windows/Linux |
| `openai-whisper-1` | cloud fallback, any language | 25 MB per request (chunks fit); cloud opt-in | OpenAI |
| `gemma-4-26b-a4b` (translation) | Japanese → English, general; always loaded on the Spark, two slots | | the Spark |
| `qwen3.8-27b` (translation) | Mandarin/Cantonese → English | Dan's coder: on demand, one slot, 41 GiB; not a default | the Spark |
| `openai-gpt` (translation) | cloud fallback | cloud opt-in | OpenAI |
| *later:* `parakeet-tdt-0.6b-v3`, `kotoba-whisper-v2`, `translategemma`, `hy-mt-1.5` | | | |
Excluded with reasons recorded: Ollama for transcription (no speech-to-text), SenseVoice (no
native timestamps).
### 11.2 Routing
An ordered preference per source language; the first model available now wins.
| Source | Transcription | Translation to English |
|---|---|---|
| Cantonese | `qwen3-asr-1.7b` (Spark, then Mac) → `whisper-large-v3` (warns: weak) | `gemma-4-26b-a4b` → configured local endpoint → OpenAI (opt-in) |
| Mandarin | `qwen3-asr-1.7b` → `whisper-large-v3` → turbo | the same |
| Japanese | `whisper-large-v3` → `qwen3-asr-1.7b` → turbo (order revisited after a comparison) | the same |
| English | `whisper-large-v3-turbo` (Spark) → `qwen3-asr-1.7b` (Mac) → `faster-whisper` turbo | none (English captions) |
The Spark default for translation is Gemma 4 for every language in v1 (council, 2026-10-14):
it's resident with two slots, while the only Qwen chat model on the Spark is the coder.
### 11.3 Chinese script
A per-track script setting, Traditional or Simplified, converted with OpenCC (`opencc-purepy`)
on cue text, never on words (conversion changes text length). The conversion is recorded as a
derived `cues` rendition with engine `opencc:`. Defaults: Cantonese → Traditional (HK);
Mandarin → Simplified unless the user chooses Traditional (TW).
## 12. Building cues
Pure code in `vid2sub.subs.build`, from normalised words (§9.2), segments, VAD regions, the
language and the rules.
### 12.1 Invariants
Times are integer milliseconds; `0 ≤ start < end`; cues sorted; no overlaps; text uses `\n` for
line breaks and never contains a blank line.
### 12.2 Rules (defaults from Netflix's timed-text style guides, user-adjustable)
| | English | Japanese | Chinese (both) | Cantonese |
|---|---|---|---|---|
| Line width (EAW units: wide/fullwidth = 2, others = 1) | 42 | 26 (13 full-width) | 32 (16) | 32 (16) |
| Lines per cue | 2 | 2 | 2 | 2 |
| Reading speed for lint (chars/s) | 20 | 4 | 9 | 9 |
- Duration 833 ms to 7 s; minimum gap 2 frames (84 ms); a gap under 500 ms is closed by
extending the earlier cue to the later start minus the minimum gap.
- **Reading speed never splits or merges cues.** It only extends a cue into a following gap and
produces a lint warning. Verbatim Japanese can't meet 4 cps, so its warning is informational.
### 12.3 Breaking text
- Cues break at sentence punctuation and pauses, then at the width limit.
- Two lines are balanced. Space-delimited languages break at spaces; CJK breaks between characters
following kinsoku rules (no line starts with closing punctuation such as `、。,.!?)」』…ー`;
no line ends with opening brackets).
- Chinese lines don't end with `,` or `。`.
### 12.4 Unaligned and suspect text
- An unaligned segment's time is split across its cues in proportion to characters; the cues are
flagged `est`.
- **Hallucinations are flagged, never deleted:** known phrases per language
(ご視聴ありがとうございました, 字幕由Amara.org社区提供, "Thank you for watching", …), words
inside VAD non-speech, and segments with high compression ratio or no-speech probability where
the engine reports them. Flagged cues show in the editor (§20).
### 12.5 Flags
A cue's `flags` may hold `est`, `halluc`, `cps`, `width`, `duration`, `stale`, `untranslated`,
`edited`. Lint flags are recomputed, never stored as truth.
## 13. Translation
### 13.1 Providers (chatlas, Dan, 2026-10-14)
- `ChatOpenAI` for OpenAI itself (Responses API).
- `ChatOpenAICompletions` for every other OpenAI-compatible server: the Spark's llama-swap, LM
Studio, vLLM and Ollama. (`ChatOllama` calls `/api/tags` when constructed and refuses anything
that isn't Ollama.)
- **A fresh Chat per batch** (`chat_structured` adds turns to the Chat it's called on). At most
`concurrency` batches in flight per model (default 1; 1 for the Spark).
- Thinking is switched off per request through the model entry's `request_extra`
(`extra_body={"chat_template_kwargs": {"enable_thinking": false}}`), which llama.cpp honours.
- No tools are registered. Each item's length is capped. Output is treated as untrusted text
(§6.3).
### 13.2 Batches
- About 40 source cues per batch, with up to 5 cues of context before and after, marked
read-only.
- **A translation item covers one or more contiguous source cues:** `{"src": [ids…], "text": …}`.
Japanese → English often reorders across cues, and strict one-to-one forces distortion.
- **The JSON schema is built per batch:** `src` items are `Literal[<this batch's ids>]`, so
llama.cpp's grammar (which converts `response_format.json_schema.schema`) admits only real ids.
A pure validator then checks the items partition the batch's ids in order.
- Each item gets `max_chars = min(2 × line width, reading speed × duration of its src cues)`,
stated in the prompt so the model condenses.
- On a validation failure: retry once at half the batch size, then one cue at a time with
context.
### 13.3 Prompt
A system prompt naming the source and target languages, the subtitle style (concise, natural,
no notes or explanations), the show's description and glossary entries with their preferred
translations (§16). No file names or titles.
### 13.4 Timing and pairing
- A translation cue's time is the span of its `src` cues, unless the user detaches it.
- Translation cues have their own ids. Each records `src` and `src_sha`, a hash of its source cues'
text when translated.
- **Stale:** any `src` id is gone from the source head, or `src_sha` no longer matches, or the
glossary hash or translation model changed. Stale cues are flagged; **Retranslate stale**
re-runs them.
- **A translation the user edited is never replaced automatically** (`edited` flag, derived from
the rendition lineage).
### 13.5 Edit effects on pairing
- **Split** a source cue: both halves get new ids with `from: [old]`; translations pointing at the
old id re-point to both halves and are flagged.
- **Merge:** one new id; the translations of either part are concatenated and flagged.
- **Delete:** the id leaves every `src`; a translation left with an empty `src` is deleted.
- **Insert:** the new cue is `untranslated`.
- **Nudge:** translation times follow the source unless detached.
## 14. The sidecar and data model
### 14.1 File
`