Skip to content

wiedymi/subframe

Repository files navigation

Subframe

GitHub Twitter Email Discord Support me

Subframe is a realtime ASS/SSA subtitle renderer for web and Bun. It renders Subforge SubtitleDocument directly and targets libass visual parity. The runtime is TypeScript plus WGSL and small self-verified WebAssembly raster kernels; it does not ship a native addon or a full libass build.

  • Measured on hostile content. The repository includes Bun and real-browser harnesses for the JASSUB suite, including the Beastars frame-by-frame typesetting storm. Current measured tails are recorded in docs/GOALS.md; 60 fps is a target, not a blanket guarantee.
  • Byte-exact GPU filters. Blur and subpixel shift run as batched WGSL compute fused into the composite submit. A hardware gate proves GPU output identical (maxDiff 0) to the CPU reference path.
  • Deterministic CPU core. Fixed-point (1/64 px) layout and raster; same inputs, same pixels. The CPU path is the source of truth and the fallback when no GPU is available.
  • Bounded memory. Byte-bounded caches and buffer recycling keep the heaviest benchmark fixture around ~106 MB steady JS heap; typical content runs in tens of MB.

Install

bun add subframe subforge
# or: npm install subframe subforge

Fonts are your responsibility: register the font files your subtitles use (see below). Subframe ships no fonts.

Quick start (browser)

import { parseASS } from "subforge/ass";
import { Subframe } from "subframe";

const canvas = document.querySelector("canvas")!;

const doc = parseASS(await (await fetch("/subs.ass")).text(), {
  onError: "collect",
}).document;

const sf = new Subframe({
  canvas,
  backend: "auto", // WebGPU -> WebGL -> Canvas2D CPU fallback
  fonts: [
    await (await fetch("/fonts/Lato-Regular.ttf")).arrayBuffer(),
  ],
});

await sf.ready;
sf.resize(canvas.width, canvas.height);
await sf.setDocument(doc, { timeMs: video.currentTime * 1000 });

// Manual clock: render and composite one subtitle frame.
await sf.render(video.currentTime * 1000);

// Or let Subframe drive requestVideoFrameCallback-synced playback.
sf.attachVideo(video);

Notes:

  • Subframe starts async initialization in the constructor; ready, render(), and frame() queue behind it.
  • Workers default on. Package builds embed an inline module worker; strict CSP pages can serve the exported subframe/worker-entry.js asset from their own origin and pass that URL through workerUrl, or use workers: false to stay single-threaded.
  • Each Subframe owns its font registry, scheduler, lookahead state, workers, and returned-frame leases. Multiple live facades can use different documents and fonts without sharing those lifecycle resources.
  • Pass the expected initial media time to setDocument() when starting deep in a script. This warms the actual playback window instead of the first subtitle event. If a video is already attached, its current time is used automatically.
  • Seeking needs no special handling — jump the time you pass to render() or the attached video; the pipeline re-primes itself.

Quick start (Bun / offline)

import { parseASS } from "subforge/ass";
import { Subframe } from "subframe";

const doc = parseASS(await Bun.file("episode.ass").text(), { onError: "collect" }).document;

const sf = new Subframe({
  fonts: [await Bun.file("fonts/Lato-Regular.ttf").arrayBuffer()],
});
await sf.ready;
sf.resize(1920, 1080);
await sf.setDocument(doc, { timeMs: 90_000 });

const { layers, release } = await sf.frame(90_000);
// Each layer is a grayscale bitmap + premultiply color + placement — composite
// however you like (Canvas2D, PNG encode, an encoder pipeline, ...):
for (const layer of layers) {
  // layer.bitmap (Uint8Array), layer.width / height / stride,
  // layer.originX / originY (pixel coordinates), layer.color, layer.z
}
release();
sf.dispose();

The same instance-owned worker runtime runs under Bun for realtime server-side rendering. Use workers: false for deterministic single-threaded scripts, or workerCount to cap a facade's pool.

Fonts

new Subframe({ fonts }) accepts an array of font bytes, Blob/File objects, or URLs. Each file is parsed once and registered under its own names from the font name table: family, typographic family, full name, and PostScript name. Name matching remains case-insensitive.

const sf = new Subframe({
  canvas,
  fonts: [
    await (await fetch("/fonts/Lato-Regular.ttf")).arrayBuffer(),
    "/fonts/Allison-Regular.otf",
  ],
  fontResolver: async (name) => {
    const res = await fetch(`/fallback-fonts/${encodeURIComponent(name)}.ttf`);
    return res.ok ? await res.arrayBuffer() : null;
  },
});

Font resolution priority is:

  1. Embedded ASS [Fonts] data on the active document.
  2. The fonts input passed to Subframe.
  3. fontResolver.
  4. Chromium Local Font Access API, when available and permitted.

