Add real ffmpeg-backed transcoding subsystem (bounded concurrency, streaming, HLS segmenting)

This commit is contained in:
2026-08-23 00:10:55 -05:00
parent 6e2b875eaa
commit b6ec06f272
3 changed files with 361 additions and 0 deletions
+148
View File
@@ -0,0 +1,148 @@
package transcoding
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
)
// segmentSeconds is the target HLS segment duration.
const segmentSeconds = 10
// HLSSession holds the completed segments for one (source, bitrate)
// combination, cached on disk so repeated segment requests (which every
// HLS client issues, one per segment) don't re-transcode.
type HLSSession struct {
Dir string
SegmentCount int
}
// hlsCache maps a cache key (source path + options) to its completed
// session, so concurrent/repeated requests for the same transcode reuse
// one ffmpeg run instead of racing separate ones.
type hlsCacheEntry struct {
once sync.Once
session *HLSSession
err error
}
var (
hlsCacheMu sync.Mutex
hlsCache = map[string]*hlsCacheEntry{}
)
func hlsCacheKey(sourcePath string, opts Options) string {
return fmt.Sprintf("%s|%d|%s", sourcePath, opts.MaxBitRateKbps, opts.Format)
}
// BuildHLS transcodes and segments sourcePath into cacheDir (once per
// distinct source+options; concurrent callers for the same key share the
// result), returning the session describing the produced .ts segments.
func (m *Manager) BuildHLS(ctx context.Context, sourcePath, cacheDir string, opts Options) (*HLSSession, error) {
key := hlsCacheKey(sourcePath, opts)
hlsCacheMu.Lock()
entry, ok := hlsCache[key]
if !ok {
entry = &hlsCacheEntry{}
hlsCache[key] = entry
}
hlsCacheMu.Unlock()
entry.once.Do(func() {
entry.session, entry.err = m.buildHLSOnce(ctx, sourcePath, cacheDir, opts, key)
})
return entry.session, entry.err
}
func (m *Manager) buildHLSOnce(ctx context.Context, sourcePath, cacheDir string, opts Options, key string) (*HLSSession, error) {
select {
case m.sem <- struct{}{}:
case <-ctx.Done():
return nil, ctx.Err()
}
defer func() { <-m.sem }()
dir := filepath.Join(cacheDir, "hls", sanitizeKey(key))
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
playlistPath := filepath.Join(dir, "index.m3u8")
segmentPattern := filepath.Join(dir, "seg%05d.ts")
bitrate := opts.MaxBitRateKbps
if bitrate <= 0 {
bitrate = defaultBitRateKbps
}
args := []string{
"-i", sourcePath,
"-c:v", "libx264", "-preset", "veryfast", "-c:a", "aac",
"-b:a", fmt.Sprintf("%dk", bitrate),
"-f", "hls",
"-hls_time", strconv.Itoa(segmentSeconds),
"-hls_list_size", "0",
"-hls_segment_filename", segmentPattern,
"--", playlistPath,
}
cmd := exec.CommandContext(ctx, m.FFmpegPath, args...)
if out, err := cmd.CombinedOutput(); err != nil {
return nil, fmt.Errorf("ffmpeg hls: %w: %s", err, out)
}
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
count := 0
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".ts") {
count++
}
}
return &HLSSession{Dir: dir, SegmentCount: count}, nil
}
func sanitizeKey(key string) string {
var b strings.Builder
for _, r := range key {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
b.WriteRune(r)
default:
b.WriteByte('_')
}
}
return b.String()
}
// Playlist returns the m3u8 media playlist text for a completed session,
// with segment URIs built by segmentURL(index).
func (s *HLSSession) Playlist(segmentURL func(index int) string) string {
var b strings.Builder
b.WriteString("#EXTM3U\n")
b.WriteString("#EXT-X-VERSION:3\n")
b.WriteString(fmt.Sprintf("#EXT-X-TARGETDURATION:%d\n", segmentSeconds))
b.WriteString("#EXT-X-PLAYLIST-TYPE:VOD\n")
for i := 0; i < s.SegmentCount; i++ {
b.WriteString(fmt.Sprintf("#EXTINF:%d.0,\n", segmentSeconds))
b.WriteString(segmentURL(i))
b.WriteString("\n")
}
b.WriteString("#EXT-X-ENDLIST\n")
return b.String()
}
// SegmentPath returns the on-disk path for segment index, or "" if out
// of range.
func (s *HLSSession) SegmentPath(index int) string {
if index < 0 || index >= s.SegmentCount {
return ""
}
return filepath.Join(s.Dir, fmt.Sprintf("seg%05d.ts", index))
}