Skip to content

Raw 1080p and 4K transport streams play, go live, and reach a browser - #126

Merged
ralyodio merged 1 commit into
mainfrom
feat/raw-mpegts-1080p-4k
Sep 12, 2026
Merged

Raw 1080p and 4K transport streams play, go live, and reach a browser#126
ralyodio merged 1 commit into
mainfrom
feat/raw-mpegts-1080p-4k

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

A .ts file off a capture card, a receiver or an IPTV recorder is how most high-definition television arrives, and it was the one shape of media nixamp could not take. The library walk skipped the extension, the router read it as a song and handed back its soundtrack, and what did reach a browser was re-encoded when it did not need to be.

What was broken

  • findAudio had no transport-stream extension on its list, so a folder of recordings was an empty library.
  • hasPicture did not know them either, so /api/media sent a 4K film through transcode()-vn, MP3, no picture.
  • videoArgs re-encoded every H.265 source at its own size. Measured on this box, 4K through libx264 -preset veryfast runs at 0.52x real time; 1080p at 1.65x. A 4K HEVC film therefore arrived slower than it played.
  • The channel backlog was a fixed 4 MB — six seconds of 720p, under one second of copied 4K. A joiner landed on the live edge with no cushion, which is the play-wait-play loop the backlog exists to prevent (see the live-fmp4-needs-backlog finding).
  • ffprobe's default probe gives up before the picture on a padded 4K recording, and a recording cut mid-stream has no timestamps on its first frames.

