Skip to content

Make seeking in a transcoded stream instant - #309

Merged
tripsittr merged 1 commit into
mainfrom
hls-seekable
Oct 7, 2026
Merged

tripsittr merged 1 commit into
mainfrom
hls-seekable

Conversation

@tripsittr

Copy link
Copy Markdown
Owner

Reported: going back ten seconds, or forward five minutes, re-buffers.

Why

ffmpeg writes its playlist as it encodes. So the playlist read moments after a stream starts lists only the handful of segments that exist — and that is all the player believes the film to be.

Seeking past them stalled waiting for the encoder. Seeking backwards re-buffered too, because every reload handed the player a different, longer playlist to reconcile against what it already had.

The whole timeline, up front

A VOD playlist is derivable from two numbers both known before a frame is encoded: the duration (the probe measured it) and the segment length. The player now gets the real timeline immediately.

The final segment is advertised at its true, shorter length — a player told every segment is six seconds will seek past the end of a film whose last is two. Verified: a 95-second film produces 16 segments, the last 5 seconds, totalling exactly 95.

An unmeasured file gets no synthetic playlist and falls back to ffmpeg's partial one, which is what shipped before. A playlist with a wrong duration is worse than none, because the player trusts it and seeks into nothing.

Segments the encoder has not reached

Promising the whole film makes the seek possible; producing the seeked-to part makes it fast. A request for a segment that does not exist starts a second encode at exactly that offset, writing into the same directory with -start_number so the two interleave into one coherent set.

Bounded at three encodes per viewing — dragging a scrubber asks for a dozen positions in a second, and an ffmpeg for each would cost far more than the seeks save. A claim expires after two minutes so a died encode does not block its segment for ever.

Testing

7 new tests, 372 pass. Confirmed to fail against both bugs they guard: a final segment at full length, and a missing ENDLIST (which makes the player treat it as live and refuse to seek at all).

For review — two things I would value your eye on

1. Concurrent encodes writing to one directory. Two ffmpegs writing seg%03d.ts into the same place, with the second offset by -start_number, should not collide — the segment a given index names is the same content either way. But I have not proven that under load, and "should not collide" is doing work in that sentence. If you think this wants a separate directory per encode with a merge step, say so.

2. The three-encode ceiling is a guess. Not measured. It could be too low for a viewer who seeks a lot, or too high for a server already transcoding for several people.

Not verified end-to-end: I have no transcodable file here, so the seek behaviour itself rests on the playlist arithmetic being right rather than on watching a scrubber. Worth one real seek test on a5.

ffmpeg writes its playlist as it encodes, so the playlist read moments after a
stream starts lists only the segments that exist -- and that is all the player
believes the film to be. Seeking past them stalled waiting for the encoder,
and seeking backwards re-buffered too, because each reload handed the player a
different, longer playlist to reconcile.

A VOD playlist is derivable from the duration and the segment length, both
known before a frame is encoded, so the player now gets the real timeline at
once. The final segment is advertised at its true shorter length: a player
told every segment is six seconds seeks past the end of a film whose last one
is two. An unmeasured file gets no synthetic playlist and falls back to
ffmpeg's partial one, because a playlist with a wrong duration is worse than
none.

Promising the whole film makes the seek possible; producing the part seeked to
makes it fast. A request for a segment that does not exist starts a second
encode at that offset, writing into the same directory with -start_number so
the two interleave. Bounded at three per viewing, since dragging a scrubber
asks for a dozen positions in a second.

What a session is encoding is recorded when it starts: the segment route is
given only a session id, and that is a hash.
@tripsittr

Copy link
Copy Markdown
Owner Author

Reviewed on a5 — clean. 7 tests pass. Based on current main, no migration.

Writing the full VOD playlist up front from measured duration + segment length (instead of ffmpeg's as-it-encodes partial) is the right fix for seek re-buffering, and the integration points are all handled:

  • Signed-URL protection carries through — the new playlist's segment URLs still go through signsSegments() → temporarySignedRoute() for token clients, so Serve HLS segments to clients that authenticate with a token #302's protection isn't bypassed.
  • On-demand produce() is safe and bounded — strict ^seg(\d{3,})\.ts$ name regex (no traversal), index drives the -ss offset, claimEncode() de-dupes concurrent requests for the same segment, waitFor() returns null gracefully if it doesn't arrive. And it's reachable only via a server-signed URL, so segment indices can't be forged arbitrarily.
  • Graceful fallback — no measured duration → falls back to ffmpeg's own playlist (prior behaviour), rather than guessing a wrong timeline.

Nothing blocking. The streaming chain (#301 decision / #302 signed segments / #305 profile / #309 seekable) is solid now.

@tripsittr

Copy link
Copy Markdown
Owner Author

a5 — the "should is doing work there" claim, measured. Encoded segment index 2 two ways on a real Midnight Gospel episode (1080p HEVC→H.264, your exact flags: forced -g/-keyint_min = 144, -sc_threshold 0, -hls_time 6):

sequential (-ss 0 -t 24) on-demand (-ss 12 -start_number 2 -t 6)
frames 144 144
first frame keyframe keyframe
duration 6.071s 6.027s
size 985 KB 953 KB

Verdict: decode-equivalent, interchangeable. Both hold the same 144 frames (one forced GOP = one segment) over the same time range, each independently decodable (keyframe-started). They are not byte-identical — libx264 rate-control state differs between a full run and a fresh seek — but byte-identity isn't what HLS needs; independent decodability + matching timeline is, and that holds. The ~44 ms EXTINF variance is within player tolerance. So the -start_number offset path is sound.

Separately, the three-encode ceiling: I can't measure the right value without production load, so it stays a reasonable-default/tuning guess — flagging, not blocking. Everything else on #309 I already reviewed clean.

@tripsittr
tripsittr merged commit 34b9f6e into main Oct 7, 2026
3 checks passed
@tripsittr
tripsittr deleted the hls-seekable branch October 7, 2026 00:12
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