Telecine

2026  On air

Broadcast protocol · Data pipelines with a legal gate · Two runtimes held to parity

A public broadcast network for old films. Four channels run around the clock with no streaming server and no account: every receiver reads the same schedule file and the same clock, and lands on the same frame. You don’t press play; you tune in.

Role
Designer & engineer
Stack
Astro · vanilla JS · Swift & SwiftUI (iPhone, iPad, Apple TV) · Vercel · Internet Archive · ffmpeg
Methods
Protocol design, build-time verification, a rights ledger as a gate, cross-runtime parity fixtures, clock-synchronised playback
Scope
4 channels · 85 features, 126.3 hours · 118 ledger entries · 581 people pages · 344 tests
The Telecine set — a walnut television cabinet tuned to Noir After Dark, with the channel dial beside it
The format

A schedule is a file, and the file is the broadcast

Each channel is a Transmission: a fixed epoch and an ordered list of blocks — features, shorts and station breaks — that loop forever. Every client reads the same static file and the same wall clock, does the same arithmetic, and lands on the same frame:

// what is on, and how far into it are we
loop = Σ durationSec
into = ((t − epoch) mod loop + loop) mod loop
// walk the blocks; the one containing `into` is on air;
// the remainder is the seek offset
the resolution arithmetic · SPEC.md, Transmission format v0.1

That is the decision worth defending: a format, not a service. A schedule in a JSON file is diffable, versionable and forkable. There is no origin to fall over when everyone tunes in at programme start, because the documents are static and the video comes from the Internet Archive. And there is no operator who can move a programme for one audience and not another — the shared present tense is enforced by arithmetic, not policy.

Two runtimes

One answer, proved across languages

The web set and the build resolve the schedule with one pure, dependency-free file, broadcast.js. The Apple receivers — iPhone, iPad and Apple TV — resolve it again in Swift. Two implementations of one protocol drift unless something holds them together, so a parity fixture does: the JavaScript generates 7,751 resolve cases, 7,592 programme beginnings and 19,726 standby cases, and the Swift test suite must reproduce every one.

/**
 * Resolve a channel against a moment in time.
 */
export function resolve(channel, now) {
  const t = typeof now === 'number' ? now : now.getTime();
  const epoch = Date.parse(channel.epoch);
  const loop = loopDuration(channel);
  if (loop <= 0) throw new Error(`channel ${channel.id}: empty schedule`);
  // seconds into the current loop (well-defined for t before the epoch too)
  const into = (((t - epoch) / 1000) % loop + loop) % loop;
  let cum = 0;
  for (let i = 0; i < channel.blocks.length; i++) {
    const b = channel.blocks[i];
    if (into < cum + b.durationSec) {
      const loopStart = t - into * 1000;
      return {
        block: b,
        index: i,
        offsetSec: into - cum,
        blockStart: new Date(loopStart + cum * 1000),
        blockEnd: new Date(loopStart + (cum + b.durationSec) * 1000),
        loopSec: loop,
      };
    }
    cum += b.durationSec;
  }
  /* unreachable: `into` is always < loop */
  throw new Error('resolve: fell off the schedule');
}
broadcast.js · the resolver, unabridged

The widget below imports that exact file and today’s schedule, and resolves all four channels live in this page.

—
ChannelOn airOffset into it
loading the network file…

broadcast.js and network.json are vendored byte-identical from the Telecine source at commit 6bb478b (5 November 2026), the same files the set and the receivers run. Nothing on this page was rewritten to make the demo work.

Unknown is not clear, and a hold is a question for counsel, never a quiet yes.
The ledger

A film airs only where it is cleared

“Public domain” is a claim about one country, and about the film, not the file. Half the silent prints in the first audit were rips of commercial restorations — a modern score, new intertitles, a restorer’s copyright line — and a US film that lapsed can still infringe the novel it was built on. So every film has an entry in a rights ledger, with its basis and sources, before it can be scheduled.

The ledger is a build gate, not a document. The bake fails on a film with no entry, holds back any film not cleared for the network’s zone, and fails if the print on air is not the exact file the ledger inspected. Inspection is measured: soundtrack loudness sampled at eight points to tell silence from a modern score, and contact sheets of the titles and credits. The ledger holds 118 entries today; it clears films for the United States, and it records honestly that outside it almost nothing clears.

