feat(stream): HLS-copy — reemplazo resiliente del remux progresivo
Nuevo modo VideoCopy en el engine HLS: ffmpeg -c:v copy (el vídeo jamás se re-encodea — I/O puro, funciona en un NAS sin GPU), audio copy si ya es AAC o AAC 192k si no, muxeado a segmentos fMP4 con ffmpeg escribiendo SU PROPIO playlist (EVENT mientras corre, ENDLIST al acabar, EXTINF exactos en los keyframes del source). Sustituye al remux growing-fMP4 servido por HTTP Range artesanal, cuya fragilidad estructural produjo tres incidentes en un día (init malformado/delay_moov, loop de re-seek por total inventado, iOS rechazando total desconocido). Diferencias deliberadas respecto al modo encode: - playlist de ffmpeg servido desde disco (los cortes van a keyframe del source → duraciones imposibles de pre-renderizar; medido: probar keyframes antes cuesta 8-24s, inviable para TTFF) - sin seek-restart ni auto-restart (la copia va a velocidad de disco y adelanta a cualquier viewer; el -ss de segmentos uniformes corrompería la timeline de cortes variables) - sin caché HLS (regenerar no cuesta encode; cachear solo quema disco) - resume vía -ss (snap a keyframe) + -output_ts_offset - master playlist sin CODECS (un string hardcodeado equivocado hace que iOS rechace la variante; omitirlo es legal y universal) Validación: TTFB seg-0 510ms sobre el MKV real del incidente (HEVC Main10 + EAC3, 6.7GB). Suite de integración con ffmpeg real (tag smoke): h264+aac (copy total), h264+ac3 (re-encode de audio con priming dts — la clase delay_moov), hevc10+eac3 (la forma exacta del incidente, tag hvc1), resume con StartSec, y serving del playlist; asserts de codecs vía ffprobe sobre el playlist servido, suma EXTINF ≈ duración, segmentos completos en disco (+temp_file = rename atómico). El wiring web (plan remux→hls+videoCopy con gate de versión ≥1.0.10) va en el repo web. Plan: docs/plans/hls-copy-remux-replacement.md (web).
This commit is contained in:
parent
3fcfaaf234
commit
5a92df1e14
4 changed files with 499 additions and 12 deletions
|
|
@ -522,6 +522,12 @@ type StreamSession struct {
|
||||||
// the raw file over /stream (HTTP Range, no ffmpeg) instead of
|
// the raw file over /stream (HTTP Range, no ffmpeg) instead of
|
||||||
// transcoding to HLS. See hueco #3 phase 3a in the roadmap.
|
// transcoding to HLS. See hueco #3 phase 3a in the roadmap.
|
||||||
PlayMethod string `json:"playMethod,omitempty"`
|
PlayMethod string `json:"playMethod,omitempty"`
|
||||||
|
// VideoCopy (playMethod "hls" only): serve via HLS-copy — ffmpeg -c:v copy
|
||||||
|
// into fMP4 segments, audio to AAC when needed. The robust replacement for
|
||||||
|
// the progressive-remux path: same near-zero CPU (video never re-encoded,
|
||||||
|
// works on a GPU-less NAS), but in the segmented transport every player
|
||||||
|
// handles. Set by webs that know this agent supports it (≥1.0.10).
|
||||||
|
VideoCopy bool `json:"videoCopy,omitempty"`
|
||||||
// DirectURL, when set, is an HTTPS link to the media resolved server-side
|
// DirectURL, when set, is an HTTPS link to the media resolved server-side
|
||||||
// from the user's debrid account (hueco #2 / 2a). The source has no local
|
// from the user's debrid account (hueco #2 / 2a). The source has no local
|
||||||
// file: the daemon streams /stream from this URL via ranged GETs
|
// file: the daemon streams /stream from this URL via ranged GETs
|
||||||
|
|
|
||||||
|
|
@ -998,6 +998,7 @@ func runDaemonStart() error {
|
||||||
BurnSubtitleIndex: sess.BurnSubtitleIndex,
|
BurnSubtitleIndex: sess.BurnSubtitleIndex,
|
||||||
StartSec: sess.StartSec,
|
StartSec: sess.StartSec,
|
||||||
Prewarm: sess.Prewarm,
|
Prewarm: sess.Prewarm,
|
||||||
|
VideoCopy: sess.VideoCopy,
|
||||||
Transcode: tcRuntime,
|
Transcode: tcRuntime,
|
||||||
Cache: hlsCache,
|
Cache: hlsCache,
|
||||||
}, hlsCtx, hlsCancel)
|
}, hlsCtx, hlsCancel)
|
||||||
|
|
|
||||||
|
|
@ -191,8 +191,30 @@ type HLSSessionConfig struct {
|
||||||
// of the same file at the same quality skip ffmpeg entirely. nil disables
|
// of the same file at the same quality skip ffmpeg entirely. nil disables
|
||||||
// caching (per-session tmpdir, deleted on Close — original behavior).
|
// caching (per-session tmpdir, deleted on Close — original behavior).
|
||||||
Cache *HLSCache
|
Cache *HLSCache
|
||||||
|
// VideoCopy switches the session to HLS-copy mode: ffmpeg `-c:v copy`
|
||||||
|
// (NEVER re-encodes video — I/O-bound, works on a GPU-less NAS), audio
|
||||||
|
// copied when already AAC or re-encoded to AAC otherwise. This replaces
|
||||||
|
// the fragile progressive-remux path (growing fMP4 over manual HTTP
|
||||||
|
// Range) with the robust segmented transport every player handles
|
||||||
|
// (hls.js + native iOS HLS). Differences from the encode mode, all
|
||||||
|
// driven by "segments cut at the SOURCE's keyframes, so their durations
|
||||||
|
// are unknown upfront":
|
||||||
|
// - the media playlist is ffmpeg's own (EVENT → ENDLIST), served from
|
||||||
|
// disk — not the pre-rendered uniform-2s VOD manifest;
|
||||||
|
// - no seek-restart / auto-restart (copy outruns any viewer: the whole
|
||||||
|
// 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).
|
||||||
|
// See docs/plans/hls-copy-remux-replacement.md (web repo).
|
||||||
|
VideoCopy bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// copyPlaylistName is the on-disk media playlist ffmpeg owns in VideoCopy
|
||||||
|
// mode, under <tmpDir>/video/. Distinct from the encode mode's in-memory
|
||||||
|
// manifest so the two can never be confused.
|
||||||
|
const copyPlaylistName = "copy.m3u8"
|
||||||
|
|
||||||
// sourceRef returns the ffmpeg/ffprobe input: the remote URL when set, else the
|
// sourceRef returns the ffmpeg/ffprobe input: the remote URL when set, else the
|
||||||
// local path. Used everywhere a `-i` argument or a probe target is needed so
|
// local path. Used everywhere a `-i` argument or a probe target is needed so
|
||||||
// the local-file and debrid-URL paths share one code path.
|
// the local-file and debrid-URL paths share one code path.
|
||||||
|
|
@ -490,6 +512,12 @@ func StartHLSSession(ctx context.Context, cfg HLSSessionConfig) (*HLSSession, er
|
||||||
fromCache bool
|
fromCache bool
|
||||||
writerLockHeld bool
|
writerLockHeld bool
|
||||||
)
|
)
|
||||||
|
if cfg.VideoCopy && cfg.Cache != nil {
|
||||||
|
// HLS-copy never caches: re-generating costs no encode (I/O-bound), so
|
||||||
|
// persisting segments would only burn cache budget that real transcodes
|
||||||
|
// need. Private per-session tmpdir, deleted on Close.
|
||||||
|
cfg.Cache = nil
|
||||||
|
}
|
||||||
if cfg.Cache != nil {
|
if cfg.Cache != nil {
|
||||||
// Debrid URL sessions key by CacheID (info_hash) so re-plays hit cache
|
// Debrid URL sessions key by CacheID (info_hash) so re-plays hit cache
|
||||||
// despite the URL changing each resolution; local files key by path.
|
// despite the URL changing each resolution; local files key by path.
|
||||||
|
|
@ -566,8 +594,16 @@ func StartHLSSession(ctx context.Context, cfg HLSSessionConfig) (*HLSSession, er
|
||||||
writerLockHeld: writerLockHeld,
|
writerLockHeld: writerLockHeld,
|
||||||
liveURL: cfg.SourceURL, // mutable copy; cfg stays immutable
|
liveURL: cfg.SourceURL, // mutable copy; cfg stays immutable
|
||||||
}
|
}
|
||||||
s.manifestVideo = renderVideoPlaylist(probe.DurationSec, segCount)
|
if cfg.VideoCopy {
|
||||||
s.manifestRoot = renderMasterPlaylist(probe, cfg.Quality)
|
// Copy mode: ffmpeg owns the media playlist (segments cut at the
|
||||||
|
// source's keyframes → durations unknown upfront, the uniform-2s
|
||||||
|
// pre-render would lie). ServeVideoPlaylist reads it from disk.
|
||||||
|
s.manifestVideo = ""
|
||||||
|
s.manifestRoot = renderMasterPlaylistCopy(probe)
|
||||||
|
} else {
|
||||||
|
s.manifestVideo = renderVideoPlaylist(probe.DurationSec, segCount)
|
||||||
|
s.manifestRoot = renderMasterPlaylist(probe, cfg.Quality)
|
||||||
|
}
|
||||||
|
|
||||||
// Cache HIT: every segment + init.mp4 is already on disk. Skip ffmpeg
|
// Cache HIT: every segment + init.mp4 is already on disk. Skip ffmpeg
|
||||||
// entirely and mark readyMax so handlers don't wait. Background subtitle
|
// entirely and mark readyMax so handlers don't wait. Background subtitle
|
||||||
|
|
@ -596,7 +632,12 @@ func StartHLSSession(ctx context.Context, cfg HLSSessionConfig) (*HLSSession, er
|
||||||
// encode never seals the cache (allSegmentsPresent checks 0..N), matching
|
// encode never seals the cache (allSegmentsPresent checks 0..N), matching
|
||||||
// today's post-seek behaviour.
|
// today's post-seek behaviour.
|
||||||
startIdx := 0
|
startIdx := 0
|
||||||
if cfg.StartSec > 0 && cfg.StartSec < probe.DurationSec {
|
if cfg.VideoCopy {
|
||||||
|
// Copy mode always numbers from 0: segment indices don't map to
|
||||||
|
// uniform 2s slots, so a StartSec-derived index would be wrong. The
|
||||||
|
// resume seek itself is handled inside buildHLSCopyArgs via `-ss`
|
||||||
|
// (keyframe-snapped) + `-output_ts_offset`.
|
||||||
|
} else if cfg.StartSec > 0 && cfg.StartSec < probe.DurationSec {
|
||||||
startIdx = segmentIdxForTime(cfg.StartSec)
|
startIdx = segmentIdxForTime(cfg.StartSec)
|
||||||
if startIdx > segCount-1 {
|
if startIdx > segCount-1 {
|
||||||
startIdx = segCount - 1
|
startIdx = segCount - 1
|
||||||
|
|
@ -616,7 +657,12 @@ func StartHLSSession(ctx context.Context, cfg HLSSessionConfig) (*HLSSession, er
|
||||||
// touching the parent ctx.
|
// touching the parent ctx.
|
||||||
ffCtx, cancel := context.WithCancel(context.Background())
|
ffCtx, cancel := context.WithCancel(context.Background())
|
||||||
s.cancel = cancel
|
s.cancel = cancel
|
||||||
args := buildHLSFFmpegArgsAt(cfg, probe, tmpDir, startIdx, segmentStartSec(startIdx))
|
var args []string
|
||||||
|
if cfg.VideoCopy {
|
||||||
|
args = buildHLSCopyArgs(cfg, probe, tmpDir)
|
||||||
|
} else {
|
||||||
|
args = buildHLSFFmpegArgsAt(cfg, probe, tmpDir, startIdx, segmentStartSec(startIdx))
|
||||||
|
}
|
||||||
cmd := exec.CommandContext(ffCtx, cfg.Transcode.FFmpegPath, args...)
|
cmd := exec.CommandContext(ffCtx, cfg.Transcode.FFmpegPath, args...)
|
||||||
cmd.Stderr = &hlsStderrCapture{owner: s}
|
cmd.Stderr = &hlsStderrCapture{owner: s}
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
|
|
@ -644,19 +690,27 @@ func StartHLSSession(ctx context.Context, cfg HLSSessionConfig) (*HLSSession, er
|
||||||
// triaged from the agent log alone — `encoder=libx264 accel=none` means
|
// triaged from the agent log alone — `encoder=libx264 accel=none` means
|
||||||
// the user's ffmpeg has no HW encoders compiled in, which is the most
|
// the user's ffmpeg has no HW encoders compiled in, which is the most
|
||||||
// common root cause (linuxbrew, default brew formula on macOS).
|
// common root cause (linuxbrew, default brew formula on macOS).
|
||||||
profile := ResolveEncoderProfile(cfg.Transcode.HWAccel, cfg.Transcode.Preset)
|
encoderNote := ""
|
||||||
presetNote := ""
|
if cfg.VideoCopy {
|
||||||
if profile.Preset != "" {
|
encoderNote = "encoder=copy (no video re-encode)"
|
||||||
presetNote = " preset=" + profile.Preset
|
} else {
|
||||||
|
profile := ResolveEncoderProfile(cfg.Transcode.HWAccel, cfg.Transcode.Preset)
|
||||||
|
presetNote := ""
|
||||||
|
if profile.Preset != "" {
|
||||||
|
presetNote = " preset=" + profile.Preset
|
||||||
|
}
|
||||||
|
encoderNote = fmt.Sprintf("encoder=%s accel=%s%s", profile.Codec, string(cfg.Transcode.HWAccel), presetNote)
|
||||||
}
|
}
|
||||||
startNote := ""
|
startNote := ""
|
||||||
if startIdx > 0 {
|
if cfg.VideoCopy && cfg.StartSec > 0 {
|
||||||
|
startNote = fmt.Sprintf(" start=%.0fs", cfg.StartSec)
|
||||||
|
} else if startIdx > 0 {
|
||||||
startNote = fmt.Sprintf(" start=seg-%d@%.0fs", startIdx, segmentStartSec(startIdx))
|
startNote = fmt.Sprintf(" start=seg-%d@%.0fs", startIdx, segmentStartSec(startIdx))
|
||||||
}
|
}
|
||||||
log.Printf("[hls %s] started: %s, %.1fs, %d segs (quality=%s, encoder=%s accel=%s%s)%s%s",
|
log.Printf("[hls %s] started: %s, %.1fs, %d segs (quality=%s, %s)%s%s",
|
||||||
shortHLSID(cfg.SessionID), cfg.logName(),
|
shortHLSID(cfg.SessionID), cfg.logName(),
|
||||||
probe.DurationSec, segCount, coalesce(cfg.Quality, "auto"),
|
probe.DurationSec, segCount, coalesce(cfg.Quality, "auto"),
|
||||||
profile.Codec, string(cfg.Transcode.HWAccel), presetNote, cachedNote, startNote)
|
encoderNote, cachedNote, startNote)
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -953,6 +1007,15 @@ func (s *HLSSession) waitFFmpeg() {
|
||||||
}
|
}
|
||||||
log.Printf("[hls %s] ffmpeg exited: %v", shortHLSID(s.cfg.SessionID), err)
|
log.Printf("[hls %s] ffmpeg exited: %v", shortHLSID(s.cfg.SessionID), err)
|
||||||
|
|
||||||
|
// Copy mode: no auto-restart. restartFromSegment's `-ss segmentStartSec(N)`
|
||||||
|
// math assumes uniform 2s segments, which copy mode doesn't have — a
|
||||||
|
// restart would corrupt the timeline. A failed copy surfaces through the
|
||||||
|
// player's probe deadline / fallback chain instead.
|
||||||
|
if s.cfg.VideoCopy {
|
||||||
|
log.Printf("[hls %s] copy session failed — not restarting (player falls back)", shortHLSID(s.cfg.SessionID))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Decide whether to attempt an auto-restart. We don't restart when:
|
// Decide whether to attempt an auto-restart. We don't restart when:
|
||||||
// - the session was closed externally (kill on quality change etc.)
|
// - the session was closed externally (kill on quality change etc.)
|
||||||
// - we've already retried 3 times within the last 60 s (broken file)
|
// - we've already retried 3 times within the last 60 s (broken file)
|
||||||
|
|
@ -1129,9 +1192,39 @@ func (s *HLSSession) ServeVideoPlaylist(w http.ResponseWriter, r *http.Request)
|
||||||
s.Touch()
|
s.Touch()
|
||||||
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
|
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
if s.cfg.VideoCopy {
|
||||||
|
s.serveCopyPlaylist(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
_, _ = io.WriteString(w, s.manifestVideo)
|
_, _ = io.WriteString(w, s.manifestVideo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// serveCopyPlaylist serves ffmpeg's own media playlist (VideoCopy mode). The
|
||||||
|
// file appears within ~1 s of spawn (copy is I/O-bound) but the player's
|
||||||
|
// first fetch can race it — poll briefly instead of returning a 404 hls.js
|
||||||
|
// would surface as a manifest error. Each request re-reads the file: the
|
||||||
|
// playlist GROWS (EVENT) until ffmpeg appends ENDLIST, and players re-poll
|
||||||
|
// growing playlists by design.
|
||||||
|
func (s *HLSSession) serveCopyPlaylist(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := filepath.Join(s.tmpDir, "video", copyPlaylistName)
|
||||||
|
deadline := time.Now().Add(10 * time.Second)
|
||||||
|
for {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err == nil && len(data) > 0 {
|
||||||
|
_, _ = w.Write(data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Context().Err() != nil || time.Now().After(deadline) {
|
||||||
|
http.Error(w, "playlist not ready", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-r.Context().Done():
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ServeInit writes init.mp4 (the fMP4 init segment) to w.
|
// ServeInit writes init.mp4 (the fMP4 init segment) to w.
|
||||||
func (s *HLSSession) ServeInit(w http.ResponseWriter, r *http.Request) {
|
func (s *HLSSession) ServeInit(w http.ResponseWriter, r *http.Request) {
|
||||||
s.Touch()
|
s.Touch()
|
||||||
|
|
@ -1188,7 +1281,10 @@ func (s *HLSSession) ServeSegment(w http.ResponseWriter, r *http.Request, idx in
|
||||||
readyMax := s.readyMax
|
readyMax := s.readyMax
|
||||||
s.readyMu.Unlock()
|
s.readyMu.Unlock()
|
||||||
|
|
||||||
if idx >= readyMax+hlsSeekAhead || idx < segStart {
|
// Copy mode never seek-restarts: ffmpeg outruns playback (I/O-bound), the
|
||||||
|
// playlist only lists fully-written segments (temp_file), and segment
|
||||||
|
// indices don't map to uniform 2s slots anyway. Just wait for the writer.
|
||||||
|
if !s.cfg.VideoCopy && (idx >= readyMax+hlsSeekAhead || idx < segStart) {
|
||||||
if err := s.restartFromSegment(idx); err != nil {
|
if err := s.restartFromSegment(idx); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||||
return
|
return
|
||||||
|
|
@ -1208,6 +1304,12 @@ func (s *HLSSession) ServeSegment(w http.ResponseWriter, r *http.Request, idx in
|
||||||
// `-ss` offset corresponds to segment `targetIdx`. The caller must NOT hold
|
// `-ss` offset corresponds to segment `targetIdx`. The caller must NOT hold
|
||||||
// s.mu when calling — the function takes both s.mu and s.readyMu.
|
// s.mu when calling — the function takes both s.mu and s.readyMu.
|
||||||
func (s *HLSSession) restartFromSegment(targetIdx int) error {
|
func (s *HLSSession) restartFromSegment(targetIdx int) error {
|
||||||
|
if s.cfg.VideoCopy {
|
||||||
|
// Defensive: callers already gate on VideoCopy, but the `-ss
|
||||||
|
// segmentStartSec(N)` math below assumes uniform 2s segments and
|
||||||
|
// would corrupt a copy session's keyframe-cut timeline.
|
||||||
|
return errors.New("hls: seek-restart not supported in copy mode")
|
||||||
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
if s.closed {
|
if s.closed {
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
@ -1783,6 +1885,122 @@ func renderVideoPlaylist(durationSec float64, segCount int) string {
|
||||||
// video variant + every text subtitle as an EXT-X-MEDIA group. Audio is muxed
|
// video variant + every text subtitle as an EXT-X-MEDIA group. Audio is muxed
|
||||||
// into the video segments for the MVP — separate audio renditions can come
|
// into the video segments for the MVP — separate audio renditions can come
|
||||||
// later (they require a second ffmpeg pipeline producing audio-only segments).
|
// later (they require a second ffmpeg pipeline producing audio-only segments).
|
||||||
|
// buildHLSCopyArgs builds the ffmpeg invocation for VideoCopy mode: video
|
||||||
|
// stream copied bit-exact (`-c:v copy`, the segments cut at the source's own
|
||||||
|
// keyframes), audio copied when already AAC or re-encoded to AAC 192k
|
||||||
|
// otherwise, muxed to fMP4 HLS with ffmpeg writing its OWN media playlist
|
||||||
|
// (EVENT while running, ENDLIST on completion) with byte-exact EXTINF
|
||||||
|
// durations. Validated empirically on the incident source (HEVC Main10 +
|
||||||
|
// EAC3 MKV): seg-0 TTFB ~510 ms, valid hvc1+mp4a stream.
|
||||||
|
//
|
||||||
|
// Deliberate differences from the encode path:
|
||||||
|
// - no encoder/preset/bitrate/keyframe flags (nothing is encoded);
|
||||||
|
// - `+temp_file` so segments land atomically (write .tmp → rename) and a
|
||||||
|
// listed segment is always complete on disk;
|
||||||
|
// - playlist type EVENT: the timeline grows as ffmpeg outruns playback
|
||||||
|
// (I/O-bound) and players treat it as live-DVR until ENDLIST.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.SourceURL != "" {
|
||||||
|
args = append(args,
|
||||||
|
"-reconnect", "1",
|
||||||
|
"-reconnect_streamed", "1",
|
||||||
|
"-reconnect_delay_max", "5",
|
||||||
|
"-rw_timeout", "30000000",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
audioIdx := cfg.AudioIndex
|
||||||
|
if audioIdx < 0 {
|
||||||
|
audioIdx = 0
|
||||||
|
for i, a := range probe.AudioTracks {
|
||||||
|
if a.Default {
|
||||||
|
audioIdx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n := len(probe.AudioTracks); n > 0 && audioIdx >= n {
|
||||||
|
log.Printf("[hls %s] audioIndex %d out of range (%d audio track(s)) — using 0:a:0",
|
||||||
|
shortHLSID(cfg.SessionID), audioIdx, n)
|
||||||
|
audioIdx = 0
|
||||||
|
}
|
||||||
|
args = append(args, "-map", fmt.Sprintf("0:a:%d?", audioIdx))
|
||||||
|
|
||||||
|
// Video: bit-exact copy. HEVC needs the hvc1 tag or Safari/Apple refuses
|
||||||
|
// the track (mkv extracts default to hev1).
|
||||||
|
args = append(args, "-c:v", "copy")
|
||||||
|
if strings.EqualFold(probe.VideoCodec, "hevc") {
|
||||||
|
args = append(args, "-tag:v", "hvc1")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio: copy when the SELECTED track is already AAC, else AAC 192k.
|
||||||
|
// (fMP4 HLS carries AAC universally; EAC3/DTS/TrueHD do not.)
|
||||||
|
audioCodec := probe.AudioCodec
|
||||||
|
if audioIdx < len(probe.AudioTracks) {
|
||||||
|
audioCodec = probe.AudioTracks[audioIdx].Codec
|
||||||
|
}
|
||||||
|
if strings.EqualFold(audioCodec, "aac") {
|
||||||
|
args = append(args, "-c:a", "copy")
|
||||||
|
} else {
|
||||||
|
args = append(args, "-c:a", "aac", "-b:a", "192k")
|
||||||
|
}
|
||||||
|
|
||||||
|
args = append(args,
|
||||||
|
"-f", "hls",
|
||||||
|
"-hls_time", strconv.Itoa(hlsSegmentDuration),
|
||||||
|
"-hls_playlist_type", "event",
|
||||||
|
"-hls_segment_type", "fmp4",
|
||||||
|
"-hls_list_size", "0",
|
||||||
|
"-hls_flags", "independent_segments+temp_file",
|
||||||
|
"-hls_fmp4_init_filename", "init.mp4",
|
||||||
|
"-hls_segment_filename", filepath.Join(tmpDir, "video", "seg-%d.m4s"),
|
||||||
|
filepath.Join(tmpDir, "video", copyPlaylistName),
|
||||||
|
)
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderMasterPlaylistCopy builds the master playlist for VideoCopy mode.
|
||||||
|
// Unlike the encode master it deliberately OMITS the CODECS attribute: the
|
||||||
|
// stream carries the source's codec verbatim (hvc1/avc1/av01 at whatever
|
||||||
|
// profile/level the file has) and a wrong hardcoded string makes iOS reject
|
||||||
|
// the variant outright, while omission is legal and universally tolerated.
|
||||||
|
// Resolution/bandwidth are the source's real values (best-effort).
|
||||||
|
func renderMasterPlaylistCopy(probe *StreamProbe) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("#EXTM3U\n")
|
||||||
|
b.WriteString("#EXT-X-VERSION:7\n")
|
||||||
|
// BANDWIDTH is advisory (single variant, no ABR) — a height-based
|
||||||
|
// estimate of typical source bitrates is plenty.
|
||||||
|
bw := 8_000_000
|
||||||
|
switch {
|
||||||
|
case probe.Height >= 2000:
|
||||||
|
bw = 25_000_000
|
||||||
|
case probe.Height >= 1000:
|
||||||
|
bw = 10_000_000
|
||||||
|
case probe.Height >= 700:
|
||||||
|
bw = 5_000_000
|
||||||
|
}
|
||||||
|
b.WriteString(fmt.Sprintf("#EXT-X-STREAM-INF:BANDWIDTH=%d,RESOLUTION=%dx%d\n", bw, probe.Width, probe.Height))
|
||||||
|
b.WriteString("video/index.m3u8\n")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
func renderMasterPlaylist(probe *StreamProbe, qualityLabel string) string {
|
func renderMasterPlaylist(probe *StreamProbe, qualityLabel string) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString("#EXTM3U\n")
|
b.WriteString("#EXTM3U\n")
|
||||||
|
|
|
||||||
262
internal/engine/hls_copy_smoke_test.go
Normal file
262
internal/engine/hls_copy_smoke_test.go
Normal file
|
|
@ -0,0 +1,262 @@
|
||||||
|
//go:build smoke
|
||||||
|
|
||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HLS-copy integration suite — real ffmpeg, synthetic sources replicating
|
||||||
|
// every shape that broke the progressive-remux path in production:
|
||||||
|
//
|
||||||
|
// h264+aac mkv → video copy + audio copy
|
||||||
|
// h264+ac3 mkv → video copy + audio re-encode (the priming-dts class
|
||||||
|
// that needed delay_moov on the old remux)
|
||||||
|
// hevc10+eac3 mkv → the exact "Hoppers" incident shape (Main10, hvc1 tag)
|
||||||
|
// resume (-ss) → StartSec mid-file, timeline offset
|
||||||
|
//
|
||||||
|
// Asserts on every run: ffmpeg's playlist reaches ENDLIST, EXTINF sum ≈
|
||||||
|
// source duration, every listed segment exists non-empty, ffprobe decodes
|
||||||
|
// the served playlist with the EXPECTED codecs, and the video stream was
|
||||||
|
// NOT re-encoded (copy must preserve the source codec).
|
||||||
|
//
|
||||||
|
// go test -tags=smoke -run TestHLSCopy -v ./internal/engine/
|
||||||
|
func copyTestRuntime(t *testing.T) TranscodeRuntime {
|
||||||
|
t.Helper()
|
||||||
|
ffmpeg, err := exec.LookPath("ffmpeg")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("ffmpeg not on PATH: %v", err)
|
||||||
|
}
|
||||||
|
ffprobe, err := exec.LookPath("ffprobe")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("ffprobe not on PATH: %v", err)
|
||||||
|
}
|
||||||
|
return TranscodeRuntime{FFmpegPath: ffmpeg, FFprobePath: ffprobe}
|
||||||
|
}
|
||||||
|
|
||||||
|
// genSource synthesises a test file. encV/encA are the SOURCE encoders; skip
|
||||||
|
// the test when the local ffmpeg lacks them (libx265 is optional in some
|
||||||
|
// builds).
|
||||||
|
func genSource(t *testing.T, rt TranscodeRuntime, name string, vArgs, aArgs []string, durSec int) string {
|
||||||
|
t.Helper()
|
||||||
|
out := filepath.Join(t.TempDir(), name)
|
||||||
|
args := []string{
|
||||||
|
"-y", "-loglevel", "error",
|
||||||
|
"-f", "lavfi", "-i", fmt.Sprintf("testsrc2=duration=%d:size=640x360:rate=30", durSec),
|
||||||
|
"-f", "lavfi", "-i", fmt.Sprintf("sine=frequency=440:duration=%d", durSec),
|
||||||
|
}
|
||||||
|
args = append(args, vArgs...)
|
||||||
|
args = append(args, aArgs...)
|
||||||
|
// Short GOP so the copy cuts several segments even on a short source.
|
||||||
|
args = append(args, "-g", "60", "-keyint_min", "60", out)
|
||||||
|
if outB, err := exec.Command(rt.FFmpegPath, args...).CombinedOutput(); err != nil {
|
||||||
|
if strings.Contains(string(outB), "Unknown encoder") {
|
||||||
|
t.Skipf("source encoder unavailable: %s", string(outB))
|
||||||
|
}
|
||||||
|
t.Fatalf("generate %s: %v\n%s", name, err, outB)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// runCopySession starts a VideoCopy session and waits for ffmpeg's playlist
|
||||||
|
// to reach ENDLIST. Returns the session and the final playlist text.
|
||||||
|
func runCopySession(t *testing.T, rt TranscodeRuntime, source string, startSec float64) (*HLSSession, string) {
|
||||||
|
t.Helper()
|
||||||
|
s, err := StartHLSSession(context.Background(), HLSSessionConfig{
|
||||||
|
SessionID: "copytest" + strconv.FormatInt(time.Now().UnixNano()%1_000_000, 10),
|
||||||
|
SourcePath: source,
|
||||||
|
FileName: filepath.Base(source),
|
||||||
|
AudioIndex: -1,
|
||||||
|
StartSec: startSec,
|
||||||
|
VideoCopy: true,
|
||||||
|
Transcode: rt,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StartHLSSession(copy): %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = s.Close() })
|
||||||
|
|
||||||
|
playlistPath := filepath.Join(s.tmpDir, "video", copyPlaylistName)
|
||||||
|
deadline := time.Now().Add(30 * time.Second)
|
||||||
|
for {
|
||||||
|
data, err := os.ReadFile(playlistPath)
|
||||||
|
if err == nil && strings.Contains(string(data), "#EXT-X-ENDLIST") {
|
||||||
|
return s, string(data)
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("playlist never reached ENDLIST; last read err=%v contents:\n%s", err, string(data))
|
||||||
|
}
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertCopyOutput validates playlist structure, segment files, and (via
|
||||||
|
// ffprobe over the playlist) that the served stream carries the expected
|
||||||
|
// codecs — wantVideo MUST equal the source codec, proving no re-encode.
|
||||||
|
func assertCopyOutput(t *testing.T, rt TranscodeRuntime, s *HLSSession, playlist, wantVideo, wantAudio string, wantDur float64) {
|
||||||
|
t.Helper()
|
||||||
|
if !strings.Contains(playlist, "#EXT-X-PLAYLIST-TYPE:EVENT") {
|
||||||
|
t.Errorf("playlist missing EVENT type:\n%s", playlist)
|
||||||
|
}
|
||||||
|
if !strings.Contains(playlist, `#EXT-X-MAP:URI="init.mp4"`) {
|
||||||
|
t.Errorf("playlist missing EXT-X-MAP init.mp4")
|
||||||
|
}
|
||||||
|
|
||||||
|
var sum float64
|
||||||
|
segs := 0
|
||||||
|
for _, line := range strings.Split(playlist, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(line, "#EXTINF:") {
|
||||||
|
v := strings.TrimSuffix(strings.TrimPrefix(line, "#EXTINF:"), ",")
|
||||||
|
d, err := strconv.ParseFloat(v, 64)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("bad EXTINF %q: %v", line, err)
|
||||||
|
}
|
||||||
|
sum += d
|
||||||
|
} else if strings.HasSuffix(line, ".m4s") {
|
||||||
|
segs++
|
||||||
|
fi, err := os.Stat(filepath.Join(s.tmpDir, "video", line))
|
||||||
|
if err != nil || fi.Size() == 0 {
|
||||||
|
t.Errorf("listed segment %s missing/empty: %v", line, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if segs == 0 {
|
||||||
|
t.Fatalf("no segments listed:\n%s", playlist)
|
||||||
|
}
|
||||||
|
if sum < wantDur-1.5 || sum > wantDur+1.5 {
|
||||||
|
t.Errorf("EXTINF sum = %.2fs, want ≈%.2fs (±1.5)", sum, wantDur)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ffprobe over the playlist = a real demuxer consuming init + segments.
|
||||||
|
out, err := exec.Command(rt.FFprobePath, "-v", "error",
|
||||||
|
"-show_entries", "stream=codec_type,codec_name",
|
||||||
|
"-of", "csv=p=0",
|
||||||
|
filepath.Join(s.tmpDir, "video", copyPlaylistName)).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ffprobe playlist: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
probeStr := string(out)
|
||||||
|
if !strings.Contains(probeStr, wantVideo+",video") && !strings.Contains(probeStr, "video,"+wantVideo) &&
|
||||||
|
!strings.Contains(probeStr, wantVideo) {
|
||||||
|
t.Errorf("video codec: probe=%q want %q (copy must NOT re-encode)", probeStr, wantVideo)
|
||||||
|
}
|
||||||
|
if !strings.Contains(probeStr, wantAudio) {
|
||||||
|
t.Errorf("audio codec: probe=%q want %q", probeStr, wantAudio)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHLSCopy_H264AacCopyBoth(t *testing.T) {
|
||||||
|
rt := copyTestRuntime(t)
|
||||||
|
src := genSource(t, rt, "h264aac.mkv",
|
||||||
|
[]string{"-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p"},
|
||||||
|
[]string{"-c:a", "aac", "-b:a", "128k"}, 8)
|
||||||
|
s, pl := runCopySession(t, rt, src, 0)
|
||||||
|
assertCopyOutput(t, rt, s, pl, "h264", "aac", 8)
|
||||||
|
// Audio already AAC → the args must COPY it, not re-encode.
|
||||||
|
args := buildHLSCopyArgs(s.cfg, s.probe, s.tmpDir)
|
||||||
|
if !containsSeq(args, "-c:a", "copy") {
|
||||||
|
t.Errorf("expected -c:a copy for AAC source, args: %v", args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHLSCopy_H264Ac3TranscodesAudio(t *testing.T) {
|
||||||
|
rt := copyTestRuntime(t)
|
||||||
|
src := genSource(t, rt, "h264ac3.mkv",
|
||||||
|
[]string{"-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p"},
|
||||||
|
[]string{"-c:a", "ac3", "-b:a", "192k"}, 8)
|
||||||
|
s, pl := runCopySession(t, rt, src, 0)
|
||||||
|
// The re-encoded AAC track starts with a priming dts — the exact shape
|
||||||
|
// that produced a malformed init on the old progressive remux. The HLS
|
||||||
|
// muxer must land a probe-clean stream regardless.
|
||||||
|
assertCopyOutput(t, rt, s, pl, "h264", "aac", 8)
|
||||||
|
args := buildHLSCopyArgs(s.cfg, s.probe, s.tmpDir)
|
||||||
|
if !containsSeq(args, "-c:a", "aac") {
|
||||||
|
t.Errorf("expected -c:a aac for AC3 source, args: %v", args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHLSCopy_Hevc10Eac3_IncidentShape(t *testing.T) {
|
||||||
|
rt := copyTestRuntime(t)
|
||||||
|
src := genSource(t, rt, "hevc10eac3.mkv",
|
||||||
|
[]string{"-c:v", "libx265", "-preset", "ultrafast", "-pix_fmt", "yuv420p10le", "-x265-params", "log-level=error"},
|
||||||
|
[]string{"-c:a", "eac3", "-b:a", "192k"}, 8)
|
||||||
|
s, pl := runCopySession(t, rt, src, 0)
|
||||||
|
assertCopyOutput(t, rt, s, pl, "hevc", "aac", 8)
|
||||||
|
// HEVC must carry the hvc1 tag or Safari refuses the track.
|
||||||
|
args := buildHLSCopyArgs(s.cfg, s.probe, s.tmpDir)
|
||||||
|
if !containsSeq(args, "-tag:v", "hvc1") {
|
||||||
|
t.Errorf("expected -tag:v hvc1 for HEVC source, args: %v", args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHLSCopy_ResumeStartSec(t *testing.T) {
|
||||||
|
rt := copyTestRuntime(t)
|
||||||
|
src := genSource(t, rt, "resume.mkv",
|
||||||
|
[]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).
|
||||||
|
var sum float64
|
||||||
|
for _, line := range strings.Split(pl, "\n") {
|
||||||
|
if strings.HasPrefix(line, "#EXTINF:") {
|
||||||
|
v := strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(line), "#EXTINF:"), ",")
|
||||||
|
d, _ := strconv.ParseFloat(v, 64)
|
||||||
|
sum += d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sum < 4 || sum > 9 {
|
||||||
|
t.Errorf("resume EXTINF sum = %.2fs, want ≈6s (12s source, -ss 6, ±GOP)", sum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHLSCopy_ServeVideoPlaylistFromDisk(t *testing.T) {
|
||||||
|
rt := copyTestRuntime(t)
|
||||||
|
src := genSource(t, rt, "serve.mkv",
|
||||||
|
[]string{"-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p"},
|
||||||
|
[]string{"-c:a", "aac", "-b:a", "128k"}, 6)
|
||||||
|
s, _ := runCopySession(t, rt, src, 0)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest("GET", "/hls/x/video/index.m3u8", nil)
|
||||||
|
s.ServeVideoPlaylist(rec, req)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("ServeVideoPlaylist = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
body := rec.Body.String()
|
||||||
|
if !strings.Contains(body, "#EXT-X-ENDLIST") || !strings.Contains(body, "seg-0.m4s") {
|
||||||
|
t.Errorf("served playlist incomplete:\n%s", body)
|
||||||
|
}
|
||||||
|
if ct := rec.Header().Get("Content-Type"); ct != "application/vnd.apple.mpegurl" {
|
||||||
|
t.Errorf("Content-Type = %q", ct)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Master: no CODECS attr (a wrong hardcoded string makes iOS reject the
|
||||||
|
// variant; omission is legal), real resolution present.
|
||||||
|
master := s.MasterPlaylist()
|
||||||
|
if strings.Contains(master, "CODECS") {
|
||||||
|
t.Errorf("copy master must omit CODECS:\n%s", master)
|
||||||
|
}
|
||||||
|
if !strings.Contains(master, "RESOLUTION=640x360") {
|
||||||
|
t.Errorf("copy master missing real resolution:\n%s", master)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsSeq(args []string, a, b string) bool {
|
||||||
|
for i := 0; i < len(args)-1; i++ {
|
||||||
|
if args[i] == a && args[i+1] == b {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue