transcode

package
v0.96.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 15, 2026 License: MIT Imports: 17 Imported by: 0

Documentation

Overview

Package transcode probes the host environment for hardware-accelerated video encoding/decoding options and exposes a runtime capability matrix.

Design goal: portability — swap GPUs, drop GPU entirely, install a different driver, and the probe re-evaluates. Never hard-code encoder names in handlers.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func ParseSegIndex

func ParseSegIndex(name string) (int, bool)

ParseSegIndex is the exported form of parseSegName for handlers that need to map a requested segment filename back to its index.

func ResetCachedForTesting

func ResetCachedForTesting()

ResetCachedForTesting zera o cache de capabilities. Só para testes que precisam exercitar o caminho "caps ainda não probadas".

func Run

func Run(ctx context.Context, in io.Reader, w http.ResponseWriter, opts Options) error

Run pipes an input ReadSeeker through ffmpeg with the chosen options and streams to w. On failure, the stderr tail is logged so we can diagnose pipeline issues.

Pre-warm: we read a small chunk (~256 KiB) from the input before invoking ffmpeg, then concatenate that buffer with the rest of the stream. This catches two failure modes early:

  1. anacrolix Reader returns immediately with an error (torrent dropped, reader closed, piece 0 not available). We return 503 with a clear message instead of letting ffmpeg parse-error on EOF.
  2. Source bytes arrive too late and ffmpeg parses corrupt input. Pre-warm means by the time ffmpeg sees byte 0, we already have a valid prefix.

256 KiB is enough to cover MKV/MP4 headers + first cluster on most files, while staying small enough that the warm-up doesn't add noticeable latency to the user-visible "Loading..." spinner.

func SetCachedForTesting

func SetCachedForTesting(c *Capabilities)

SetCachedForTesting injeta uma matriz de capabilities fake (ex.: um script stub no lugar do ffmpeg) para testes — inclusive de OUTROS pacotes — que precisam iniciar sessões HLS sem probar o host. Parear com ResetCachedForTesting no cleanup.

Types

type Capabilities

type Capabilities struct {
	ProbedAt    time.Time `json:"probedAt"`
	OS          string    `json:"os"`
	FFmpegPath  string    `json:"ffmpegPath"`
	FFmpegVer   string    `json:"ffmpegVersion"`
	HasNVIDIA   bool      `json:"hasNvidia"`
	HasVAAPI    bool      `json:"hasVaapi"`
	HasQSV      bool      `json:"hasQsv"`
	Encoders    []Encoder `json:"encoders"`
	Decoders    []Decoder `json:"decoders"`
	Preferred   string    `json:"preferred"`     // chosen encoder ID for H.264 transcoding
	PreferredHE string    `json:"preferredHevc"` // chosen for HEVC transcoding (if any)
}

Capabilities is the full probe result.

func Cached

func Cached() *Capabilities

Cached returns the last probe result without re-running it. nil if never probed.

func Probe

func Probe(ctx context.Context, force bool) (*Capabilities, error)

Probe runs the full detection + smoke-test sequence and caches the result. Pass force=true to re-probe (e.g. after a GPU upgrade).

func (*Capabilities) String

func (c *Capabilities) String() string

String returns a one-line summary suitable for logs.

type Decoder

type Decoder struct {
	ID         string `json:"id"`
	Codec      string `json:"codec"`
	Backend    string `json:"backend"`
	Available  bool   `json:"available"`
	Functional bool   `json:"functional"`
	Error      string `json:"error,omitempty"`
}

Decoder is the same shape but for decoding.

type Encoder

type Encoder struct {
	ID          string  `json:"id"`                 // stable identifier (e.g. "h264_nvenc")
	Codec       string  `json:"codec"`              // "h264" | "hevc"
	Backend     string  `json:"backend"`            // "nvidia" | "amd-vaapi" | "intel-qsv" | "cpu"
	Available   bool    `json:"available"`          // listed by ffmpeg as compiled in
	Functional  bool    `json:"functional"`         // smoke-test encode succeeded
	BenchFPS    float64 `json:"benchFps,omitempty"` // frames/sec on 480p test clip
	Description string  `json:"description"`
	Error       string  `json:"error,omitempty"`
}

Encoder identifies one transcoding backend.

type HLSSession

type HLSSession struct {
	Key        string
	Dir        string
	Cmd        *exec.Cmd
	Cancel     context.CancelFunc
	StartedAt  time.Time
	LastAccess time.Time
	// DurationSec is the total media duration, probed (seekably) once at
	// startup. 0 means "unknown" — the source's moov/Cues weren't reachable
	// in time, so callers must fall back to the live/EVENT playlist instead
	// of generating a finite VOD playlist.
	DurationSec float64
	// contains filtered or unexported fields
}

