diff --git a/.dockerignore b/.dockerignore
index 973f830..cabc73c 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,3 +1,5 @@
+# Only what the Dockerfile COPYs matters: package.json/bun.lock/bunfig.toml,
+# src/, scripts/, server-py/. Everything else is noise for the build context.
.git
node_modules
dist
@@ -7,3 +9,21 @@ tests
.gitlab-ci.yml
Dockerfile
.dockerignore
+docker-compose.yml
+
+# Python bytecode caches (server-py/ is COPY'd into the image)
+__pycache__/
+*.pyc
+
+# Editor / agent state
+.idea
+.claude
+
+# Local env files — configuration is runtime `-e` flags (D-015), never baked in
+.env
+.env.*
+
+# Misc local output
+coverage
+logs
+*.log
diff --git a/.gitignore b/.gitignore
index c4420eb..f12457f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,6 +28,7 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
.env.local
# caches
+__pycache__/
.eslintcache
.cache
*.tsbuildinfo
diff --git a/Decisions.md b/Decisions.md
index 18275d7..260d818 100644
--- a/Decisions.md
+++ b/Decisions.md
@@ -239,3 +239,30 @@ the universal fallback). The selected backend stays user-visible: the worker's
`{status:"ready", device}` message drives a "Running on …" badge next to the
Backend select (`ModelPicker`) and the engine line in the footer (D-004's
"surfaced in the UI" requirement).
+
+## D-018 — Cancel a file transcription by terminating the worker
+**Status:** Accepted (2026-07-14)
+Users can cancel an in-flight file run (decode, model download kicked off by
+the drop, or the transcription pass itself) via a Cancel button next to the
+file name. Transformers.js exposes no abort API for a running `asr()` call,
+and on the WASM backend the pass blocks the worker's event loop — a "cancel"
+postMessage would sit unread until the pass finished anyway. So cancellation
+**terminates the inference worker** outright; it is the only reliable
+preemption and also aborts an in-flight weight download. A `fileRun` counter
+in `App.tsx` marks the decode that was running when the user cancelled as
+stale (`decodeAudioData` itself cannot be aborted), so it never posts audio to
+the fresh worker. The next run respawns the worker and reloads the pipeline —
+cheap, because the weights stay in the browser Cache API (D-005); the UI
+resets `loadStarted`/`loadProgress` so the reload starts from a clean slate.
+Live mode is untouched: it has its own stop/drain semantics (D-016), and a
+live session never coexists with a file run (mutually exclusive in the UI).
+**Alternatives rejected:** a cancel message handled between chunks (dead on
+WASM, where the event loop is blocked; and still seconds of latency
+elsewhere); `AbortSignal` plumbed into Transformers.js (no upstream support).
+**Consequence:** cancel throws away the loaded pipeline even when only the
+transcription was unwanted — an accepted cost since reloading from cache takes
+a few seconds.
+**Amendment (2026-07-14, EngineToggle landing):** a server-engine run cancels
+by aborting the `/api/transcribe` fetch instead (`AbortController`; the server
+cancels the sidecar pass between segments, D-011/D-013) — the inference worker
+and its loaded pipeline are left untouched in that path.
diff --git a/Dockerfile b/Dockerfile
index 78da403..ca147be 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,3 +1,16 @@
+# ---------------------------------------------------------------------------
+# Multi-stage image with an OPTIONAL server-engine (faster-whisper) layer.
+#
+# docker build -t whisper-web . # Bun-only (default)
+# docker build --target with-server-engine -t whisper-web:fw . # + Python/faster-whisper
+#
+# The default image stays Bun-only (Decisions.md D-013): no Python inside, and
+# the server engine simply reports "unavailable" while the browser/anonymous
+# path works in full. `--target with-server-engine` adds python3 + the
+# server-py/requirements.txt packages so logged-in users can run inference on
+# the server. See README.md ("Deployment") for run commands and env vars.
+# ---------------------------------------------------------------------------
+
# Build stage: install all deps (build needs onnxruntime-web files from
# node_modules) and emit the self-contained dist/ (scripts/build.ts).
FROM oven/bun:1 AS build
@@ -26,12 +39,54 @@ RUN bun install --frozen-lockfile --production
COPY src ./src
COPY scripts ./scripts
+# The sidecar script ships in every image (it is one small .py file). Without
+# a Python runtime it is never executed: the supervisor in
+# src/server/transcribe.ts records "python3 is not available" once and
+# GET /api/transcribe/status reports the server engine as unavailable (D-013).
+COPY server-py ./server-py
COPY --from=build /app/dist ./dist
-# SQLite lives under /app/data — mount a volume there to persist users/sessions.
+# SQLite lives under /app/data — mount a volume there to persist users/sessions
+# (and, in the with-server-engine flavor, downloaded Whisper weights).
RUN mkdir -p /app/data && chown -R bun:bun /app/data
USER bun
ENV PORT=3000
EXPOSE $PORT
+HEALTHCHECK --interval=30s --timeout=5s --start-period=15s \
+ CMD ["bun", "-e", "const r = await fetch(`http://127.0.0.1:${process.env.PORT || 3000}/`); if (!r.ok) throw new Error(String(r.status));"]
CMD ["bun", "src/index.ts"]
+
+# ---------------------------------------------------------------------------
+# Optional server-engine layer (build with `--target with-server-engine`).
+# Adds Debian's python3 plus server-py/requirements.txt in a virtualenv
+# (Debian pip is PEP 668 "externally managed", and a venv keeps the layer
+# self-contained); SIDECAR_PYTHON points the supervisor at it. CPU-only by
+# default — FASTER_WHISPER_DEVICE=auto resolves to CPU here, int8 compute.
+# GPU (FASTER_WHISPER_DEVICE=cuda) needs a CUDA/cuDNN base image and
+# `docker run --gpus`; that is deliberately not part of this Dockerfile.
+# ---------------------------------------------------------------------------
+FROM runtime AS with-server-engine
+USER root
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends python3 python3-venv \
+ && rm -rf /var/lib/apt/lists/*
+
+RUN python3 -m venv /opt/faster-whisper \
+ && /opt/faster-whisper/bin/pip install --no-cache-dir -r server-py/requirements.txt
+
+ENV SIDECAR_PYTHON=/opt/faster-whisper/bin/python3
+# Cache Whisper weights inside the data volume so a container restart does not
+# re-download them (first request per model downloads from the HF Hub).
+ENV FASTER_WHISPER_DOWNLOAD_ROOT=/app/data/faster-whisper-models
+
+USER bun
+
+# ---------------------------------------------------------------------------
+# Default target: a plain `docker build .` (what .gitlab-ci.yml runs) must
+# produce the Bun-only image, but Docker builds the *last* stage by default
+# and with-server-engine has to come after the stage it extends — so re-select
+# the runtime stage here. Adds no layers.
+# ---------------------------------------------------------------------------
+FROM runtime AS default
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..27f4975
--- /dev/null
+++ b/README.md
@@ -0,0 +1,128 @@
+# AU Whisper Web
+
+Browser-first speech-to-text powered by OpenAI Whisper. Anonymous users run
+Whisper entirely in their browser via
+[Transformers.js](https://github.com/huggingface/transformers) (WebNN → WebGPU
+→ WASM, installable PWA, works offline once a model is cached). Logged-in
+users can optionally run inference on the **server** instead, backed by
+[faster-whisper](https://github.com/SYSTRAN/faster-whisper) (CTranslate2)
+through a small Python sidecar (`server-py/`, Decisions.md D-013).
+
+See `Architecture.md` for the design, `Decisions.md` for the decision log, and
+`CLAUDE.md` for the toolchain rules (Bun everywhere — no npm/node).
+
+## Development
+
+```sh
+bun install
+bun dev # dev server with HMR at http://localhost:3000
+bun test # unit tests
+```
+
+## Production build (bare metal)
+
+```sh
+bun install
+bun run build # emits self-contained dist/ (app shell, worker, sw.js, /ort/*)
+bun start # NODE_ENV=production bun src/index.ts — serves dist/ + /api/*
+```
+
+To enable the server engine on bare metal, additionally install Python ≥ 3.9
+and the sidecar dependencies: `python3 -m pip install -r server-py/requirements.txt`.
+Nothing else changes — the sidecar is spawned lazily on the first server-engine
+request.
+
+## Deployment (Docker)
+
+The `Dockerfile` is multi-stage and produces **two image flavors**:
+
+| Flavor | Build command | Contents |
+| --- | --- | --- |
+| **Bun-only** (default) | `docker build -t whisper-web .` | Bun runtime + built `dist/`. No Python. |
+| **With server engine** | `docker build --target with-server-engine -t whisper-web:server-engine .` | Everything above + Debian `python3` and `server-py/requirements.txt` (faster-whisper, CPU-only) in a virtualenv at `/opt/faster-whisper`. |
+
+`--target` was chosen over a build-arg because the optional layer is a strict
+superset of the runtime stage: `with-server-engine` extends `runtime`, so the
+Bun layers are shared byte-for-byte between both flavors and there is no
+conditional logic inside the Dockerfile. A plain `docker build .` (what
+`.gitlab-ci.yml` runs) intentionally resolves to the Bun-only flavor.
+
+Run either flavor the same way:
+
+```sh
+docker run -d -p 3000:3000 -v whisper-data:/app/data whisper-web
+# or, with the server engine:
+docker run -d -p 3000:3000 -v whisper-data:/app/data whisper-web:server-engine
+```
+
+`docker compose up --build` builds and runs the Bun-only flavor with the
+`whisper-data` volume already wired up (see `docker-compose.yml`).
+
+### Graceful degradation (why the Bun-only image is safe to deploy)
+
+The server engine is optional by design (D-013):
+
+- **Bun-only image:** everything anonymous users see works in full — browser
+ inference, model downloads, PWA/offline, and (if `AUTH_ENABLED` stays on)
+ accounts. The first server-engine probe finds no `python3`, the supervisor
+ records that as a permanent condition (no respawn storms), and
+ `GET /api/transcribe/status` answers `{"available": false, "reason": ...}`;
+ `POST /api/transcribe` answers `503` with the same reason. The UI simply
+ doesn't offer the server engine.
+- **with-server-engine image:** `status` reports `available: true` and
+ logged-in users get the server/device toggle. The first request for a model
+ downloads its CTranslate2 weights from the Hugging Face Hub into
+ `FASTER_WHISPER_DOWNLOAD_ROOT` (inside the data volume, so restarts don't
+ re-download); the last-used model stays loaded between requests.
+
+### Runtime environment variables
+
+All configuration is runtime-only (D-015) — one image serves every
+configuration; flip flags with `docker run -e` / compose `environment:`.
+
+| Variable | Default | Meaning |
+| --- | --- | --- |
+| `PORT` | `3000` | Port the Bun server listens on (also honored by the image's `EXPOSE`/healthcheck). |
+| `NODE_ENV` | `production` (set in the image) | `production` serves the prebuilt `dist/`; anything else runs the dev server. Leave as-is in Docker. |
+| `AUTH_ENABLED` | `true` | D-015 feature flag. `false`/`0`/`no`/`off` disables login/register: `/api/auth/*` **and** `/api/transcribe*` return 404 (the server engine is session-gated, so it disappears with auth) and the client hides the Sign in UI. |
+| `DB_PATH` | `/app/data/app.db` (image) / `./data/app.db` (bare metal) | SQLite database (users + sessions, D-009). Keep it inside the mounted volume. |
+| `TRANSCRIBE_MAX_UPLOAD_BYTES` | `536870912` (512 MiB ≈ 2¼ h of audio) | Upload cap for `POST /api/transcribe` (raw float32 mono 16 kHz PCM), enforced via Content-Length and again while streaming. |
+| `SIDECAR_PYTHON` | `python3` (with-server-engine image: `/opt/faster-whisper/bin/python3`) | Python interpreter used to spawn `server-py/sidecar.py`. Only override for exotic setups. |
+| `FASTER_WHISPER_DEVICE` | `auto` | CTranslate2 device for the sidecar (`auto`, `cpu`, `cuda`). In the stock image `auto` resolves to CPU. |
+| `FASTER_WHISPER_COMPUTE` | `int8` | CTranslate2 compute type (`int8` is the fast, low-memory CPU choice; e.g. `float16` on GPU). |
+| `FASTER_WHISPER_DOWNLOAD_ROOT` | unset (with-server-engine image: `/app/data/faster-whisper-models`) | Where the sidecar caches downloaded model weights. |
+
+There are no session-secret style variables: sessions are random 256-bit ids
+stored server-side in SQLite; the cookie is `HttpOnly`, `SameSite=Lax`, and
+`Secure` whenever `NODE_ENV=production`.
+
+### Persistence
+
+Mount a volume at **`/app/data`**. It holds:
+
+- `app.db` — the SQLite database (users + sessions). Without the volume every
+ redeploy logs everyone out and drops accounts.
+- `faster-whisper-models/` — (server-engine flavor) downloaded Whisper
+ weights, from ~75 MB (`tiny`) to ~1.6 GB (`large-v3-turbo`).
+
+Browser-engine model weights never touch the server — they live in each
+visitor's browser cache.
+
+### HTTPS / reverse proxy
+
+Run the container behind a TLS-terminating reverse proxy (Caddy, nginx,
+Traefik…). HTTPS is **functionally required** in production, not just good
+practice: service workers (PWA/offline), microphone capture
+(`getUserMedia` for live transcription), WebGPU, and the `Secure` session
+cookie all require a secure context. Plain HTTP only works on `localhost`.
+The app uses relative URLs and also tolerates being served under a subpath.
+
+### GPU note
+
+The `with-server-engine` target is CPU-only on purpose (small-ish image, no
+CUDA runtime). For GPU inference you would build your own image on an
+`nvidia/cuda` base with cuDNN 9 + cuBLAS, install Bun and
+`server-py/requirements.txt` the same way, run with `docker run --gpus all`,
+and set `FASTER_WHISPER_DEVICE=cuda` (plus e.g.
+`FASTER_WHISPER_COMPUTE=float16`). Not shipped here — CPU int8 already makes
+the server engine several times faster than real time on modest hardware.
diff --git a/WorkBreakdown.md b/WorkBreakdown.md
index 1ba5d1c..1c517b4 100644
--- a/WorkBreakdown.md
+++ b/WorkBreakdown.md
@@ -9,8 +9,17 @@ Legend: `[ ]` todo · `[x]` done · `[~]` in progress
---
## WIP Tasks before usage limit cutoff:
-- [~] Allow users to cancel current transcription.
-- [~] Better user-feedback while transcription is in progress (better than "Listening for the first few words")
+- [x] Allow users to cancel current transcription. — Cancel button on the
+ file line, wired to worker termination (D-018); covers decode, pending
+ model download, and the pass itself. Server-engine cancel is tracked in
+ M6 (needs the M5 EngineToggle first).
+- [x] Better user-feedback while transcription is in progress (better than
+ "Listening for the first few words") — the worker now posts
+ `transcribe-progress` (seconds processed / total, from the streamer's
+ chunk timestamps via the pure `TranscribeProgressTracker`); the UI shows
+ a percent in the heading plus a progress bar with mm:ss, and the file
+ flow's placeholder reads "Processing audio…" (the live wording stays
+ live-only).
## M0 — Scaffold cleanup & docs (small)
Current state is the raw `bun init` React template.
@@ -143,7 +152,7 @@ Notes (M4):
(availability probe for the toggle)
- [x] `src/lib/engine.ts` — `Engine` interface; `BrowserEngine` (wraps worker) and
`ServerEngine` (wraps fetch stream) (D-010)
-- [ ] `EngineToggle` component — visible only with a session; persists choice
+- [x] `EngineToggle` component — visible only with a session; persists choice
(persistence helpers + status probe already in `lib/engine.ts`; remaining:
the component, lifting `useAuth` out of `AuthForms` into `App` so both see
one session state, and routing the file flow through the selected engine —
@@ -154,7 +163,7 @@ Notes (M4):
`Bun.spawn` against `tests/fixtures/fake_sidecar.py`); real inference is
the opt-in `FW_INTEGRATION=1` test + manual run in `tests/server-engine.md`
(no pip/browser in the dev environment; same caveat as M2/M3)
-- [ ] Deployment: optional Python/faster-whisper layer for the Docker image +
+- [x] Deployment: optional Python/faster-whisper layer for the Docker image +
deployer note (base image stays Bun-only; install
`server-py/requirements.txt` to enable the server engine)
@@ -183,11 +192,37 @@ Notes (M5):
- Smoke-tested live against the dev server: `/` + auth unchanged; status
401 → (register) → 200 `{available:false, reason:"faster-whisper is not
installed…"}`; POST → 503 with the same reason, body untouched.
+- EngineToggle: one `useAuth()` lives in `App` (passed into `AuthForms` and
+ gating the toggle); the status probe runs on sign-in and the server option
+ renders disabled-with-reason when unavailable. The file flow now goes
+ through the `Engine` interface for **both** paths (D-010): `BrowserEngine`
+ shares the worker with the live loop (distinct requestId namespaces) and a
+ new `stream` flag on the transcribe message keeps token partials +
+ transcribe-progress flowing for id-carrying engine passes (live passes stay
+ non-streaming). Server runs skip the browser model load, show segment
+ partials without a progress bar (faster-whisper's stream carries no window
+ boundaries), and cancel by aborting the fetch (D-018 amendment) — browser
+ runs keep worker-termination cancel. The choice persists in localStorage
+ but only takes effect while signed in with the engine available.
+- Deployment: multi-stage `Dockerfile` — `runtime` (Bun-only, 244 MB) is the
+ default target so plain `docker build .` (existing CI) is unchanged;
+ `--target with-server-engine` (405 MB) adds python3 + faster-whisper in a
+ venv at `/opt/faster-whisper` (wired via `SIDECAR_PYTHON`), with
+ `FASTER_WHISPER_DOWNLOAD_ROOT` under the `/app/data` volume so weights
+ persist. README.md documents both flavors, all runtime env vars, the DB
+ volume, and the HTTPS/reverse-proxy requirement. Verified live in Docker:
+ Bun-only image degrades per D-013 (`available:false`, "python3 is not
+ available…"); server-engine image ran a real `POST /api/transcribe` of
+ `en_aiproblem.m4a` end-to-end, streamed segments matching the fixture
+ transcript — the server half of the M5 integration-test item is now
+ exercised for real (browser half still needs the manual run).
## M6 — Hardening & polish
- [ ] Error taxonomy: unsupported file, out-of-memory (suggest smaller model),
WebGPU device-lost, network loss mid-download (resume or clear retry)
-- [ ] Cancel in-flight transcription (both engines)
+- [x] Cancel in-flight transcription (both engines) — browser engine via
+ worker termination (D-018), server engine via aborting the
+ `/api/transcribe` fetch (D-018 amendment; landed with M5's EngineToggle)
- [ ] Export transcript: .txt and .srt (timestamps already available)
- [ ] Storage management UI: list cached models, per-model delete
- [ ] Basic a11y pass (keyboard, focus, aria on progress/live transcript)
diff --git a/src/App.tsx b/src/App.tsx
index 3f50011..24725ce 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
import "./index.css";
import { AuthForms } from "./components/AuthForms";
+import { EngineToggle } from "./components/EngineToggle";
import { FileDropzone } from "./components/FileDropzone";
import { LiveControls, type LiveStatus } from "./components/LiveControls";
import { ModelLoadProgress } from "./components/ModelLoadProgress";
@@ -9,6 +10,17 @@ import { ModelPicker } from "./components/ModelPicker";
import { TranscriptView } from "./components/TranscriptView";
import { TranscriptionOptions } from "./components/TranscriptionOptions";
import { decodeAudioFile } from "./lib/audio";
+import {
+ BrowserEngine,
+ ServerEngine,
+ fetchServerEngineStatus,
+ loadEngineChoice,
+ saveEngineChoice,
+ type Engine,
+ type EngineKind,
+ type ServerEngineStatus,
+} from "./lib/engine";
+import { useAuth } from "./lib/useAuth";
import { LIVE_TUNING, LiveTranscriber, type LiveTranscribeResult } from "./lib/live";
import { startMicCapture, type MicCapture } from "./lib/mic";
import { getDownloadedModels } from "./lib/modelCache";
@@ -64,11 +76,25 @@ export function App() {
// audio to the (fresh) worker. Decoding itself cannot be aborted.
const fileRun = useRef(0);
+ // Engine plumbing (M5, D-010). The BrowserEngine is bound to the worker's
+ // lifetime (recreated together on cancel); the ServerEngine is stateless
+ // and lives for the app. A server run's fetch is cancelled via abortRef.
+ const browserEngineRef = useRef
- {text || (tentativeText ? "" : "Listening for the first words…")}
+ {text || (tentativeText ? "" : placeholder)}
{tentativeText && (
{tentativeText}
)}
diff --git a/src/index.css b/src/index.css
index a77e963..ea09faa 100644
--- a/src/index.css
+++ b/src/index.css
@@ -357,6 +357,38 @@ body {
flex-wrap: wrap;
}
+/* --- EngineToggle (M5, D-010) --- */
+
+.engine-toggle {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ flex-wrap: wrap;
+ font-size: 0.9rem;
+}
+
+.engine-toggle-label {
+ font-weight: 600;
+}
+
+.engine-option {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ cursor: pointer;
+}
+
+.engine-option:has(input:disabled) {
+ color: var(--muted);
+ cursor: default;
+}
+
+.engine-toggle-hint {
+ flex-basis: 100%;
+ color: var(--muted);
+ font-size: 0.85rem;
+}
+
/* --- FileDropzone --- */
.dropzone {
@@ -458,6 +490,25 @@ body {
font-size: 0.95rem;
}
+/* Cancel for the in-flight file run (D-018) — styled like .copy-button, with
+ a warning accent on hover. */
+.cancel-button {
+ margin-left: 0.75rem;
+ border: 1px solid var(--border);
+ background: transparent;
+ color: inherit;
+ border-radius: 8px;
+ padding: 0.3rem 0.9rem;
+ font-size: 0.85rem;
+ cursor: pointer;
+ transition: border-color 0.15s, color 0.15s;
+}
+
+.cancel-button:hover {
+ border-color: #8b3a3a;
+ color: #f2b8b5;
+}
+
.error {
margin: 0;
padding: 0.75rem 1rem;
@@ -561,6 +612,33 @@ body {
cursor: default;
}
+.transcript-percent {
+ color: var(--muted);
+ font-weight: normal;
+ font-variant-numeric: tabular-nums;
+}
+
+/* Audio progress of the in-flight file pass (transcribe-progress messages). */
+.transcript-progress {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ margin-top: 0.6rem;
+}
+
+.transcript-progress progress {
+ flex: 1;
+ height: 6px;
+ accent-color: var(--accent);
+}
+
+.transcript-progress-time {
+ color: var(--muted);
+ font-size: 0.85rem;
+ white-space: nowrap;
+ font-variant-numeric: tabular-nums;
+}
+
.transcript-text {
margin: 0.75rem 0 0;
white-space: pre-wrap;
diff --git a/src/lib/engine.ts b/src/lib/engine.ts
index 5e6228f..5c60e6b 100644
--- a/src/lib/engine.ts
+++ b/src/lib/engine.ts
@@ -42,9 +42,16 @@ export interface EngineTranscribeOptions {
* Browser engine: token fragments; server engine: whole segments.
*/
onPartial?: (fragment: string) => void;
+ /**
+ * How far the pass has progressed through the audio. Browser engine only —
+ * faster-whisper's stream carries no window boundaries, so the server
+ * engine never calls it (the UI degrades to segments-as-feedback).
+ */
+ onProgress?: (progress: { processedSeconds: number; totalSeconds: number }) => void;
/**
* Abort the run. Honored by the server engine (the request is cancelled
- * server-side too); the browser worker cannot abort a pass yet (M6).
+ * server-side too); the browser worker cannot abort a pass — cancel there
+ * is worker termination via `BrowserEngine.failAll` (D-018).
*/
signal?: AbortSignal;
}
@@ -83,6 +90,7 @@ interface PendingRequest {
resolve: (result: EngineTranscribeResult) => void;
reject: (error: Error) => void;
onPartial?: (fragment: string) => void;
+ onProgress?: (progress: { processedSeconds: number; totalSeconds: number }) => void;
}
/**
@@ -116,11 +124,19 @@ export class BrowserEngine implements Engine {
): Promise