Local Font Access requires Chromium support and usually a user gesture/permission grant; denied or unavailable access silently falls through to the next source. Embedded ASS fonts are decoded from the script's UUEncoded [Fonts] section and registered by the decoded font's own names, not by the embedded filename.

The old name-keyed object form is accepted as deprecated compatibility before publish, but new integrations should use the array form.

Backends

Backend Entry Notes
WebGPU createWebGPUBackend({ canvas, enableGpuFilters? }) Default choice. Batched WGSL filter chain fused into the composite; enableGpuFilters defaults on.
WebGL createWebGLBackend({ canvas }) Compositing only; filters run on CPU.
none renderFrame returns plain bitmap layers; composite anywhere.

Playground backend selection order: WebGPU → WebGL → CPU.

Performance knobs

Everything defaults to the fast path; these exist for A/B tests and constrained hosts.

Control Default Purpose
new Subframe({ workerUrl }) / setWorkerSource(url) inline worker / unset Worker bootstrap. workerUrl is the CSP escape hatch; low-level callers can still use setWorkerSource.
new Subframe({ workerCount }) hardware concurrency minus one, capped at 8 Per-facade worker cap. Each live facade owns its workers.
setWorkerPool(false) / setWorkerCount(n) on / auto Disable or size the pool.
await sf.setDocument(doc, { timeMs, playbackFps }) / attachDocument(doc, w, h, options) first event / 60 fps Public facade attach/warmup, or explicit low-level warmup. Set timeMs when playback starts mid-script.
setMemoryBudget(bytes) ~120 MB ceilings Scales all byte-bounded caches proportionally.
setFrameHybrid(false) / setFrameScatter(false) on A/B switches for the frame pipeline engines.
releaseRenderResult(result) Returns a consumed frame's buffers to the pool (buffering players).

Playground

bun run playground

Local demo UI with a live perf panel (display cadence, pipeline stats, memory, GPU counters), backend switcher, and GPU self-tests. Workers and GPU filters are on by default (?workers=0 opts out).

For static hosting (including Cloudflare Pages), build the complete playground surface rather than bundling playground/index.html directly:

bun run build:playground

Publish dist-pages. The build injects Subframe's default inline worker and also emits worker-entry.js for the low-level functional API. If the standalone asset is missing or served with the wrong MIME type, the low-level pool falls back to the main thread while the Subframe facade keeps using inline workers.

Build

bun run build

Builds the worker in memory, injects it through a build plugin without modifying source files, emits dist/index.js, dist/worker-entry.js, and generated declarations, then type-checks the public package. The inline worker increases the main bundle size; pass workerUrl if your deployment prefers the exported standalone worker or blocks Blob workers with CSP.

Advanced / core API

The facade owns backend selection, font registration, worker lifecycle, document warmup, playback scheduling, and result lifetime. Advanced integrations can compose the intentionally exported low-level pieces directly:

import {
  createWebGPUBackend,
  renderFrame,
  releaseRenderResult,
  registerFontSource,
  setWorkerSource,
} from "subframe";

Use the core API when you need custom scheduling, tracing, or backend ownership. If you buffer returned RenderResults, call releaseRenderResult(result) only after the last presentation of that frame.

Verification tooling

The repo carries its own proof harnesses; all run headless:

bun run test                                  # unit + render tests
bun run test:golden                            # golden-image parity fixtures
bun run tools/parity/sweep.ts                  # frame sweep vs native libass (pixel diff)
bun run tools/gpu-headless/run-headless.ts     # GPU==CPU byte-exactness gate (real hardware)
bun run tools/gpu-headless/run-bench.ts        # headless-Chrome realtime benchmark
bun run tools/gpu-headless/run-versus.ts       # side-by-side vs JASSUB on its own fixtures

Parity is enforced, not claimed: the golden suite and the parity sweep compare against a native libass build (tools/ref/), and the hardware gate rejects any GPU change that is not byte-identical to the CPU path. Current parity scope and known gaps live in docs/GOALS.md.

Repository layout

src/            # Renderer core + backends
playground/     # Local demo UI
docs/           # Goals, architecture, perf notes
tools/          # Parity, diff, bench, and GPU verification tooling
test/           # Fixtures and golden data
refs/           # Reference submodules (libass, subforge, text-shaper)

Submodules

Reference submodules are used for parity research only (not needed to use the library):

git submodule update --init --recursive

See refs/README.md for details.

License

MIT. See LICENSE.

About

Deterministic subtitle renderer for Subforge documents with libass-style ASS/SSA parity goals on web and Bun

Topics

Resources

License

Stars

2 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages