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
|
||||
// transcoding to HLS. See hueco #3 phase 3a in the roadmap.
|
||||
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
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -998,6 +998,7 @@ func runDaemonStart() error {
|
|||
BurnSubtitleIndex: sess.BurnSubtitleIndex,
|
||||
StartSec: sess.StartSec,
|
||||
Prewarm: sess.Prewarm,
|
||||
VideoCopy: sess.VideoCopy,
|
||||
Transcode: tcRuntime,
|
||||
Cache: hlsCache,
|
||||
}, hlsCtx, hlsCancel)
|
||||
|
|
|
|||
|
|
@ -191,8 +191,30 @@ type HLSSessionConfig struct {
|
|||
// of the same file at the same quality skip ffmpeg entirely. nil disables
|
||||
// caching (per-session tmpdir, deleted on Close — original behavior).
|
||||
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
|
||||
// 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.
|
||||
|
|
@ -490,6 +512,12 @@ func StartHLSSession(ctx context.Context, cfg HLSSessionConfig) (*HLSSession, er
|
|||
fromCache 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 {
|
||||
// Debrid URL sessions key by CacheID (info_hash) so re-plays hit cache
|
||||
// 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,
|
||||
liveURL: cfg.SourceURL, // mutable copy; cfg stays immutable
|
||||
}
|
||||
if cfg.VideoCopy {
|
||||
// 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
|
||||
// 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
|
||||
// today's post-seek behaviour.
|
||||
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)
|
||||
if startIdx > segCount-1 {
|
||||
startIdx = segCount - 1
|
||||
|
|
@ -616,7 +657,12 @@ func StartHLSSession(ctx context.Context, cfg HLSSessionConfig) (*HLSSession, er
|
|||
// touching the parent ctx.
|
||||
ffCtx, cancel := context.WithCancel(context.Background())
|
||||
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.Stderr = &hlsStderrCapture{owner: s}
|
||||
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
|
||||
// the user's ffmpeg has no HW encoders compiled in, which is the most
|
||||
// common root cause (linuxbrew, default brew formula on macOS).
|
||||
encoderNote := ""
|
||||
if cfg.VideoCopy {
|
||||
encoderNote = "encoder=copy (no video re-encode)"
|
||||
} 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 := ""
|
||||
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))
|
||||
}
|
||||
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(),
|
||||
probe.DurationSec, segCount, coalesce(cfg.Quality, "auto"),
|
||||
profile.Codec, string(cfg.Transcode.HWAccel), presetNote, cachedNote, startNote)
|
||||
encoderNote, cachedNote, startNote)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
|
|
@ -953,6 +1007,15 @@ func (s *HLSSession) waitFFmpeg() {
|
|||
}
|
||||
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:
|
||||
// - the session was closed externally (kill on quality change etc.)
|
||||
// - 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()
|
||||
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
if s.cfg.VideoCopy {
|
||||
s.serveCopyPlaylist(w, r)
|
||||
return
|
||||
}
|
||||
_, _ = 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.
|
||||
func (s *HLSSession) ServeInit(w http.ResponseWriter, r *http.Request) {
|
||||
s.Touch()
|
||||
|
|
@ -1188,7 +1281,10 @@ func (s *HLSSession) ServeSegment(w http.ResponseWriter, r *http.Request, idx in
|
|||
readyMax := s.readyMax
|
||||
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 {
|
||||
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||
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
|
||||
// s.mu when calling — the function takes both s.mu and s.readyMu.
|
||||
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()
|
||||
if s.closed {
|
||||
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
|
||||
// into the video segments for the MVP — separate audio renditions can come
|
||||
// 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 {
|
||||
var b strings.Builder
|
||||
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