VODMode is defined in hls_vod.go. HLSSession is a single ongoing HLS transcode. Same key = same session (deduped across concurrent requests for the same content).

func (*HLSSession) EnsureSegment

func (s *HLSSession) EnsureSegment(idx int)

EnsureSegment makes sure an encoder is (or will soon be) producing segment `idx`. The segment handler calls this when `idx` isn't on disk yet. It only restarts the encoder for a real seek: backward (idx < startSeg — the encoder already passed it and won't return) or a far-forward jump (beyond the read-ahead window). Everything in between is normal buffering — the running sequential encoder will reach it, so we let the caller wait.

func (*HLSSession) IsVOD

func (s *HLSSession) IsVOD() bool

IsVOD reports whether this session serves a finite VOD playlist (full seekbar) vs the incremental EVENT/live playlist. Decided once at start from the VOD policy + known duration; handler and encoder read this single flag.

func (*HLSSession) RestartAt

func (s *HLSSession) RestartAt(seg int) error

RestartAt relaunches ffmpeg to begin producing at segment `seg`. Only meaningful in VOD mode. The decision of WHETHER to restart lives in EnsureSegment; this just performs it, serialised so concurrent segment requests can't spawn duplicate encoders. No-op when already encoding from `seg`. Older segments on disk are kept so backward seeks reuse them.

func (*HLSSession) WaitForMaster

func (s *HLSSession) WaitForMaster(timeout time.Duration) error

WaitForMaster blocks up to `timeout` waiting for the master `index.m3u8` to appear. ffmpeg only writes the playlist after the first segment is completely encoded, so the wait is bounded by `-hls_time 4` plus encoder startup. We bail if the session ends without writing one.

func (*HLSSession) WaitForSegment

func (s *HLSSession) WaitForSegment(name string, timeout time.Duration) (string, error)

WaitForSegment blocks for the named segment file (basename only) to exist and be fully written. ffmpeg's `temp_file` flag means segments appear atomically — once `seg_NNN.ts` is visible, it's complete.

type HLSSessionManager

type HLSSessionManager struct {
	// contains filtered or unexported fields
}

HLSSessionManager owns the lifecycle of ffmpeg-driven HLS transcoding sessions. One session per (info_hash, file_index, encoder options) tuple — concurrent viewers of the same content share a single ffmpeg + segments dir.

Why HLS specifically: Safari (macOS + iOS) refuses progressive fragmented MP4 via <video src> with chunked transfer encoding. Empirically every combination of -movflags, profile, level, GOP, B-frame count we tried produced bytes Safari rejects with MediaError.SRC_NOT_SUPPORTED before any frames decode. Apple's documented streaming path is HLS (.m3u8 + .ts segments) — `<video src="...m3u8">` is the only thing Safari treats as a first-class video source. Jellyfin / Plex / Emby all do this for browser clients. Stop trial-and-error, follow Apple's contract.

Trade-off vs progressive MP4: needs disk space for ~2-segment-buffer per active session (~20-40 MB at 720p, ~80 MB at 1080p) and a small directory per session. ffmpeg writes segments to disk as it encodes; the handler serves them on request. Cleanup on idle keeps the footprint bounded.

func NewHLSManager

func NewHLSManager(baseDir string) (*HLSSessionManager, error)

NewHLSManager constructs a manager rooted at baseDir/hls/. The directory is created on demand; existing contents (from previous server runs) are purged to avoid serving stale segments tied to old encoder options.

func (*HLSSessionManager) Close

func (m *HLSSessionManager) Close(key string)

Close terminates the session immediately. Called by handlers when the underlying torrent is dropped or the user explicitly cancels.

func (*HLSSessionManager) CloseForHash

func (m *HLSSessionManager) CloseForHash(hashHex string)

CloseForHash para TODAS as sessões HLS de um torrent (keys "<hash>-<fileIdx>"). Chamado quando o player fecha (Drop) pra não deixar o ffmpeg do transcode órfão consumindo CPU até o idle-reaper (5min). Idempotente; no-op se não houver.

func (*HLSSessionManager) EffectiveKey

func (m *HLSSessionManager) EffectiveKey(rawKey string, nativeHLS bool) string