What changed

  • Discovery. .m2ts, .mts, .m2t, .trp, .tp are taken on their names. A .ts is opened and asked — three sync bytes at one packet's spacing, at 188, 192 or 204 bytes, from any offset — because it is as often a TypeScript file as it is 4K television, and a checkout is not a playlist. Memoized, since it is asked once per track per listing.
  • Probing and decoding. transportProbeArgs / transportInputArgs: 20 MB probesize, 10 s analyzeduration, -fflags +genpts+discardcorrupt. codecsOf now also reports the picture's width and height, and those survive a restart in channels.json.
  • Copy, don't encode. H.264 is copied at whatever size it is: 1080p and 4K cost the same to copy as 720p. Only the transport stream's ADTS audio is redone, as before.
  • H.265. Copied with the hvc1 tag (ffmpeg writes hev1, which Safari plays as a black panel) when the client says it can decode one — the browser answers for itself via ?hevc=1, never a user-agent guess. Where it cannot, the re-encode comes down to 1080p. A channel has one encode and a whole audience, so it re-encodes H.265 by default; NIXAMP_HEVC_CHANNELS=1 copies it through.
  • Backlog by seconds, not bytes. Six seconds of the rate the channel is measured running at, between the old 4 MB floor and a 48 MB ceiling. The rate is only believed after a window has closed, because the burst at the start of a pull is not a bitrate.
  • HLS. An H.265 channel is packaged as fMP4 (#EXT-X-MAP, .m4s) rather than transport segments, keyed off what the channel emits rather than what its source holds. HLS in TS is defined for H.264 only.
  • Web. A picked .ts is media rather than something filtered out of the list, and goes to the mpegts.js engine instead of a <video> that cannot open it.

Testing

test/transport.test.ts builds its own fixtures with ffmpeg — 1080p and 4K H.264, 4K HEVC where libx265 is present — and every test that needs one skips cleanly without it. Nothing large is committed.

  • bun test test web/test530 pass, 1 skip (pre-existing), 0 fail
  • bun run typecheck (root + web + backtoschool) — clean
  • bun run web:build — clean
  • There is no lint script in this repo.

Verified against a running server on a library of raw .ts files:

asked for came back
1080p H.264 .ts over /api/media fMP4, h264/avc1, 1920x1080
4K H.264 .ts over /api/media fMP4, h264/avc1, 3840x2160 (was MP3)
4K HEVC .ts, plain h264, 1920x1080
4K HEVC .ts, ?hevc=1 hevc/hvc1, 3840x2160
4K H.264 gone live, watched on its channel h264, 3840x2160
HEVC channel with NIXAMP_HEVC_CHANNELS=1 hvc1 out, playlist with #EXT-X-MAP and .m4s

Still open

  • The channel HEVC choice is one switch for the whole server; per-viewer would mean a second encode or a ladder, which is a bigger thing than this.
  • No 10-bit or HDR handling: a 10-bit source that has to be re-encoded still goes to yuv420p.
  • Multi-programme transport streams still take whichever programme ffmpeg picks.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QuUkTrofSjQ15j79mRuy4f

A `.ts` file off a capture card, a receiver or an IPTV recorder is how most
high-definition television arrives, and it was the one shape of media nixamp
could not take. The library walk skipped it, the router read it as a song and
sent back its soundtrack, and what did reach a browser was re-encoded when it
did not need to be.

- A library lists transport streams. `.m2ts`, `.mts`, `.m2t`, `.trp` and `.tp`
  are taken on their names; a `.ts` is opened and asked, because it is as
  often a TypeScript file as it is 4K television and a checkout is not a
  playlist. Three sync bytes at one packet's spacing, at 188, 192 or 204, and
  from any offset, since a recording cut mid-stream does not start on a
  packet boundary. The answer is remembered: it is asked once per track per
  listing.
- A transport stream is read further into before anything decides what is in
  it -- 20 MB and 10 seconds rather than ffprobe's default -- and decoded with
  `+genpts+discardcorrupt`, because a 4K recording can carry seconds of null
  padding and no timestamps on its first frames.
- H.264 is copied at whatever size it is. 1080p and 4K cost the same to copy
  as 720p, so a 4K recording is watched and put on the air without an encoder
  running at all. Only the transport stream's ADTS audio is redone, as before.
- H.265 is copied when the client says it can decode one -- Safari, phones,
  televisions -- with the `hvc1` tag every player wants rather than the `hev1`
  ffmpeg writes. The browser says so for itself in the URL (`?hevc=1`); it is
  never guessed from a user agent. Where it cannot, the re-encode comes down
  to 1080p: measured here, 4K through libx264 -preset veryfast runs at 0.52x
  real time and 1080p at 1.65x, and an encode slower than real time is not a
  stream. A channel has one encode and a whole audience, so it re-encodes
  H.265 by default; NIXAMP_HEVC_CHANNELS=1 copies it through.
- A channel's backlog is six seconds again rather than four megabytes. Four
  megabytes is six seconds of 720p and under one second of copied 4K, which
  puts a joiner on the live edge with no cushion -- the play-wait-play loop
  the backlog exists to prevent. The cap now follows the rate the channel is
  measured running at, between the old floor and a 48 MB ceiling, and the
  rate is only believed after a window has closed: the burst at the start of
  a pull is not a bitrate.
- An H.265 channel is packaged into fMP4 HLS segments rather than transport
  ones, keyed off what the channel emits rather than what its source holds.
  HLS in TS is defined for H.264 only, and Safari -- the browser that needs
  HLS and the one that decodes H.265 -- plays an HEVC channel packaged as TS
  as sound over a black screen.
- In the browser, a picked `.ts` is media rather than something filtered out
  of the list, and goes to the mpegts.js engine instead of a <video> that
  cannot open it.

Tests build their own fixtures with ffmpeg -- 1080p and 4K H.264, and 4K HEVC
where libx265 is there -- and skip cleanly without it. Verified end to end
against a running server: a 4K H.264 recording comes back as 3840x2160 avc1
over /api/media and on its channel, a 4K HEVC one as 1080p H.264 or, with
?hevc=1, as 3840x2160 hvc1, and an HEVC channel's playlist carries
#EXT-X-MAP with .m4s parts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QuUkTrofSjQ15j79mRuy4f
@ralyodio
ralyodio marked this pull request as ready for review September 12, 2026 11:24
@ralyodio
ralyodio merged commit 018078d into main Sep 12, 2026
2 checks passed
@ralyodio
ralyodio deleted the feat/raw-mpegts-1080p-4k branch September 12, 2026 11:24
ralyodio added a commit that referenced this pull request Sep 12, 2026
Two fragmented-MP4 packagings met: the relay's, with a Packaging type and a
per-run token on the init segment, and the transport-stream work's, which
asks for fMP4 for an H.265 channel. The relay's shape stays and the H.265
rule folds into it: playlist(id, fmp4) picks fmp4 when asked, else the
compression policy's packaging. One segmentType, the finer one. The
transport tests learn the tokened init name.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MEguYxw44VkpjFrxxGrNGx
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant