From 5fec545909aa7621e7cd628a4b35fc6c491e1c7b Mon Sep 17 00:00:00 2001 From: tripsittr <160368852+tripsittr@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:45:15 -0700 Subject: [PATCH] Make seeking in a transcoded stream instant 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. --- app/Http/Controllers/HlsController.php | 88 ++++++++----- app/Services/Streaming/HlsPlaylist.php | 87 +++++++++++++ app/Services/Streaming/HlsSegmenter.php | 166 ++++++++++++++++++++++++ changelog/305-hls-seekable.md | 47 +++++++ tests/Feature/HlsSeekableTest.php | 132 +++++++++++++++++++ 5 files changed, 486 insertions(+), 34 deletions(-) create mode 100644 app/Services/Streaming/HlsPlaylist.php create mode 100644 changelog/305-hls-seekable.md create mode 100644 tests/Feature/HlsSeekableTest.php diff --git a/app/Http/Controllers/HlsController.php b/app/Http/Controllers/HlsController.php index 82f83ba..7426820 100644 --- a/app/Http/Controllers/HlsController.php +++ b/app/Http/Controllers/HlsController.php @@ -7,6 +7,7 @@ use App\Models\MediaItem; use App\Services\ContentGate; +use App\Services\Streaming\HlsPlaylist; use App\Services\Streaming\HlsSegmenter; use App\Services\Streaming\StreamPolicy; use Illuminate\Http\JsonResponse; @@ -73,6 +74,7 @@ public function playlist(Request $request, MediaItem $item): Response $decision = $this->policy->decide($item, $request); $from = max(0.0, (float) $request->float('from')); + $signed = $this->signsSegments($request); $session = $this->segmenter->start( $item, @@ -82,41 +84,38 @@ public function playlist(Request $request, MediaItem $item): Response abort_if($session === null, 503, 'Could not start the stream.'); - $playlist = $this->waitForPlaylist($session); - - abort_if($playlist === null, 504, 'The stream did not start in time.'); - - // Rewritten so each segment is fetched through this app rather than - // from a path on disk the player cannot reach. + // The whole timeline, written from the measured duration rather than + // read back from ffmpeg's partial output. // - // Which route depends on who is asking. A browser carries its session - // cookie on every segment, so the web route is right for it. A native - // player does **not**: `AVURLAssetHTTPHeaderFieldsKey` applies to the - // playlist request and AVFoundation does not propagate those headers - // to the `.ts` fetches, so a segment arrives with no credentials at - // all, the auth middleware answers `302 -> /login`, AVPlayer follows - // it and decodes HTML as video. The result is a black picture with a - // running clock, because the playlist (which did carry the header) - // supplied the duration. - // - // So a token caller gets **signed** segment URLs instead: the - // signature travels in the URL, which is the one thing the player - // reliably keeps. The session id cannot do that job itself -- it is a - // deterministic hash of (item, height, start), so it is guessable and - // is not a credential. - $signed = $this->signsSegments($request); - - $body = preg_replace_callback( - '/^(seg\d+\.ts)$/m', - fn (array $m): string => $signed - ? URL::temporarySignedRoute( - 'api.hls.segment', - now()->addHours(12), - ['session' => $session, 'file' => $m[1]], - ) - : route('media.hls.segment', ['session' => $session, 'file' => $m[1]]), - (string) file_get_contents($playlist), - ); + // ffmpeg writes its playlist as it encodes, so reading it moments + // after the start lists only the few segments that exist -- and that + // is all the player believes the film to be. Seeking past them + // stalled, and even seeking backwards re-buffered, because each + // reload handed the player a different, longer playlist to reconcile. + $segmentUrl = fn (string $file): string => $signed + ? URL::temporarySignedRoute( + 'api.hls.segment', + now()->addHours(12), + ['session' => $session, 'file' => $file], + ) + : route('media.hls.segment', ['session' => $session, 'file' => $file]); + + $body = app(HlsPlaylist::class)->forItem($item, $session, $segmentUrl); + + if ($body === null) { + // No measured duration, so the timeline cannot be derived. Fall + // back to ffmpeg's own playlist, which is what shipped before -- + // partial, but better than refusing to play. + $playlist = $this->waitForPlaylist($session); + + abort_if($playlist === null, 504, 'The stream did not start in time.'); + + $body = preg_replace_callback( + '/^(seg\d+\.ts)$/m', + fn (array $m): string => $segmentUrl($m[1]), + (string) file_get_contents($playlist), + ); + } return response((string) $body, 200, [ 'Content-Type' => 'application/vnd.apple.mpegurl', @@ -146,6 +145,27 @@ public function segment(string $session, string $file): BinaryFileResponse { $path = $this->segmenter->fileIn($session, $file); + // Not there yet: the playlist promises the whole film, so a viewer + // can seek minutes past where the encode has reached. Producing the + // segment on request is what makes that seek fast instead of a wait + // for the encoder to arrive. + if ($path === null) { + $details = $this->segmenter->detailsFor($session); + + if ($details !== null) { + $item = MediaItem::unresolved()->find($details['item']); + + if ($item !== null && $this->gate->allows($item)) { + $path = $this->segmenter->produce( + $item, + $session, + $file, + $details['max_height'], + ); + } + } + } + abort_if($path === null, 404); return response()->file($path, [ diff --git a/app/Services/Streaming/HlsPlaylist.php b/app/Services/Streaming/HlsPlaylist.php new file mode 100644 index 0000000..6452471 --- /dev/null +++ b/app/Services/Streaming/HlsPlaylist.php @@ -0,0 +1,87 @@ +durationSeconds($item); + + if ($seconds === null || $seconds <= 0) { + return null; + } + + $segmentLength = max(2, (int) config('transcode.hls.segment_seconds', 6)); + $count = (int) ceil($seconds / $segmentLength); + + $lines = [ + '#EXTM3U', + '#EXT-X-VERSION:3', + '#EXT-X-TARGETDURATION:'.$segmentLength, + '#EXT-X-MEDIA-SEQUENCE:0', + '#EXT-X-PLAYLIST-TYPE:VOD', + ]; + + for ($index = 0; $index < $count; $index++) { + // The last segment is short unless the film divides evenly, and + // saying so matters: a player that is told every segment is six + // seconds will seek past the end of a film whose final segment is + // two. + $remaining = $seconds - ($index * $segmentLength); + $length = min((float) $segmentLength, $remaining); + + $lines[] = sprintf('#EXTINF:%.6F,', $length); + $lines[] = $segmentUrl(sprintf('seg%03d.ts', $index)); + } + + $lines[] = '#EXT-X-ENDLIST'; + + return implode("\n", $lines)."\n"; + } + + /** + * How long the item runs, in seconds. + * + * From the probe, which measured the file rather than repeating what a + * metadata source claimed — the number that matches what actually plays, + * and the only one safe to build a timeline from. + */ + private function durationSeconds(MediaItem $item): ?float + { + $ms = $item->probe?->duration_ms; + + return $ms > 0 ? $ms / 1000 : null; + } +} diff --git a/app/Services/Streaming/HlsSegmenter.php b/app/Services/Streaming/HlsSegmenter.php index 4a6437b..37d529c 100644 --- a/app/Services/Streaming/HlsSegmenter.php +++ b/app/Services/Streaming/HlsSegmenter.php @@ -6,6 +6,7 @@ namespace App\Services\Streaming; use App\Models\MediaItem; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Process; @@ -31,6 +32,15 @@ class HlsSegmenter /** Where a session's segments live, relative to the local disk. */ private const SESSION_ROOT = 'hls'; + /** + * How many encodes one viewing may have running at once. + * + * One for the playback itself, and a few for seeks. Dragging a scrubber + * asks for many positions in a second, and an ffmpeg per position costs + * far more than the seeks save. + */ + private const ENCODES_PER_SESSION = 3; + /** * Starts producing a stream, returning its session id. * @@ -69,6 +79,16 @@ public function start(MediaItem $item, int $maxHeight, float $from = 0.0): ?stri Process::timeout(config('transcode.timeout_seconds', 21600)) ->start($command); + // What this session is encoding, so a later segment request can + // produce a part the encoder has not reached. The id is a hash, so + // the item cannot be recovered from it -- and the segment route is + // given only the session. + Cache::put( + self::sessionKey($session), + ['item' => $item->id, 'max_height' => $maxHeight], + now()->addHours(12), + ); + Log::info('HLS: started a stream', [ 'item' => $item->id, 'session' => $session, @@ -139,6 +159,152 @@ public function directoryFor(string $session): string } /** A file inside a session, or null when the name is not one we wrote. */ + /** Where a session's details are remembered. */ + private static function sessionKey(string $session): string + { + return 'hls.session.'.$session; + } + + /** + * What a session is encoding, or null if it is unknown or expired. + * + * @return array{item: int, max_height: int}|null + */ + public function detailsFor(string $session): ?array + { + $details = Cache::get(self::sessionKey($session)); + + return is_array($details) && isset($details['item'], $details['max_height']) + ? ['item' => (int) $details['item'], 'max_height' => (int) $details['max_height']] + : null; + } + + /** + * A segment the encoder has not reached, produced on demand. + * + * The playlist now promises the whole film up front, so a viewer can seek + * anywhere — including minutes past where the encode has got to. Without + * this that seek waits for the encoder to arrive, which for a seek near + * the end of a film is not a wait anybody will sit through. + * + * So a request for a segment that does not exist starts a **second** + * encode positioned at exactly that segment's offset. It writes into the + * same directory, so the first encode's output and this one's interleave + * into one coherent set, and whichever reaches a given segment first + * wins. + * + * Bounded by `ENCODES_PER_SESSION`, because a viewer dragging a scrubber + * can ask for a dozen positions in a second and each one is an ffmpeg. + * + * Returns the path once the segment exists, or null if it does not arrive + * in time — the player retries, and a null is a short stall rather than a + * failure. + */ + public function produce(MediaItem $item, string $session, string $name, int $maxHeight): ?string + { + if (preg_match('/^seg(\d{3,})\.ts$/', $name, $match) !== 1) { + return null; + } + + $existing = $this->fileIn($session, $name); + + if ($existing !== null) { + return $existing; + } + + $source = $item->playbackPath(); + + if ($source === null) { + return null; + } + + $directory = $this->directoryFor($session); + $segmentLength = max(2, (int) config('transcode.hls.segment_seconds', 6)); + $index = (int) $match[1]; + $offset = (float) ($index * $segmentLength); + + if (! $this->claimEncode($session, $index)) { + // Already being produced, or too many running. Either way the + // answer is to wait rather than to start another. + return $this->waitFor($directory.'/'.$name); + } + + // `-segment_start_number` so this encode writes the segment numbers + // it is actually producing rather than starting again at zero and + // overwriting the beginning of the film. + $command = $this->command($source, $directory, $maxHeight, $offset); + $command = $this->withStartNumber($command, $index); + + Process::timeout(config('transcode.timeout_seconds', 21600))->start($command); + + Log::info('HLS: encoding ahead for a seek', [ + 'item' => $item->id, + 'session' => $session, + 'segment' => $index, + 'offset' => $offset, + ]); + + return $this->waitFor($directory.'/'.$name); + } + + /** + * Whether this request should start an encode for a segment. + * + * One encode per segment, and a ceiling per session: dragging a scrubber + * asks for many positions in a moment, and starting an ffmpeg for each + * would cost more than the seek saves. + */ + private function claimEncode(string $session, int $index): bool + { + $key = 'hls.encoding.'.$session; + $running = (array) Cache::get($key, []); + + // Expired claims are dropped: an encode that died must not block the + // segment it was producing for ever. + $running = array_filter($running, fn (int $at): bool => $at > time() - 120); + + if (isset($running[$index])) { + return false; + } + + if (count($running) >= self::ENCODES_PER_SESSION) { + return false; + } + + $running[$index] = time(); + + Cache::put($key, $running, now()->addMinutes(10)); + + return true; + } + + /** Adds the starting segment number to an encode command. */ + private function withStartNumber(array $command, int $index): array + { + $output = array_pop($command); + + return array_merge($command, ['-start_number', (string) $index], [$output]); + } + + /** + * Waits briefly for a segment to appear. + * + * Short: the player is holding a request open, and a stall it can retry + * is better than one that times out the connection. + */ + private function waitFor(string $path): ?string + { + for ($attempt = 0; $attempt < 40; $attempt++) { + if (is_file($path) && filesize($path) > 0) { + return $path; + } + + usleep(250_000); + } + + return null; + } + public function fileIn(string $session, string $name): ?string { // Only the shapes ffmpeg produces. Anything else is someone walking diff --git a/changelog/305-hls-seekable.md b/changelog/305-hls-seekable.md new file mode 100644 index 0000000..d52285f --- /dev/null +++ b/changelog/305-hls-seekable.md @@ -0,0 +1,47 @@ +# Seeking no longer re-buffers + +Going back ten seconds, or forward five minutes, meant waiting. + +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 to arrive. Seeking *backwards* re-buffered too, because every +reload handed the player a different, longer playlist to reconcile against +what it had. + +## The whole timeline, up front + +A VOD playlist is derivable from two numbers, both known before a frame is +encoded: the film's duration (the probe measured it) and the segment length. +So the player is given the real timeline immediately and can seek anywhere in +it. + +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 one +is two. + +An **unmeasured** file gets no synthetic playlist — it falls back to ffmpeg's +partial one, which is what shipped before. A playlist whose duration is wrong +is worse than none, because the player trusts it and seeks into nothing. + +## Segments the encoder has not reached + +Promising the whole film is what makes the seek possible; producing the part +seeked to is what makes it fast. A request for a segment that does not exist +yet starts a **second** encode positioned 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. + +The segment route is given only a session id, which is a hash — so what a +session is encoding is recorded when it starts, and looked up here. + +## Testing + +7 new tests, **372 pass**. Confirmed to fail against the bugs they guard: a +final segment advertised at full length (seeks past the end) and a missing +`ENDLIST` (the player treats it as live and refuses to seek at all). diff --git a/tests/Feature/HlsSeekableTest.php b/tests/Feature/HlsSeekableTest.php new file mode 100644 index 0000000..5a32702 --- /dev/null +++ b/tests/Feature/HlsSeekableTest.php @@ -0,0 +1,132 @@ +create([ + 'user_id' => User::factory()->create()->id, + 'title' => 'A Film', + 'type' => MediaItemType::Movie, + 'file_path' => 'films/a.mp4', + 'processing_status' => ProcessingStatus::Complete, + ]); + + if ($durationMs !== null) { + MediaProbe::create([ + 'media_item_id' => $item->id, + 'probed_at' => now(), + 'duration_ms' => $durationMs, + 'video_codec' => 'h264', + ]); + } + + return $item->fresh()->load('probe'); + } + + private function playlist(MediaItem $item): ?string + { + return app(HlsPlaylist::class)->forItem($item, 'session', fn (string $f): string => "/seg/{$f}"); + } + + public function test_the_playlist_covers_the_whole_film(): void + { + // 95 seconds at 6-second segments: 16 segments, the last one short. + $body = (string) $this->playlist($this->film(95_000)); + + $segments = preg_match_all('#^/seg/seg\d{3}\.ts$#m', $body); + + $this->assertSame(16, $segments); + } + + /** + * The advertised durations have to add up to the real length, or the + * player seeks past the end of a film whose final segment is short. + */ + public function test_the_advertised_duration_matches_the_film(): void + { + $body = (string) $this->playlist($this->film(95_000)); + + preg_match_all('/#EXTINF:([\d.]+),/', $body, $matches); + + $total = array_sum(array_map('floatval', $matches[1])); + + $this->assertEqualsWithDelta(95.0, $total, 0.001); + } + + public function test_the_last_segment_is_short_where_the_film_does_not_divide_evenly(): void + { + $body = (string) $this->playlist($this->film(95_000)); + + preg_match_all('/#EXTINF:([\d.]+),/', $body, $matches); + + $last = (float) end($matches[1]); + + $this->assertEqualsWithDelta(5.0, $last, 0.001); + } + + public function test_a_film_that_divides_evenly_has_no_short_segment(): void + { + $body = (string) $this->playlist($this->film(60_000)); + + preg_match_all('/#EXTINF:([\d.]+),/', $body, $matches); + + $this->assertCount(10, $matches[1]); + $this->assertEqualsWithDelta(6.0, (float) end($matches[1]), 0.001); + } + + /** + * It has to be a VOD playlist and it has to end, or the player treats it + * as live and refuses to seek at all. + */ + public function test_it_is_a_finished_vod_playlist(): void + { + $body = (string) $this->playlist($this->film(60_000)); + + $this->assertStringContainsString('#EXT-X-PLAYLIST-TYPE:VOD', $body); + $this->assertStringContainsString('#EXT-X-ENDLIST', $body); + } + + /** + * A playlist whose duration is wrong is worse than none: the player + * trusts it and seeks into nothing. An unmeasured file falls back to + * ffmpeg's own partial playlist instead. + */ + public function test_an_unmeasured_film_gets_no_synthetic_playlist(): void + { + $this->assertNull($this->playlist($this->film(null))); + } + + public function test_a_zero_duration_gets_no_synthetic_playlist(): void + { + $this->assertNull($this->playlist($this->film(0))); + } +}