EffectiveKey maps a raw content key to the session key actually used. When VOD is off the key is unchanged (one shared EVENT session per content, zero behaviour change). When VOD is on, VOD-eligible and non-eligible clients are split into distinct sessions (-vod/-evt) so a VOD session created by one client never serves a VOD playlist to a client that must stay on EVENT (the Safari #61 safeguard). Master and segment handlers must agree on this.

func (*HLSSessionManager) GetOrStart

func (m *HLSSessionManager) GetOrStart(ctx context.Context, opts HLSStartOpts) (*HLSSession, error)

Start/build de sessão HLS (GetOrStart, buildSession, reserva de GPU) — extraído de hls.go. GetOrStart returns an existing session keyed by opts.Key or starts a new one. On new-session, ffmpeg begins encoding immediately; the caller should poll for index.m3u8 to appear via WaitForMaster.

Ownership: GetOrStart takes opts.Source. A new session keeps it (and closes it on stop); when an existing session is returned or creation fails, the source is closed here (io.Closer sources only). Callers must NOT close it.

func (*HLSSessionManager) Peek

func (m *HLSSessionManager) Peek(key string) (*HLSSession, error)

Peek returns an existing session without starting one. Used by the segment handler which must NOT race the playlist handler into creating a duplicate ffmpeg. Returns an error when the session isn't tracked.

func (*HLSSessionManager) Sessions

func (m *HLSSessionManager) Sessions() []HLSSessionSnapshot

Sessions returns all currently active transcode sessions in the manager.

func (*HLSSessionManager) SetGPUTranscodeLimit

func (m *HLSSessionManager) SetGPUTranscodeLimit(limit int)

SetGPUTranscodeLimit overrides the concurrent HW-decode cap (tests; or a future live-config path). limit <= 0 means unlimited.

func (*HLSSessionManager) SetVODMode

func (m *HLSSessionManager) SetVODMode(mode VODMode)

SetVODMode sets the VOD policy (called once at wiring time from config).

func (*HLSSessionManager) Stop

func (m *HLSSessionManager) Stop()

Stop reaps every live session (kills ffmpeg, closes its loopback server and removes its segment dir) and halts gcLoop. Called on graceful shutdown so no encoder is left orphaned writing into the cache. Idempotent.

type HLSSessionSnapshot

type HLSSessionSnapshot struct {
	Key           string    `json:"key"`
	Codec         string    `json:"codec"`
	SegmentsReady int       `json:"segmentsReady"`
	StartedAt     time.Time `json:"startedAt"`
	LastActivity  time.Time `json:"lastActivity"`
	Pid           int       `json:"pid"`
}

HLSSessionSnapshot is a read-only representation of an active transcode session.

type HLSStartOpts

type HLSStartOpts struct {
	Key                 string        // raw content key, e.g. `${hash}-${fileIdx}`; EffectiveKey may add a mode suffix
	Source              io.ReadSeeker // seekable input — wrapped by an internal HTTP server
	SourceSize          int64         // total size hint; required when the underlying reader lies about EOF
	VideoCodec          string        // "h264_nvenc" | "libx264" | etc.
	PreserveSourceAudio bool          // when true and source audio is AAC, -c:a copy; else transcode to AAC
	// NativeHLS marks a Safari/iOS client (native HLS). Combined with the VOD
	// policy it decides whether this session uses the finite-VOD path.
	NativeHLS bool
	// KnownDurationSec lets the caller supply a duration it already probed (the
	// local-file path runs ffprobe at play time). >0 skips the in-session 30s
	// seekable probe — the rclone/Drive latency win.
	KnownDurationSec float64
	// ForceVOD opts this session into the finite-VOD (seekbar) path whenever the
	// duration is known, BYPASSING the per-client vodMode gate. Used by the
	// local-file path: a fully-downloaded file on disk/rclone is complete and
	// seekable, so EVENT/live (the last-resort path for unknown-duration
	// streams) is wrong for it — VOD is the correct default per the playback
	// premise. Torrents leave this false so the global vodMode still guards the
	// #61 Safari seek instability on (incomplete) torrent sources.
	ForceVOD bool
	// AudioOnly transcodes a pure-audio source (FLAC/OGG/Opus/ALAC/WMA/…) to an
	// AAC HLS stream with NO video map (`-vn`). The local-file path sets it for
	// codecs the target browser can't direct-play (Safari refuses FLAC/OGG/Opus),
	// since the video pipeline's unconditional `-map 0:v:0` would fail on a file
	// with no video stream.
	AudioOnly bool
	// AudioTrack é o índice ABSOLUTO da faixa de áudio a mapear no vídeo (>0 =
	// escolhida; <=0 = primeira/default). A sessão é keyed pela faixa (ver
	// hlsSessionKey) pra que trocar o áudio gere um transcode novo, não reuse o cache.
	AudioTrack int
	// Variant é a rung do ladder ABR que esta sessão codifica (HLS master, Phase
	// 2). O zero-value (Height 0) é o caminho single-variant legado (cap 1080p,
	// L5.2, sem cap de bitrate). A variante entra na session key (hlsSessionKey
	// `-vN`) → Dir/segmentos próprios por rung, então só uma toca por vez e o
	// seek-restart funciona idêntico ao atual, sem coordenação entre variantes.
	Variant Variant
}

HLSStartOpts groups what's needed to spin up a session. The source is the torrent file source; the manager doesn't know about anacrolix specifically.

IMPORTANT: Source MUST implement io.ReadSeeker. We expose it to ffmpeg via an ephemeral loopback HTTP server (one per session) so ffmpeg can issue Range requests and seek freely. Direct pipe-to-stdin (the previous design) fails on MP4 with `moov` at end of file because pipe input is non-seekable — ffmpeg can't walk past a multi-GB mdat box to read the metadata.

type Options

type Options struct {
	AudioTrack   int    // absolute stream index for `-map 0:<n>` (-1 = first audio)
	SubBurnTrack int    // -1 = none; otherwise absolute stream index for hardsub burn-in
	VideoCodec   string // "" = copy; "h264" / "hevc" = transcode video
	AudioCodec   string // "" = copy; "aac" = transcode audio
	Container    string // "mp4" | "matroska" | "webm" — default "mp4"
	SourceVCodec string // optional hint about source video codec (for hwaccel selection)
}

Options describes how to transcode one stream segment. All fields are optional — empty means "keep original / passthrough".

type VODMode

type VODMode int

VODMode gates the finite-VOD (seekbar) HLS path, by client class. See StreamConfig.HLSVODMode. The zero value is VODOff (current/safe behaviour).

const (
	VODOff   VODMode = iota // EVENT/live for everyone (no seekbar)
	VODHLSJS                // VOD for hls.js clients (non-Safari); Safari stays EVENT
	VODAll                  // VOD for everyone, including Safari native HLS
)

func ParseVODMode

func ParseVODMode(s string) VODMode

ParseVODMode maps the config/env string to a VODMode (default VODOff).

type Variant added in v0.92.0

type Variant struct {
	Height    int
	VBitrateK int
	Level     int
}

Variant is one rung of the HLS ABR ladder (multi-resolution master, Phase 2). Height caps the scale (never upscales); VBitrateK is the video -maxrate in kbit/s; Level is the H.264 level_idc (e.g. 40 = L4.0, 31 = L3.1) fed both to ffmpeg (-level:v) and advertised in the master's CODECS — so the browser's pre-download compatibility check (Safari/hls.js) matches the actual bitstream.

Height == 0 is the LEGACY single-variant sentinel: default cap 1080p, level 5.2, no explicit bitrate cap — byte-for-byte the pre-Phase-2 behaviour. It is only produced when the source height is unknown and is never placed in a master (a master is built solely for a ladder of ≥2 variants).

func VariantLadder added in v0.92.0

func VariantLadder(srcHeight int) []Variant

VariantLadder is the exported entry point handlers use to turn a probed source height into the ABR ladder (the master builder + variant/segment handlers live in the handlers package). See variantLadder.

func (Variant) Bandwidth added in v0.92.0

func (v Variant) Bandwidth() int

Bandwidth is the EXT-X-STREAM-INF BANDWIDTH (peak bits/s): video cap + AAC (~192k) + ~10% container/overhead. Deterministic so ABR selection is stable.

func (Variant) Codecs added in v0.92.0

func (v Variant) Codecs() string

Codecs is the RFC 6381 CODECS attribute for the master's EXT-X-STREAM-INF: H.264 Main profile (0x4d) + constraint flags (0x40) + this level, plus AAC-LC (mp4a.40.2). Matching the advertised level to the encoded -level:v is what keeps a low-end device from skipping a rung it could actually decode.

func (Variant) IsDefault added in v0.92.0

func (v Variant) IsDefault() bool

IsDefault reports the legacy single-variant sentinel (unknown source height).

func (Variant) LevelStr added in v0.92.0

func (v Variant) LevelStr() string

LevelStr renders the H.264 level for ffmpeg's -level:v (e.g. 40 → "4.0").

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL