fix(stream): el modo copy ignora StartSec (offset EVENT rompe iOS nativo)

Un playlist EVENT cuyas entradas empiezan en 0 mientras los fragmentos
llevan tfdt desplazado (-ss + -output_ts_offset) es exactamente la forma
que el parser HLS nativo de iOS no traga: resume a 368s → error del player
y bucle de re-bootstrap de sesión en iPhone (observado 2026-06-10).

Copy produce siempre desde 0 con PTS absolutos reales: adelanta a la
reproducción a velocidad de I/O, así que el punto de resume aparece en la
timeline creciente en segundos y el seek de startPosition del player
aterriza con normalidad. Test de resume actualizado: el playlist debe
cubrir la timeline completa.
This commit is contained in:
Deivid Soto 2026-06-10 23:31:58 +02:00
parent 5a92df1e14
commit 9eb3e44153
2 changed files with 15 additions and 15 deletions

View file

@ -205,7 +205,8 @@ type HLSSessionConfig struct {
// file is remuxed at I/O speed, minutes at worst on a weak NAS);
// - no HLS cache (re-generating costs no encode — caching would only
// burn disk);
// - StartSec is passed straight to `-ss` (keyframe-snapped by ffmpeg).
// - StartSec is ignored: copy produces from 0 (outruns playback at I/O
// speed); an offset EVENT playlist breaks iOS's native HLS parser.
// See docs/plans/hls-copy-remux-replacement.md (web repo).
VideoCopy bool
}
@ -1902,13 +1903,14 @@ func renderVideoPlaylist(durationSec float64, segCount int) string {
func buildHLSCopyArgs(cfg HLSSessionConfig, probe *StreamProbe, tmpDir string) []string {
args := []string{"-y", "-hide_banner", "-loglevel", "warning", "-stats"}
// Resume: input-side seek snaps to the keyframe at/before StartSec (demux
// seek — instant). -output_ts_offset keeps the fragments' tfdt on the
// absolute timeline so the player's clock matches the real position.
if cfg.StartSec > 0 && cfg.StartSec < probe.DurationSec {
ss := strconv.FormatFloat(cfg.StartSec, 'f', 3, 64)
args = append(args, "-ss", ss)
}
// StartSec is INTENTIONALLY ignored in copy mode: an EVENT playlist whose
// entries start at position 0 while the fragments carry an offset tfdt
// (-ss + -output_ts_offset) is exactly the shape iOS's native HLS parser
// chokes on (observed 2026-06-10: resume at 368s → player error + session
// re-bootstrap loop on iPhone). Copy always produces from 0 with true
// absolute PTS — it outruns playback at I/O speed, so the resume point
// appears in the growing timeline within seconds and the player's own
// startPosition seek lands normally.
if cfg.SourceURL != "" {
args = append(args,
@ -1919,9 +1921,6 @@ func buildHLSCopyArgs(cfg HLSSessionConfig, probe *StreamProbe, tmpDir string) [
)
}
args = append(args, "-i", cfg.sourceRef())
if cfg.StartSec > 0 && cfg.StartSec < probe.DurationSec {
args = append(args, "-output_ts_offset", strconv.FormatFloat(cfg.StartSec, 'f', 3, 64))
}
// Map video + selected audio (same clamping rules as the encode path).
args = append(args, "-map", "0:v:0")

View file

@ -205,8 +205,9 @@ func TestHLSCopy_ResumeStartSec(t *testing.T) {
[]string{"-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p"},
[]string{"-c:a", "aac", "-b:a", "128k"}, 12)
_, pl := runCopySession(t, rt, src, 6)
// Resume covers roughly the back half (keyframe-snapped, so allow the
// full GOP of slack: 60 frames @30fps = 2s).
// StartSec must be IGNORED in copy mode: the playlist covers the FULL
// timeline from 0 (an offset EVENT playlist breaks iOS's native parser;
// the player seeks to the resume point itself). Sum ≈ full 12s.
var sum float64
for _, line := range strings.Split(pl, "\n") {
if strings.HasPrefix(line, "#EXTINF:") {
@ -215,8 +216,8 @@ func TestHLSCopy_ResumeStartSec(t *testing.T) {
sum += d
}
}
if sum < 4 || sum > 9 {
t.Errorf("resume EXTINF sum = %.2fs, want ≈6s (12s source, -ss 6, ±GOP)", sum)
if sum < 10.5 || sum > 13.5 {
t.Errorf("copy EXTINF sum = %.2fs, want ≈12s (StartSec ignored, full timeline)", sum)
}
}