A graphical subtitle burn-in demuxes the whole file first, and never reads what it extracted #168

Closed
opened 2026-08-21 11:45:18 +02:00 by Claude · 0 comments
Collaborator

Turning on a PGS track freezes the player. The segment request never answers,
nothing is logged, and no encoder starts — because the request is inside a
demux of the whole file, extracting subtitle tracks the transcode does not read.

Measured on a 24.2 GB 1080p remux over a gigabit share: ~4¼ minutes before
the first segment, at 95 MB/s, which is the link saturated. Upstream serves the
same file with the same track in 2–3 seconds.

The extracted files are never used. The filter_complex of our own job:

[0:2]scale,…,crop=1920:1036[sub];[0:0]…[main];[main][sub]overlay=eof_action=pass:repeatlast=0

[0:2] is the PGS stream mapped out of the input. The three .sup files the
request waited on are referenced by nothing.

Where the two servers part. EncodingHelper branches on hasGraphicalSubs
against hasTextSubs in every filter chain it builds (4122/4150, 4340/4374,
4679/4713, 4934/4976). Only the text branch calls GetTextSubtitlesFilter, and
only that reaches GetSubtitleFilePath → GetReadableFile →
ExtractAllExtractableSubtitles. A graphical subtitle is overlaid from the
stream and nothing is extracted at all.

Ours makes no such distinction:
crates/ephyra-app/src/streaming/encoder/extract.rs::burns_in_a_subtitle is
state.subtitle_stream.is_some() && (Encode || always_burn_in), so
extract_subtitles runs for a graphical burn-in too — and subtitle::tracks_of
takes every extractable track in the file, so picking one English PGS track
extracted all three. The filter chains themselves are right: software.rs:89/92
and the three hardware chains pick graphical_sub_preprocess_filters or
text_subtitles_filter exactly as upstream does. Only the extractor's
precondition is wrong.

A cancelled attempt keeps nothing. The extraction is spawned with
kill_on_drop, and <path>.meta is written only after a complete run, so a
client that gives up on the frozen player loses every byte read. Retrying
starts from zero. That is why it reads as broken rather than slow — and it
stays true for a text burn-in, where the extraction is correct and upstream's.

Where to start

burns_in_a_subtitle needs the text test the filter chains already make —
is_text_subtitle is in ephyra-encoding. That alone turns four minutes into
two seconds for every graphical burn-in.

Worth a second look afterwards: an extraction that survives the request that
started it, so the text case does not lose a long read to a client that stopped
watching.

How to know it is done

Burning in a PGS track starts within seconds of the segment request and writes
no .sup at all — the overlay reads the stream. A burned-in SRT or ASS still
extracts, and still burns in. A test over the argument builder that asks which
of the two a graphical track takes, failing today.

House rules

CLAUDE.md is the short version and these are the parts that bite: tests run
under cargo nextest; upstream's source is the checkout in upstream/ at the
pin
; generated files are regenerated rather than edited; a comment says what
the code does, not how it got there.

A test that cannot fail is worse than none.

Turning on a PGS track freezes the player. The segment request never answers, nothing is logged, and no encoder starts — because the request is inside a demux of the whole file, extracting subtitle tracks the transcode does not read. Measured on a 24.2 GB 1080p remux over a gigabit share: **~4¼ minutes** before the first segment, at 95 MB/s, which is the link saturated. Upstream serves the same file with the same track in **2–3 seconds**. **The extracted files are never used.** The `filter_complex` of our own job: [0:2]scale,…,crop=1920:1036[sub];[0:0]…[main];[main][sub]overlay=eof_action=pass:repeatlast=0 `[0:2]` is the PGS stream mapped out of the input. The three `.sup` files the request waited on are referenced by nothing. **Where the two servers part.** `EncodingHelper` branches on `hasGraphicalSubs` against `hasTextSubs` in every filter chain it builds (4122/4150, 4340/4374, 4679/4713, 4934/4976). Only the text branch calls `GetTextSubtitlesFilter`, and only that reaches `GetSubtitleFilePath` → `GetReadableFile` → `ExtractAllExtractableSubtitles`. A graphical subtitle is overlaid from the stream and nothing is extracted at all. Ours makes no such distinction: `crates/ephyra-app/src/streaming/encoder/extract.rs::burns_in_a_subtitle` is `state.subtitle_stream.is_some() && (Encode || always_burn_in)`, so `extract_subtitles` runs for a graphical burn-in too — and `subtitle::tracks_of` takes every extractable track in the file, so picking one English PGS track extracted all three. The filter chains themselves are right: `software.rs:89/92` and the three hardware chains pick `graphical_sub_preprocess_filters` or `text_subtitles_filter` exactly as upstream does. Only the extractor's precondition is wrong. **A cancelled attempt keeps nothing.** The extraction is spawned with `kill_on_drop`, and `<path>.meta` is written only after a complete run, so a client that gives up on the frozen player loses every byte read. Retrying starts from zero. That is why it reads as broken rather than slow — and it stays true for a *text* burn-in, where the extraction is correct and upstream's. ## Where to start `burns_in_a_subtitle` needs the text test the filter chains already make — `is_text_subtitle` is in `ephyra-encoding`. That alone turns four minutes into two seconds for every graphical burn-in. Worth a second look afterwards: an extraction that survives the request that started it, so the text case does not lose a long read to a client that stopped watching. ## How to know it is done Burning in a PGS track starts within seconds of the segment request and writes no `.sup` at all — the overlay reads the stream. A burned-in SRT or ASS still extracts, and still burns in. A test over the argument builder that asks which of the two a graphical track takes, failing today. ### House rules `CLAUDE.md` is the short version and these are the parts that bite: tests run under `cargo nextest`; upstream's source is the checkout in `upstream/` **at the pin**; generated files are regenerated rather than edited; a comment says what the code does, not how it got there. A test that cannot fail is worse than none.
alex closed this issue 2026-08-21 12:35:04 +02:00
Sign in to join this conversation.
No description provided.