Recently Written · git

subplz-web

git clone https://github.com/equwal/subplz-web

Log | Files | Refs


commit b96bf0f91682cf2c75efb7e01c5898902722b471
equwal <truex@equwal.com>
2026-09-20 17:29:04 -0700

The site converts in the browser; the server keeps the books

Dropping the two files no longer uploads anything. The book is read and its
language detected in the tab, the job runs there (engine/), and only the
finished .srt and its metadata are sent back, so history still follows the
account to another device.

The server's part is now entitlement and record-keeping:
  POST /api/local/jobs            may this start? (free window / credit)
  POST /api/local/jobs/{id}/finish  keep the subtitles
  POST /api/local/jobs/{id}/fail    hand back what the job held
  POST /api/convert               mobi/azw3 -> epub, the one format family
                                  that needs a real parser
Reopening the same book resumes under the job already paid for; a tab closed
for good releases its slot or credit after 48 hours.

Videos are made on request in the tab, in seconds. Paying to unlock one opens
checkout in a new tab, since the finished job only exists in this one.

 backend/api.py               | 175 +++++++++++++++++++
 backend/db.py                |   3 +
 backend/main.py              |   5 +-
 frontend/app.js              | 390 +++++++++++++++++++++++++++----------------
 frontend/engine/languages.js |  20 +++
 frontend/index.html          |  30 +++-
 frontend/style.css           |   9 +
 tests/test_local_jobs.py     | 122 ++++++++++++++
 8 files changed, 607 insertions(+), 147 deletions(-)
diff --git a/backend/api.py b/backend/api.py
index 7220f1e..437e55e 100644
--- a/backend/api.py
+++ b/backend/api.py
@@ -145,6 +145,7 @@ class JobOut(BaseModel):
     audio_duration_seconds: float | None
     error: str | None
     created_at: str
+    local: bool = False
     tier: str = billing.FREE
     artifacts: list[ArtifactOut] = Field(default_factory=list)
 
@@ -189,6 +190,28 @@ class AccountOut(BaseModel):
     queue_depth: int
 
 
+class LocalJobIn(BaseModel):
+    """A conversion about to run in the visitor's browser."""
+    audio_filename: str = Field(max_length=512)
+    audio_parts: int = Field(default=1, ge=1, le=2000)
+    audio_bytes: int = Field(default=0, ge=0)
+    audio_duration_seconds: float | None = None
+    text_filename: str = Field(max_length=512)
+    language: str = Field(max_length=16)
+    tier: Literal["free", "youtube"] = "free"
+
+
+class LocalFinishIn(BaseModel):
+    # A twenty-hour book is a few megabytes of subtitles.
+    srt: str = Field(max_length=8_000_000)
+    filename: str = Field(max_length=512)
+    metadata: dict = Field(default_factory=dict)
+
+
+class LocalFailIn(BaseModel):
+    error: str = Field(default="", max_length=4000)
+
+
 class EmailIn(BaseModel):
     email: str
 
@@ -222,6 +245,7 @@ def _job_out(job: Job, arts: list[Artifact]) -> JobOut:
         audio_duration_seconds=job.audio_duration_seconds,
         error=job.error,
         created_at=job.created_at.isoformat(),
