Add real ffmpeg-backed transcoding subsystem (bounded concurrency, streaming, HLS segmenting)
This commit is contained in:
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user