candidates.json · films, channels, runtime windows
verify resolves each film to a real Archive item, checks the title matches, the duration falls inside its window and the file answers byte ranges, so a mid-film seek works.
rights ledger no entry fails the bake; not cleared for the zone is held back; a different print from the one inspected fails.
bake lays each channel out as a week and writes the Transmissions; exits non-zero on anything unverified, doubled or dangling.
network.json · shipped to every receiver
preflight probes every print due in the next 24 hours; a print is reported down only after failing twice, thirty seconds apart.
standby.json · the head-end’s second file
The week

A grid, with the wait measured

A channel’s loop is one week, 604,800 seconds. Every feature starts on a half-hour mark, the station break flexes to fill the gap, and the channels are staggered by quarter hours so the dial always has something about to begin. The bake measures the longest anyone can wait for a beginning anywhere on the dial: 75 minutes today, against a goal of 15. Missed, and printed.

The edge cases

Everyone sees the same frame, defended

“Everyone sees the same frame” is arithmetic on paper and a fight on a real device — drift, scrubbing fingers, background tabs, autoplay policy, a flaky connection. When a print stops answering, every receiver stands by on the same reel at the same frame, because the reel’s offset is the wall clock modulo its length; the block’s own countdown runs on, and the schedule resumes by itself.

CaseHandling
Drift between setsRe-resolve on a 500 ms tick; correct when |currentTime − offsetSec| > 2.5 s, at most once every 15 s — tight enough that two sets in one room agree, loose enough never to stutter.
ScrubbingA seeking listener snaps back to LIVE past 4 s. No pause, no scrubbing, on purpose: a broadcast you can rewind is video-on-demand with extra steps.
Joining mid-filmSeek to offsetSec on loadedmetadata — you join what's on, never from the top. Byte-range support makes the mid-film seek possible, which is why the pipeline proves it per film rather than assuming it.
Cold startThe source loads and seeks behind the TUNE IN lens before power-on, so the set switches on into a moving picture.
Station breaksThe next reel pre-buffers behind the test card while the countdown runs.
Safari autoplay policyThe first play() happens inside the click gesture; if sound is refused anyway, the set falls back to muted and says so honestly on screen.
Signal lossSIGNAL LOST · STANDING BY. A print the head-end reports down plays a standby reel for everyone at once; one that fails only on this set retries.
Backgrounded tabvisibilitychange re-syncs on return — the broadcast went on without you, as broadcasts do.
Block boundariesHalf-open intervals: the end instant belongs to the next block. Tested, because two clients straddling a boundary is the exact moment agreement matters most.
Before the epochThe loop is congruent backwards — resolution is well-defined for any instant, so the broadcast has always been on. Tested.
Clock skewBounded by the viewers' system clocks, which NTP keeps within fractions of a second — see the honest edge in 03.
A Telecine film page for Sherlock Jr. (1924) — programme notes, credits where every name is a door, and the next screenings
The editorial layer: hand-written programme notes, credits linked through 581 people pages, and the film's next screenings — the curator's research, handed over.
What it knows

The station counts itself, never its audience

No accounts, no analytics, no location. The schedule is anchored in UTC and identical for the planet; your browser turns it into your local hours. The station’s own numbers — features on air, hours, the longest wait, the ledger’s counts, the preflight’s age — come from one pure function, and a test fails if any sheet carries a visitor, session, device, country or referrer. The headers deny framing, and a content security policy runs in report-only mode while it is tuned.

A mirror is built for the day the Archive slows: an HLS ladder at 720p and 480p, cut to each block’s exact second so a receiver can switch to it and land on the same frame, with the source file’s SHA-256 and every removal written down. It waits on storage before it goes live.

85Features on air, 126.3 hours
118Rights ledger entries
7,751Parity cases, JavaScript to Swift
344Tests, gating every build
Where it stands

On the air; the Apple receivers in development

The web set is live and has been on air since its epochs. The receivers for iPhone, iPad and Apple TV are built and tested but not yet submitted. The source is private while they are.