+        local=bool(job.local),
         tier=job.tier or billing.FREE,
         artifacts=[
             ArtifactOut(
@@ -646,6 +670,157 @@ def start_job(
     return _job_out(job, [])
 
 
+# --------------------------------------------------------------------------
+# jobs that run in the browser
+# --------------------------------------------------------------------------
+#
+# The audio never reaches us. The server's part is to say whether the job may
+# start (the free window, or a credit), and to keep the finished subtitles so
+# they are still there on another device. Nothing here can be enforced against
+# someone who edits the page's JavaScript, and that is accepted: see billing.py.
+
+_LOCAL_STALE_HOURS = 48
+
+
+@router.post("/local/jobs", response_model=JobOut)
+def start_local_job(
+    body: LocalJobIn,
+    account: Annotated[Account, Depends(get_account)],
+    session: Annotated[Session, Depends(get_session)],
+):
+    # The same book started again - a closed tab, a reload - carries on under
+    # the job it already paid for rather than being charged a second time.
+    running = (
+        session.query(Job)
+        .filter(
+            Job.account_id == account.id, Job.local == 1,
+            Job.status == JobStatus.running,
+            Job.audio_filename == body.audio_filename,
+            Job.audio_bytes == body.audio_bytes,
+        )
+        .order_by(Job.created_at.desc())
+        .first()
+    )
+    if running is not None:
+        if body.tier == billing.YOUTUBE and running.tier != billing.YOUTUBE:
+            try:
+                billing.unlock(session, account, running)
+            except billing.PaymentRequired as exc:
+                session.rollback()
+                raise HTTPException(402, str(exc)) from exc
+        running.language = body.language
+        session.commit()
+        return _job_out(running, [])
+
+    job = Job(
+        account_id=account.id, status=JobStatus.running, local=1,
+        language=body.language, splitter="browser", model="whisper-tiny",
+        audio_filename=body.audio_filename, text_filename=body.text_filename,
+        audio_parts=body.audio_parts, audio_bytes=body.audio_bytes,
+        audio_duration_seconds=body.audio_duration_seconds,
+        stage="Running in your browser", started_at=utcnow(),
+    )
+    session.add(job)
+    try:
+        billing.authorize_start(session, account, job, body.tier)
+    except billing.PaymentRequired as exc:
+        session.rollback()
+        raise HTTPException(402, str(exc)) from exc
+    session.commit()
+    return _job_out(job, [])
+
+
+@router.post("/local/jobs/{job_id}/finish", response_model=JobOut)
+def finish_local_job(
+    job_id: str,
+    body: LocalFinishIn,
+    account: Annotated[Account, Depends(get_account)],
+    session: Annotated[Session, Depends(get_session)],
+):
+    job = _load(session, account, job_id)
+    if not job.local or job.status != JobStatus.running:
+        raise HTTPException(409, f"Job is already {job.status.value}.")
+
+    import json
+    import tempfile
+
+    name = Path(body.filename).name or "subtitles.srt"
+    with tempfile.TemporaryDirectory() as tmp:
+        for kind, filename, content in (
+            ("srt", name, body.srt),
+            ("metadata", "metadata.json",
+             json.dumps({"job_id": job.id, **body.metadata}, ensure_ascii=False, indent=2)),
+        ):
+            src = Path(tmp) / filename
+            # Bytes, not text: on Windows write_text would turn every line ending into CRLF.
+            src.write_bytes(content.encode("utf-8"))
+            key = f"{job.id}/{filename}"
+            size = storage.put_file(key, src)
+            session.add(Artifact(job_id=job.id, kind=kind, filename=filename,
+                                 storage_key=key, size_bytes=size))
+
+    job.status = JobStatus.succeeded
+    job.stage, job.progress, job.finished_at = "Done", 1.0, utcnow()
+    session.commit()
+    return _job_out(job, _artifacts(session, job.id))
+
+
+@router.post("/local/jobs/{job_id}/fail", response_model=JobOut)
+def fail_local_job(
+    job_id: str,
+    body: LocalFailIn,
+    account: Annotated[Account, Depends(get_account)],
+    session: Annotated[Session, Depends(get_session)],
+):
+    job = _load(session, account, job_id)
+    if not job.local or job.status != JobStatus.running:
+        raise HTTPException(409, f"Job is already {job.status.value}.")
+    job.status = JobStatus.failed
+    job.stage, job.error, job.finished_at = "Failed", body.error or "Failed in the browser.", utcnow()
+    # Whatever went wrong, they got nothing: the free slot or the credit goes back.
+    billing.refund(session, job)
+    session.commit()
+    return _job_out(job, [])
+
+
+def expire_stale_local_jobs(session: Session) -> int:
+    """A tab that was closed for good never reports back. Release what it held."""
+    from datetime import timedelta
+
+    stale = (
+        session.query(Job)
+        .filter(Job.local == 1, Job.status == JobStatus.running,
+                Job.created_at < utcnow() - timedelta(hours=_LOCAL_STALE_HOURS))
+        .all()
+    )
+    for job in stale:
+        job.status, job.stage, job.finished_at = JobStatus.canceled, "Abandoned", utcnow()
+        billing.refund(session, job)
+    session.commit()
+    return len(stale)
+
+
+@router.post("/convert")
+async def convert_book(file: Annotated[UploadFile, File()]):
+    """mobi / azw3 to epub. The one thing the browser cannot do for itself:
+    those formats need a real parser, and a book is small enough to send."""
+    name = Path(file.filename or "book").name
+    if not convert.needs_conversion(name):
+        raise HTTPException(400, "That format does not need converting.")
+    import tempfile
+
+    with tempfile.TemporaryDirectory() as tmp:
+        src = Path(tmp) / name
+        await _save(file, src)
+        try:
+            out = convert.to_readable(src, Path(tmp) / "converted")
+        except convert.ConversionError as exc:
+            raise HTTPException(400, str(exc)) from exc
+        data = out.read_bytes()
+    return Response(data, media_type="application/epub+zip",
+                    headers={"X-Filename": Path(name).stem + ".epub"})
+
+
 @router.get("/jobs", response_model=list[JobOut])
 def list_jobs(
     account: Annotated[Account, Depends(get_account)],
diff --git a/backend/db.py b/backend/db.py
index 864bde4..245db7b 100644
--- a/backend/db.py
+++ b/backend/db.py
@@ -143,6 +143,9 @@ class Job(Base):
     tier: Mapped[str] = mapped_column(
         String(16), default="free", server_default=text("'free'")
     )
+    # 1 when the work happens in the visitor's browser and the server only
+    # keeps the books: nothing to queue, nothing to resume after a restart.
+    local: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0"))
     # 1 if a credit was spent on this job, so a failed run can hand it back.
     credit_spent: Mapped[int] = mapped_column(
         Integer, default=0, server_default=text("0")
diff --git a/backend/main.py b/backend/main.py
index 3fd7bc7..484ace4 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -46,7 +46,7 @@ def _requeue_interrupted() -> None:
     with SessionLocal() as s:
         stale = (
             s.query(Job)
-            .filter(Job.status.in_([JobStatus.running, JobStatus.queued]))
+            .filter(Job.status.in_([JobStatus.running, JobStatus.queued]), Job.local == 0)
             .all()
         )
         for job in stale:
@@ -73,6 +73,9 @@ async def lifespan(_: FastAPI):
         "on" if settings.billing_enabled else "off",
     )
     _requeue_interrupted()
+    with SessionLocal() as s:
+        from .api import expire_stale_local_jobs
+        expire_stale_local_jobs(s)
     yield
     queue.shutdown()
 
diff --git a/frontend/app.js b/frontend/app.js
index 3905319..a8b4f96 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -22,6 +22,9 @@ const el = {
   signinNote: $('signin-note'),
   pricingDialog: $('pricing-dialog'), pricingWhy: $('pricing-why'),
   plans: $('plans'), toast: $('toast'),
+  working: $('working'), workTitle: $('work-title'), workBar: $('work-bar'),
+  workMeta: $('work-meta'), stop: $('stop'), results: $('results'),
+  resultFiles: $('result-files'), another: $('another'),
 };
 
 let languages = [];
@@ -73,7 +76,8 @@ async function api(path, opts = {}) {
 /* ---------------- setup ---------------- */
 
 async function loadLanguages() {
-  languages = await api('/api/languages');
+  // Whisper's own list: the speech model is the only thing that cares.
+  ({ LANGUAGES: languages } = await import('/engine/languages.js'));
   el.language.innerHTML = '';
   for (const l of languages) {
     const o = document.createElement('option');
@@ -95,7 +99,6 @@ function setLanguage(code) {
     el.language.appendChild(o);
     el.language.value = code;
   }
-  updateSplitNote();
 }
 
 async function refreshAccount() {
@@ -227,7 +230,7 @@ function addFiles(files) {
   renderStaged();
   if (!audio.length && !text.length) return;  // an image alone is not a job
   if (staged.audio.length && staged.text) {
-    uploadFiles([...staged.audio, staged.text]);
+    prepare();
   }
 }
 
@@ -281,65 +284,47 @@ document.querySelectorAll('.slot-x').forEach((btn) => {
   });
 });
 
-function uploadFiles(files) {
-  clearError();
-  const list = [...files];
+/* Nothing is uploaded. Once both halves are here the book is read in this tab,
+   its language guessed, and the visitor asked to confirm before hours of work. */
+const NEEDS_CONVERTING = /\.(mobi|azw|azw3|prc)$/i;
+const natural = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
 
+async function prepare() {
+  clearError();
   el.dropzone.hidden = true;
   el.staged.hidden = true;
   el.confirm.hidden = true;
   el.uploading.hidden = false;
-  el.upbar.style.width = '0%';
-  el.uptext.textContent = 'Starting…';
-
-  const form = new FormData();
-  for (const f of list) form.append('files', f, f.name);
-  if (staged.cover && signedIn) form.append('files', staged.cover, staged.cover.name);
-
-  // XHR rather than fetch: it reports upload progress, and these files are big.
-  const xhr = new XMLHttpRequest();
-  xhr.open('POST', '/api/uploads');
-  xhr.withCredentials = true;
-
-  xhr.upload.onprogress = (e) => {
-    if (!e.lengthComputable) return;
-    const pct = Math.round((e.loaded / e.total) * 100);
-    el.upbar.style.width = `${pct}%`;
-    el.uptext.textContent = pct < 100
-      ? `${pct}% — ${fmtBytes(e.loaded)} of ${fmtBytes(e.total)}`
-      : 'Analysing the book…';
-  };
+  el.upbar.style.width = '100%';
+  el.uptext.textContent = 'Reading the book…';
 
-  xhr.onload = () => {
-    el.uploading.hidden = true;
-    if (xhr.status >= 200 && xhr.status < 300) {
-      try {
-        const body = JSON.parse(xhr.responseText);
-        if (body.cover_requires_sign_in) {
-          showError('Your cover image was not used — that needs an account. ' +
-                    'Everything else ran normally.');
-        }
-        showConfirm(body);
-      }
-      catch { showError('Server sent an unreadable response.'); resetToDrop(); }
-    } else {
-      let msg = `Upload failed (${xhr.status}).`;
-      try {
-        const b = JSON.parse(xhr.responseText);
-        if (b.detail) msg = typeof b.detail === 'string' ? b.detail : msg;
-      } catch { /* keep the generic message */ }
-      showError(msg);
-      resetToDrop();
+  try {
+    const { readBook, detectLanguage } = await import('/engine/book.js');
+    let book = staged.text;
+    if (NEEDS_CONVERTING.test(book.name)) {
+      // The one thing the browser cannot do itself: Kindle formats need a real
+      // parser. A book is small; the audio still never leaves this machine.
+      el.uptext.textContent = 'Converting the book to epub…';
+      const form = new FormData();
+      form.append('file', book, book.name);
+      const res = await fetch('/api/convert', { method: 'POST', body: form, credentials: 'same-origin' });
+      if (!res.ok) throw new Error((await res.json().catch(() => ({}))).detail || 'Could not convert that book.');
+      book = new File([await res.blob()], res.headers.get('X-Filename') || 'book.epub');
     }
-  };
-
-  xhr.onerror = () => {
+    const parsed = await readBook(book);
+    if (!parsed.paragraphs.length) {
+      throw new Error(`No text could be read from ${book.name}. A scanned, image-only book cannot be aligned.`);
+    }
+    // 9.mp3 before 10.mp3: playback order is what the timeline is built from.
+    const audio = staged.audio.slice().sort((a, b) => natural.compare(a.name, b.name));
+    draft = { audio, book, cover: staged.cover, detected: detectLanguage(parsed.paragraphs) };
+    el.uploading.hidden = true;
+    showConfirm();
+  } catch (e) {
     el.uploading.hidden = true;
-    showError('Upload failed: lost connection to the server.');
+    showError(e.message);
     resetToDrop();
-  };
-
-  xhr.send(form);
+  }
 }
 
 function resetToDrop() {
@@ -348,117 +333,219 @@ function resetToDrop() {
   el.dropzone.hidden = false;
   el.confirm.hidden = true;
   el.uploading.hidden = true;
+  el.working.hidden = true;
   el.picker.value = '';
 }
 
 /* ---------------- confirm ---------------- */
 
-async function showConfirm(payload) {
-  // The options must exist before we can select the detected language.
-  try { await languagesReady; } catch { /* handled at boot */ }
-
-  draft = payload.job;
-  const d = payload.detected;
-
-  const dur = fmtDuration(draft.audio_duration_seconds);
-  const bits = [
-    draft.audio_parts > 1 ? `${draft.audio_parts} parts` : null,
-    fmtBytes(draft.audio_bytes),
-    dur,
-  ].filter(Boolean);
-  el.cAudio.innerHTML =
-    `${escapeHtml(draft.audio_filename)} <span class="meta">${bits.join(' · ')}</span>`;
-  el.cText.textContent = draft.text_filename;
-
-  setLanguage(draft.language);
-
-  if (d.code && d.supported) {
-    const pct = Math.round(d.confidence * 100);
-    el.detected.textContent = `detected ${d.name} · ${pct}%`;
-    el.detected.classList.toggle('low', d.confidence < 0.7);
-    el.detected.hidden = false;
-  } else if (d.code) {
-    el.detected.textContent = `detected ${d.name} — not supported, pick one`;
-    el.detected.classList.add('low');
-    el.detected.hidden = false;
+async function showConfirm() {
+  const bytes = draft.audio.reduce((n, f) => n + f.size, 0);
+  const name = draft.audio.length > 1
+    ? `${draft.audio[0].name} + ${draft.audio.length - 1} more` : draft.audio[0].name;
+  el.cAudio.innerHTML = `${escapeHtml(name)} <span class="meta">${fmtBytes(bytes)}</span>`;
+  el.cText.textContent = draft.book.name;
+
+  if (draft.detected) {
+    setLanguage(draft.detected);
+    el.detected.textContent = 'detected from the book';
+    el.detected.classList.remove('low');
   } else {
-    el.detected.hidden = true;
+    el.detected.textContent = 'could not tell — please choose';
+    el.detected.classList.add('low');
   }
-
-  renderMatch(payload.match);
-
-  el.eta.textContent = draft.audio_duration_seconds
-    ? `Alignment usually takes a fraction of the book's length, but on CPU it can approach it. ${dur} of audio — expect a long run.`
-    : '';
-
+  el.detected.hidden = false;
+  el.match.hidden = true;
+
+  // Only the speech model decides how long this takes, and only the GPU
+  // decides how fast the speech model is. Say which it will be.
+  const { hasWebGpu } = await import('/engine/asr.js');
+  const gpu = await hasWebGpu();
+  const { savedProgress } = await import('/engine/job.js');
+  const saved = await savedProgress(draft.audio, el.language.value);
+  el.eta.textContent =
+    (saved?.complete ? 'This audio was transcribed here before, so this will take seconds. '
+      : saved?.doneUntil ? `Picks up where it stopped, ${fmtDuration(saved.doneUntil)} in. ` : '') +
+    'Everything runs in this tab: nothing is uploaded, and it has to stay open. ' +
+    (saved?.complete ? '' : gpu ? 'Expect roughly a quarter of the book\'s length.'
+      : 'This browser has no WebGPU, so expect about the book\'s own length — Chrome or Edge on a computer with a graphics card is several times faster.');
+
+  el.start.textContent = saved?.doneUntil && !saved.complete ? 'Continue' : 'Start';
   el.confirm.hidden = false;
   el.start.disabled = false;
   el.confirm.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
 }
 
-/* The preflight score, using the backend's own matching rule. A poor score is
-   shown but never blocks: the check samples the audio, so it can be wrong, and
-   it is the user's book. */
-function renderMatch(m) {
-  if (!m || m.skipped || m.verdict === 'unknown') {
-    el.match.hidden = true;
-    el.start.textContent = 'Start alignment';
+el.discard.addEventListener('click', () => { clearError(); resetToDrop(); });
+
+/* ---------------- the job, in this tab ---------------- */
+
+let running = null;    // { job, serverId }
+let wakeLock = null;
+
+el.start.addEventListener('click', async () => {
+  if (!draft || running) return;
+  el.start.disabled = true;
+  clearError();
+  const language = el.language.value;
+  const tier = chosenTier();
+
+  // Ask first: the free window and credits are the server's to say.
+  let registered;
+  try {
+    registered = await api('/api/local/jobs', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        audio_filename: draft.audio.length > 1 ? `${draft.audio[0].name} + ${draft.audio.length - 1} more` : draft.audio[0].name,
+        audio_parts: draft.audio.length,
+        audio_bytes: draft.audio.reduce((n, f) => n + f.size, 0),
+        text_filename: draft.book.name, language, tier,
+      }),
+    });
+  } catch (e) {
+    // 402 is not an error to apologise for; it is the price list's cue.
+    if (e.status === 402) openPricing(e.message); else showError(e.message);
+    el.start.disabled = false;
     return;
   }
 
-  el.match.hidden = false;
-  el.match.className = `match ${m.verdict}`;
-  el.matchBadge.textContent =
-    { good: 'match', marginal: 'weak match', poor: 'no match' }[m.verdict] || m.verdict;
-  el.matchSummary.textContent = m.summary;
-
-  el.matchWarnings.innerHTML = '';
-  for (const w of m.warnings || []) {
-    const li = document.createElement('li');
-    li.textContent = w;
-    el.matchWarnings.appendChild(li);
+  const { Job, Cancelled } = await import('/engine/job.js');
+  const job = new Job({ audio: draft.audio, book: draft.book, language, onStatus: renderWorking });
+  running = { job, serverId: registered.id, tier: registered.tier, cover: draft.cover };
+  el.confirm.hidden = true;
+  el.working.hidden = false;
+  el.results.hidden = true;
+  el.stop.hidden = false;
+  renderWorking({ phase: 'preparing', detail: 'Starting', fraction: 0 });
+  refreshAccount();
+  try { wakeLock = await navigator.wakeLock?.request('screen'); } catch { /* not granted: fine */ }
+
+  try {
+    const result = await job.run();
+    await api(`/api/local/jobs/${registered.id}/finish`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        srt: result.srt, filename: result.srtName,
+        metadata: {
+          source: { audio_parts: draft.audio.length, text_filename: draft.book.name,
+                    audio_duration_seconds: result.duration },
+          alignment: { where: 'in the browser', device: job.device ?? 'cached transcript', model: 'whisper-tiny',
+                       language, mode: 'forced alignment against supplied text' },
+          output: { filename: result.srtName, cue_count: result.cues.length,
+                    match_rate: result.matchRate, paragraphs_dropped: result.paragraphsDropped },
+        },
+      }),
+    }).catch(() => { /* the subtitles are still here to download; only the history entry is missing */ });
+    showResults(result);
+  } catch (e) {
+    const stopped = e instanceof Cancelled;
+    await api(`/api/local/jobs/${registered.id}/fail`, {
+      method: 'POST', headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ error: stopped ? 'Stopped.' : String(e.message || e) }),
+    }).catch(() => {});
+    job.close();
+    running = null;
+    el.working.hidden = true;
+    if (stopped) { toast('Stopped. What was transcribed is kept — the same files will carry on from there.'); el.confirm.hidden = false; el.start.disabled = false; }
+    else { showError(e.message || String(e)); resetToDrop(); }
+  } finally {
+    wakeLock?.release?.().catch(() => {});
+    refreshAccount();
+    refreshJobs();
   }
+});
 
-  // Make the user's choice explicit when we expect this to fail.
-  el.start.textContent =
-    m.verdict === 'poor' ? 'Start anyway' : 'Start alignment';
+el.stop.addEventListener('click', () => { running?.job.cancel(); el.stop.disabled = true; });
+
+function renderWorking(st) {
+  el.workTitle.textContent = st.detail;
+  el.workBar.style.width = `${Math.round((st.fraction || 0) * 100)}%`;
+  el.workBar.parentElement.classList.toggle('indeterminate', !!st.indeterminate);
+  el.workMeta.textContent = [
+    `${Math.round((st.fraction || 0) * 100)}%`,
+    st.eta != null ? `about ${fmtDuration(st.eta)} left` : null,
+    st.speed ? `${st.speed.toFixed(1)}× real time` : null,
+  ].filter(Boolean).join('  ·  ');
 }
 
-function updateSplitNote() {
-  // The note comes from the server, so the UI carries no knowledge of which
-  // alignment backend is running or how it splits sentences.
-  const lang = languages.find((l) => l.code === el.language.value);
-  el.splitnote.textContent = (lang && lang.note) || '';
+function showResults(r) {
+  el.stop.hidden = true;
+  el.stop.disabled = false;
+  el.results.hidden = false;
+  const pct = Math.round(r.matchRate * 100);
+  el.workTitle.textContent = `Done — ${r.cues.length} lines, ${pct}% found in the book`;
+  el.workBar.style.width = '100%';
+  el.workMeta.textContent =
+    (pct < 80 ? 'That is low: usually a different edition or translation, or the wrong language. ' : '') +
+    (r.paragraphsDropped ? `${r.paragraphsDropped} paragraphs of the book were never narrated (front matter, notes) and were left out.` : '');
+  renderResultButtons();
 }
 
-el.language.addEventListener('change', updateSplitNote);
-
-el.discard.addEventListener('click', async () => {
-  if (draft) { try { await api(`/api/jobs/${draft.id}`, { method: 'DELETE' }); } catch {} }
-  clearError();
-  resetToDrop();
-  refreshJobs();
-});
+function renderResultButtons() {
+  const r = running.job.result;
+  const paid = !account?.billing_enabled || running.tier === 'youtube';
+  el.resultFiles.innerHTML = '';
+  const button = (label, hint, cls, onClick) => {
+    const b = document.createElement('button');
+    b.type = 'button'; b.className = `dl ${cls}`; b.title = hint; b.textContent = label;
+    b.addEventListener('click', () => onClick(b));
+    el.resultFiles.appendChild(b);
+  };
+  button('⬇ Subtitles (.srt)', 'For Hoshi Reader, or to upload alongside the YouTube video', '',
+    () => save(new File([r.srt], r.srtName, { type: 'application/x-subrip' })));
+  button('⬇ Video with subs built in (.mkv)', 'Subtitles inside the file, for MPV or VLC', '',
+    (b) => makeVideo('mkv', b));
+  button(paid ? '⬇ Video for YouTube (.mp4)' : '🔒 Unlock the YouTube video (.mp4)',
+    paid ? 'No subtitles baked in — add the .srt in YouTube Studio'
+      : 'Part of the YouTube tier — one credit, or the unlimited plan',
+    paid ? '' : 'locked', (b) => (paid ? makeVideo('mp4', b) : unlockRunning(b)));
+}
 
-el.start.addEventListener('click', async () => {
-  if (!draft) return;
-  el.start.disabled = true;
-  clearError();
+async function makeVideo(kind, btn) {
+  const label = btn.textContent;
+  btn.disabled = true;
   try {
-    await api(`/api/jobs/${draft.id}/start`, {
-      method: 'POST',
-      headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({ language: el.language.value, tier: chosenTier() }),
+    const file = await running.job.video(kind, {
+      coverFile: signedIn ? running.cover : null,
+      onProgress: (f) => { btn.textContent = `Making the ${kind}… ${Math.round(f * 100)}%`; },
     });
-    resetToDrop();
-    refreshJobs();
+    save(file);
+  } catch (e) { showError(`Could not make the ${kind}: ${e.message}`); }
+  btn.textContent = label;
+  btn.disabled = false;
+}
+
+async function unlockRunning(btn) {
+  btn.disabled = true;
+  try {
+    const j = await api(`/api/jobs/${running.serverId}/unlock`, { method: 'POST' });
+    running.tier = j.tier;
+    toast('Unlocked.');
+    renderResultButtons();
     refreshAccount();
   } catch (e) {
-    // 402 is not an error to apologise for; it is the price list's cue.
-    if (e.status === 402) openPricing(e.message);
-    else showError(e.message);
-    el.start.disabled = false;
+    if (e.status === 402) { unlockJobId = running.serverId; openPricing(e.message); } else showError(e.message);
+    btn.disabled = false;
   }
+}
+
+function save(file) {
+  const a = document.createElement('a');
+  a.href = URL.createObjectURL(file);
+  a.download = file.name;
+  document.body.appendChild(a);
+  a.click();
+  a.remove();
+  setTimeout(() => URL.revokeObjectURL(a.href), 60_000);
+}
+
+el.another.addEventListener('click', () => { running?.job.close(); running = null; resetToDrop(); });
+
+// Hours of work live in this tab. Make closing it a decision, not an accident.
+window.addEventListener('beforeunload', (e) => {
+  if (running && !running.job.result) { e.preventDefault(); e.returnValue = ''; }
 });
 
 /* ---------------- jobs ---------------- */
@@ -473,7 +560,9 @@ function jobCard(j) {
   const li = document.createElement('li');
   li.className = 'job';
 
-  const active = j.status === 'running' || j.status === 'queued';
+  // A browser-run job reports no progress to the server; it is either ours
+  // (shown above, in the working panel) or running in some other tab.
+  const active = !j.local && (j.status === 'running' || j.status === 'queued');
   const pct = Math.round(j.progress * 100);
 
   const meta = [
@@ -498,6 +587,9 @@ function jobCard(j) {
       <div class="job-stage"><span>${escapeHtml(j.stage)}</span><span>${pct}%</span></div>`;
   }
 
+  if (j.local && j.status === 'running') {
+    html += '<div class="job-stage"><span>Running in a browser tab</span></div>';
+  }
   if (j.error) html += `<div class="job-err">${escapeHtml(j.error)}</div>`;
 
   const files = j.artifacts || [];
@@ -564,12 +656,12 @@ async function refreshJobs() {
   catch { return; }
 
   // Drafts live in the confirm panel, not the list.
-  const visible = jobs.filter((j) => j.status !== 'draft');
+  const visible = jobs.filter((j) => j.status !== 'draft' && j.id !== running?.serverId);
   el.jobsSection.hidden = visible.length === 0;
   el.jobs.innerHTML = '';
   for (const j of visible) el.jobs.appendChild(jobCard(j));
 
-  const anyActive = visible.some((j) => j.status === 'running' || j.status === 'queued');
+  const anyActive = visible.some((j) => !j.local && (j.status === 'running' || j.status === 'queued'));
   clearTimeout(pollTimer);
   if (anyActive) pollTimer = setTimeout(refreshJobs, 1500);
 }
@@ -723,6 +815,14 @@ async function checkout(planId, btn) {
       headers: { 'Content-Type': 'application/json' },
       body: JSON.stringify({ plan_id: planId, job_id: unlockJobId }),
     });
+    if (running) {
+      // The finished job only exists in this tab. Pay in another one.
+      window.open(url, '_blank', 'noopener');
+      el.pricingDialog.close();
+      toast('Checkout opened in a new tab. Come back here when you have paid.', 12000);
+      btn.disabled = false;
+      return;
+    }
     window.location.href = url;  // Stripe's page; we come back via /api/billing/return
   } catch (e) {
     el.pricingDialog.close();
@@ -805,6 +905,16 @@ async function handleArrival() {
   }
 }
 
+// Back from paying in the other tab: the purchase unlocked this job server-side.
+window.addEventListener('focus', async () => {
+  if (!running?.job.result || running.tier === 'youtube') return;
+  try {
+    const j = await api(`/api/jobs/${running.serverId}`);
+    if (j.tier === 'youtube') { running.tier = j.tier; renderResultButtons(); toast('Unlocked.'); }
+    refreshAccount();
+  } catch { /* try again on the next focus */ }
+});
+
 /* ---------------- boot ---------------- */
 
 (async function init() {
diff --git a/frontend/engine/languages.js b/frontend/engine/languages.js
new file mode 100644
index 0000000..69ad7fb
--- /dev/null
+++ b/frontend/engine/languages.js
@@ -0,0 +1,20 @@
+/* The languages Whisper transcribes, by its own codes. */
+export const LANGUAGES = Object.entries({
+  af: 'Afrikaans', sq: 'Albanian', am: 'Amharic', ar: 'Arabic', hy: 'Armenian', as: 'Assamese',
+  az: 'Azerbaijani', ba: 'Bashkir', eu: 'Basque', be: 'Belarusian', bn: 'Bengali', bs: 'Bosnian',
+  br: 'Breton', bg: 'Bulgarian', my: 'Burmese', yue: 'Cantonese', ca: 'Catalan', zh: 'Chinese',
+  hr: 'Croatian', cs: 'Czech', da: 'Danish', nl: 'Dutch', en: 'English', et: 'Estonian',
+  fo: 'Faroese', fi: 'Finnish', fr: 'French', gl: 'Galician', ka: 'Georgian', de: 'German',
+  el: 'Greek', gu: 'Gujarati', ht: 'Haitian Creole', ha: 'Hausa', haw: 'Hawaiian', he: 'Hebrew',
+  hi: 'Hindi', hu: 'Hungarian', is: 'Icelandic', id: 'Indonesian', it: 'Italian', ja: 'Japanese',
+  jw: 'Javanese', kn: 'Kannada', kk: 'Kazakh', km: 'Khmer', ko: 'Korean', lo: 'Lao', la: 'Latin',
+  lv: 'Latvian', ln: 'Lingala', lt: 'Lithuanian', lb: 'Luxembourgish', mk: 'Macedonian',
+  mg: 'Malagasy', ms: 'Malay', ml: 'Malayalam', mt: 'Maltese', mi: 'Maori', mr: 'Marathi',
+  mn: 'Mongolian', ne: 'Nepali', no: 'Norwegian', nn: 'Nynorsk', oc: 'Occitan', ps: 'Pashto',
+  fa: 'Persian', pl: 'Polish', pt: 'Portuguese', pa: 'Punjabi', ro: 'Romanian', ru: 'Russian',
+  sa: 'Sanskrit', sr: 'Serbian', sn: 'Shona', sd: 'Sindhi', si: 'Sinhala', sk: 'Slovak',
+  sl: 'Slovenian', so: 'Somali', es: 'Spanish', su: 'Sundanese', sw: 'Swahili', sv: 'Swedish',
+  tl: 'Tagalog', tg: 'Tajik', ta: 'Tamil', tt: 'Tatar', te: 'Telugu', th: 'Thai', bo: 'Tibetan',
+  tr: 'Turkish', tk: 'Turkmen', uk: 'Ukrainian', ur: 'Urdu', uz: 'Uzbek', vi: 'Vietnamese',
+  cy: 'Welsh', yi: 'Yiddish', yo: 'Yoruba',
+}).map(([code, name]) => ({ code, name })).sort((a, b) => a.name.localeCompare(b.name));
diff --git a/frontend/index.html b/frontend/index.html
index e39d819..9fe5dfe 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -4,7 +4,7 @@
 <meta charset="utf-8">
 <meta name="viewport" content="width=device-width, initial-scale=1">
 <title>SubRead — read along with your audiobook</title>
-<link rel="stylesheet" href="/style.css?v=9">
+<link rel="stylesheet" href="/style.css?v=10">
 <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎧</text></svg>">
 </head>
 <body>
@@ -31,7 +31,8 @@
     <p>
       Drop in an audiobook and the ebook it was read from. You get back subtitles
       timed to the narration, with the wording taken from your own book — not
-      from a machine's guess at what it heard.
+      from a machine's guess at what it heard. It all happens in this tab:
+      <strong>your files are never uploaded</strong>.
     </p>
     <ul class="uses">
       <li><strong>HoshiReader read-along</strong> — the .srt timing file, to match
@@ -99,7 +100,7 @@
   <div id="uploading" class="panel uploading" hidden>
     <div class="spinner" aria-hidden="true"></div>
     <div class="up-body">
-      <p class="up-title">Uploading &amp; analysing…</p>
+      <p class="up-title">Getting ready…</p>
       <div class="bar"><div id="upbar" class="bar-fill"></div></div>
       <p id="uptext" class="muted small">0%</p>
     </div>
@@ -159,7 +160,23 @@
     <p class="muted small" id="eta"></p>
   </section>
 
-  <!-- step 3: jobs -->
+  <!-- step 3: the job, running in this tab -->
+  <section id="working" class="panel working" hidden>
+    <p class="up-title" id="work-title">Starting</p>
+    <div class="bar"><div id="work-bar" class="bar-fill"></div></div>
+    <p id="work-meta" class="muted small"></p>
+    <div class="actions">
+      <button id="stop" class="ghost">Stop (progress is kept)</button>
+    </div>
+    <div id="results" hidden>
+      <div id="result-files" class="job-files"></div>
+      <p class="muted small">Videos are made here when you ask for them, in seconds.
+        They are not kept anywhere: make them before closing this tab.</p>
+      <div class="actions"><button id="another" class="ghost">Convert another book</button></div>
+    </div>
+  </section>
+
+  <!-- step 4: history -->
   <section id="jobs-section" hidden>
     <h2 class="section-title">Your conversions</h2>
     <ul id="jobs" class="jobs"></ul>
@@ -168,7 +185,8 @@
 
 <footer>
   <p class="muted small">
-    Alignment by <a href="https://github.com/kanjieater/SubPlz" target="_blank" rel="noopener">SubPlz</a>.
+    Alignment method from <a href="https://github.com/kanjieater/SubPlz" target="_blank" rel="noopener">SubPlz</a>;
+    speech model <a href="https://github.com/openai/whisper" target="_blank" rel="noopener">Whisper</a>.
     Every line of text comes from the book you upload, so names and spelling stay right.
   </p>
 </footer>
@@ -199,6 +217,6 @@
 
 <div id="toast" class="toast" hidden role="status"></div>
 
-<script src="/app.js?v=9"></script>
+<script type="module" src="/app.js?v=10"></script>
 </body>
 </html>
diff --git a/frontend/style.css b/frontend/style.css
index 831fe42..7f362c2 100644
--- a/frontend/style.css
+++ b/frontend/style.css
@@ -277,6 +277,15 @@ button.ghost:hover { color: var(--ink); border-color: var(--muted); }
 .dl.secondary { background: transparent; color: var(--muted); border-color: var(--line); }
 .dl.secondary:hover { color: var(--ink); border-color: var(--muted); }
 
+/* ---------- the running job ---------- */
+.working .up-title { margin: 0 0 10px; font-weight: 600; }
+.working .job-files { margin-top: 16px; }
+button.dl { font: inherit; font-weight: 500; cursor: pointer; border: 1px solid transparent; border-radius: 9px; }
+button.dl:disabled { opacity: .6; cursor: progress; }
+.bar.indeterminate .bar-fill { width: 35% !important; animation: slide 1.4s ease-in-out infinite; }
+@keyframes slide { 0% { margin-left: -35%; } 100% { margin-left: 100%; } }
+@media (prefers-reduced-motion: reduce) { .bar.indeterminate .bar-fill { animation: none; margin-left: 0; } }
+
 /* ---------- account bar ---------- */
 .account { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
 .who { color: var(--muted); font-size: 13px; max-width: 220px;
diff --git a/tests/test_local_jobs.py b/tests/test_local_jobs.py
new file mode 100644
index 0000000..b5b3dae
--- /dev/null
+++ b/tests/test_local_jobs.py
@@ -0,0 +1,122 @@
+"""Jobs that run in the visitor's browser: the server only keeps the books."""
+
+from __future__ import annotations
+
+import time
+from datetime import timedelta
+
+from backend import api
+from backend.db import Job, JobStatus, SessionLocal, utcnow
+
+from .conftest import account_id, checkout_event, get_job_row, post_webhook
+
+BOOK = {
+    "audio_filename": "book.m4b", "audio_bytes": 123456, "audio_duration_seconds": 3600.0,
+    "text_filename": "book.epub", "language": "ja",
+}
+SRT = "1\n00:00:00,000 --> 00:00:01,000\nこんにちは\n\n"
+
+
+def begin(client, **over):
+    return client.post("/api/local/jobs", json={**BOOK, **over})
+
+
+def buy(client, plan="single", email="local@example.com"):
+    sid = f"cs_{time.time_ns()}"
+    assert post_webhook(client, checkout_event(sid, account_id(client), plan, email=email)).status_code == 200
+
+
+def test_free_job_runs_finishes_and_keeps_its_subtitles(client):
+    r = begin(client)
+    assert r.status_code == 200
+    job = r.json()
+    assert job["local"] is True and job["status"] == "running" and job["tier"] == "free"
+
+    done = client.post(f"/api/local/jobs/{job['id']}/finish",
+                       json={"srt": SRT, "filename": "book.ja.srt", "metadata": {"cues": 1}})
+    assert done.status_code == 200 and done.json()["status"] == "succeeded"
+    kinds = {a["kind"] for a in done.json()["artifacts"]}
+    assert kinds == {"srt", "metadata"}
+
+    got = client.get(f"/api/jobs/{job['id']}/files/srt")
+    assert got.status_code == 200 and got.content.decode("utf-8") == SRT
+    assert job["id"] in {j["id"] for j in client.get("/api/jobs").json()}
+
+
+def test_free_window_applies_to_browser_jobs_too(client):
+    assert begin(client).status_code == 200
+    second = begin(client, audio_filename="another.m4b", audio_bytes=999)
+    assert second.status_code == 402
+
+
+def test_reopening_the_same_book_does_not_charge_twice(client):
+    first = begin(client).json()
+    again = begin(client)          # closed tab, same files
+    assert again.status_code == 200 and again.json()["id"] == first["id"]
+    with SessionLocal() as s:
+        assert s.query(Job).filter(Job.account_id == account_id(client)).count() == 1
+
+
+def test_youtube_tier_costs_a_credit_and_a_failure_returns_it(client):
+    assert begin(client, tier="youtube").status_code == 402
+
+    buy(client)
+    r = begin(client, tier="youtube")
+    assert r.status_code == 200 and r.json()["tier"] == "youtube"
+    assert client.get("/api/account").json()["credits"] == 0
+
+    failed = client.post(f"/api/local/jobs/{r.json()['id']}/fail", json={"error": "GPU lost"})
+    assert failed.status_code == 200 and failed.json()["status"] == "failed"
+    assert client.get("/api/account").json()["credits"] == 1
+    # And the free book was never touched.
+    assert client.get("/api/account").json()["free_remaining"] == 1
+
+
+def test_upgrading_a_running_free_job_to_youtube(client):
+    job = begin(client).json()
+    assert begin(client, tier="youtube").status_code == 402     # no credit yet
+    buy(client, email="upgrade@example.com")
+    up = begin(client, tier="youtube")
+    assert up.status_code == 200 and up.json()["id"] == job["id"] and up.json()["tier"] == "youtube"
+    assert client.get("/api/account").json()["credits"] == 0
+
+
+def test_finish_is_once_only_and_owner_only(client, second_client):
+    job = begin(client).json()
+    body = {"srt": SRT, "filename": "x.srt"}
+    assert second_client.post(f"/api/local/jobs/{job['id']}/finish", json=body).status_code == 404
+    assert client.post(f"/api/local/jobs/{job['id']}/finish", json=body).status_code == 200
+    assert client.post(f"/api/local/jobs/{job['id']}/finish", json=body).status_code == 409
+    assert client.post(f"/api/local/jobs/{job['id']}/fail", json={}).status_code == 409
+
+
+def test_filename_cannot_escape_the_job_directory(client):
+    job = begin(client).json()
+    r = client.post(f"/api/local/jobs/{job['id']}/finish",
+                    json={"srt": SRT, "filename": "../../../etc/passwd"})
+    assert r.status_code == 200
+    srt = next(a for a in r.json()["artifacts"] if a["kind"] == "srt")
+    assert srt["filename"] == "passwd"
+
+
+def test_abandoned_jobs_release_what_they_held(client):
+    job = begin(client).json()
+    assert begin(client, audio_filename="b.m4b", audio_bytes=2).status_code == 402
+    with SessionLocal() as s:
+        row = s.get(Job, job["id"])
+        row.created_at = utcnow() - timedelta(hours=72)
+        s.commit()
+        assert api.expire_stale_local_jobs(s) >= 1
+    assert get_job_row(job["id"]).status == JobStatus.canceled
+    assert begin(client, audio_filename="b.m4b", audio_bytes=2).status_code == 200
+
+
+def test_restart_does_not_queue_browser_jobs(client, monkeypatch):
+    from backend import main
+
+    job = begin(client).json()
+    queued = []
+    monkeypatch.setattr(main.queue, "enqueue", queued.append)
+    main._requeue_interrupted()
+    assert job["id"] not in queued
+    assert get_job_row(job["id"]).status == JobStatus.running