feat(stream): progressive fMP4 remux source for /stream (hueco #3 / 3b-i)

Agent side of 3b: serve a growing ffmpeg `-c copy` remux (mkv h264/aac →
fragmented MP4) over /stream with no video re-encode. Dormant until the web
sends PlayMethod="remux" (3b-ii), so this commit changes no live behavior.

- GrowingSource interface + transcodeSource already satisfies it; estimate is
  the source file size for copy actions (≈ remux output) vs bitrate×duration
  for real transcodes.
- NewRemuxSource: ffmpeg -c copy → growing fMP4 temp, returned as GrowingSource.
- StreamServer.SetGrowingFile + serveGrowing: manual Range responder for a
  growing source (http.ServeContent needs a fixed size). 206 with an estimated
  total in Content-Range; chunked body while not final (never promise bytes a
  running remux might not produce); exact Content-Length once final. Blocks via
  ReadAt for not-yet-produced bytes; forward seek waits, backward seek instant.
- daemon OnStreamSession: PlayMethod=="remux" → NewRemuxSource + SetGrowingFile
  + MarkSessionReady (after the ffmpeg check; copy still needs ffmpeg).
- Tests: parseByteRange + serveGrowing (full/offset/bounded/estimate/HEAD/416).
This commit is contained in:
Deivid Soto 2026-05-31 11:49:31 +02:00
parent 6e8bca2ac4
commit 4a12f13b96
4 changed files with 450 additions and 6 deletions

View file

@ -618,6 +618,41 @@ func runDaemonStart() error {
log.Printf("[hls %s] rejected: ffmpeg/ffprobe unavailable", agent.ShortID(sess.SessionID))
return
}
// Remux path (hueco #3 / 3b): codecs are browser-native (h264/aac) but
// the container isn't (mkv). ffmpeg `-c copy` → growing fMP4 served raw
// over /stream — no video re-encode, no HLS. The web decided this from
// scan metadata + version gate; we still need ffmpeg (copy uses it).
if sess.PlayMethod == "remux" {
probeCtx, cancelProbe := context.WithTimeout(ctx, 15*time.Second)
probe, perr := engine.ProbeFile(probeCtx, tcRuntime.FFprobePath, filePath)
cancelProbe()
if perr != nil {
log.Printf("[stream %s] remux probe failed: %v", agent.ShortID(sess.SessionID), perr)
return
}
remuxCtx, remuxCancel := context.WithCancel(ctx)
src, serr := engine.NewRemuxSource(remuxCtx, filePath, probe, tcRuntime.FFmpegPath, sess.FileName)
if serr != nil {
remuxCancel()
log.Printf("[stream %s] remux start failed: %v", agent.ShortID(sess.SessionID), serr)
return
}
streamSrv.SetGrowingFile(src, sess.TaskID)
// cancel stops the ffmpeg copy; SetGrowingFile/ClearFile also Close()
// the source, so the temp file is always cleaned up.
playerSessionRegistry.add(sess.SessionID, func() { remuxCancel(); streamSrv.ClearFile() })
log.Printf("[stream %s] remux (copy) → fMP4: %s", agent.ShortID(sess.SessionID), filepath.Base(filePath))
go func() {
rctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if err := agentClient.MarkSessionReady(rctx, sess.SessionID); err != nil {
log.Printf("[stream %s] mark-ready failed: %v", agent.ShortID(sess.SessionID), err)
}
}()
return
}
hlsCtx, hlsCancel := context.WithCancel(ctx)
playerSessionRegistry.add(sess.SessionID, hlsCancel)
hlsCfg := engine.HLSSessionConfig{