620 lines
14 KiB
Go
620 lines
14 KiB
Go
// backend\transcode.go
|
||
|
||
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"golang.org/x/sync/singleflight"
|
||
)
|
||
|
||
// -------------------------
|
||
// Transcode config / globals
|
||
// -------------------------
|
||
|
||
// max parallel ffmpeg jobs
|
||
var transcodeSem = make(chan struct{}, 2)
|
||
|
||
// de-dupe concurrent requests for same output
|
||
var transcodeSF singleflight.Group
|
||
|
||
type heightCacheEntry struct {
|
||
mtime time.Time
|
||
size int64
|
||
height int
|
||
}
|
||
|
||
var heightCacheMu sync.Mutex
|
||
var heightCache = map[string]heightCacheEntry{}
|
||
|
||
type durationCacheEntry struct {
|
||
mtime time.Time
|
||
size int64
|
||
dur float64
|
||
}
|
||
|
||
var durationCacheMu sync.Mutex
|
||
var durationCache = map[string]durationCacheEntry{}
|
||
|
||
func probeVideoDurationSeconds(ctx context.Context, inPath string) (float64, error) {
|
||
// ffprobe -v error -show_entries format=duration -of csv=p=0 <file>
|
||
cmd := exec.CommandContext(ctx, "ffprobe",
|
||
"-v", "error",
|
||
"-show_entries", "format=duration",
|
||
"-of", "csv=p=0",
|
||
inPath,
|
||
)
|
||
out, err := cmd.Output()
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
s := strings.TrimSpace(string(out))
|
||
if s == "" {
|
||
return 0, fmt.Errorf("ffprobe returned empty duration")
|
||
}
|
||
d, err := strconv.ParseFloat(s, 64)
|
||
if err != nil || d <= 0 {
|
||
return 0, fmt.Errorf("bad duration %q", s)
|
||
}
|
||
return d, nil
|
||
}
|
||
|
||
func getVideoDurationSecondsCached(ctx context.Context, inPath string) (float64, error) {
|
||
fi, err := os.Stat(inPath)
|
||
if err != nil || fi.IsDir() || fi.Size() <= 0 {
|
||
return 0, fmt.Errorf("input not usable")
|
||
}
|
||
|
||
durationCacheMu.Lock()
|
||
if e, ok := durationCache[inPath]; ok {
|
||
if e.size == fi.Size() && e.mtime.Equal(fi.ModTime()) && e.dur > 0 {
|
||
d := e.dur
|
||
durationCacheMu.Unlock()
|
||
return d, nil
|
||
}
|
||
}
|
||
durationCacheMu.Unlock()
|
||
|
||
d, err := probeVideoDurationSeconds(ctx, inPath)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
|
||
durationCacheMu.Lock()
|
||
durationCache[inPath] = durationCacheEntry{mtime: fi.ModTime(), size: fi.Size(), dur: d}
|
||
durationCacheMu.Unlock()
|
||
|
||
return d, nil
|
||
}
|
||
|
||
func probeVideoHeight(ctx context.Context, inPath string) (int, error) {
|
||
// ffprobe -v error -select_streams v:0 -show_entries stream=height -of csv=p=0 <file>
|
||
cmd := exec.CommandContext(ctx, "ffprobe",
|
||
"-v", "error",
|
||
"-select_streams", "v:0",
|
||
"-show_entries", "stream=height",
|
||
"-of", "csv=p=0",
|
||
inPath,
|
||
)
|
||
out, err := cmd.Output()
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
s := strings.TrimSpace(string(out))
|
||
if s == "" {
|
||
return 0, fmt.Errorf("ffprobe returned empty height")
|
||
}
|
||
h, err := strconv.Atoi(s)
|
||
if err != nil || h <= 0 {
|
||
return 0, fmt.Errorf("bad height %q", s)
|
||
}
|
||
return h, nil
|
||
}
|
||
|
||
func getVideoHeightCached(ctx context.Context, inPath string) (int, error) {
|
||
fi, err := os.Stat(inPath)
|
||
if err != nil || fi.IsDir() || fi.Size() <= 0 {
|
||
return 0, fmt.Errorf("input not usable")
|
||
}
|
||
|
||
heightCacheMu.Lock()
|
||
if e, ok := heightCache[inPath]; ok {
|
||
if e.size == fi.Size() && e.mtime.Equal(fi.ModTime()) && e.height > 0 {
|
||
h := e.height
|
||
heightCacheMu.Unlock()
|
||
return h, nil
|
||
}
|
||
}
|
||
heightCacheMu.Unlock()
|
||
|
||
h, err := probeVideoHeight(ctx, inPath)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
|
||
heightCacheMu.Lock()
|
||
heightCache[inPath] = heightCacheEntry{mtime: fi.ModTime(), size: fi.Size(), height: h}
|
||
heightCacheMu.Unlock()
|
||
return h, nil
|
||
}
|
||
|
||
type TranscodeProfile struct {
|
||
Name string // "1080p" | "720p" | "480p"
|
||
Height int
|
||
}
|
||
|
||
func profileFromResolution(res string) (TranscodeProfile, bool) {
|
||
// Stash-like: LOW | MEDIUM | HIGH | ORIGINAL (case-insensitive)
|
||
s := strings.ToUpper(strings.TrimSpace(res))
|
||
switch s {
|
||
case "", "ORIGINAL", "SOURCE", "AUTO":
|
||
return TranscodeProfile{Name: "ORIGINAL", Height: 0}, true
|
||
case "LOW":
|
||
return TranscodeProfile{Name: "LOW", Height: 480}, true
|
||
case "MEDIUM":
|
||
return TranscodeProfile{Name: "MEDIUM", Height: 720}, true
|
||
case "HIGH":
|
||
return TranscodeProfile{Name: "HIGH", Height: 1080}, true
|
||
}
|
||
|
||
// Backwards-Kompatibilität: "<height>p" (z.B. 720p)
|
||
s2 := strings.ToLower(strings.TrimSpace(res))
|
||
if m := regexp.MustCompile(`^(\d{3,4})p$`).FindStringSubmatch(s2); m != nil {
|
||
h, err := strconv.Atoi(m[1])
|
||
if err != nil || h <= 0 {
|
||
return TranscodeProfile{}, false
|
||
}
|
||
if h < 144 || h > 4320 {
|
||
return TranscodeProfile{}, false
|
||
}
|
||
return TranscodeProfile{Name: fmt.Sprintf("%dp", h), Height: h}, true
|
||
}
|
||
|
||
return TranscodeProfile{}, false
|
||
}
|
||
|
||
// Cache layout: <doneAbs>/.transcodes/<canonicalID>/<v>/<quality>/s<start>.mp4
|
||
func transcodeCachePath(doneAbs, canonicalID, quality string, startSec int) string {
|
||
const v = "v2"
|
||
return filepath.Join(doneAbs, ".transcodes", canonicalID, v, quality, fmt.Sprintf("s%d.mp4", startSec))
|
||
}
|
||
|
||
func ensureFFmpegAvailable() error {
|
||
_, err := exec.LookPath("ffmpeg")
|
||
if err != nil {
|
||
return fmt.Errorf("ffmpeg not found in PATH")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func ensureFFprobeAvailable() error {
|
||
_, err := exec.LookPath("ffprobe")
|
||
if err != nil {
|
||
return fmt.Errorf("ffprobe not found in PATH")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func fileUsable(p string) (os.FileInfo, bool) {
|
||
fi, err := os.Stat(p)
|
||
if err != nil {
|
||
return nil, false
|
||
}
|
||
if fi.IsDir() || fi.Size() <= 0 {
|
||
return nil, false
|
||
}
|
||
return fi, true
|
||
}
|
||
|
||
func isCacheFresh(inPath, outPath string) bool {
|
||
inFi, err := os.Stat(inPath)
|
||
if err != nil || inFi.IsDir() || inFi.Size() <= 0 {
|
||
return false
|
||
}
|
||
outFi, ok := fileUsable(outPath)
|
||
if !ok {
|
||
return false
|
||
}
|
||
// if out is not older than input -> ok
|
||
return !outFi.ModTime().Before(inFi.ModTime())
|
||
}
|
||
|
||
func acquireTranscodeSlot(ctx context.Context) error {
|
||
select {
|
||
case transcodeSem <- struct{}{}:
|
||
return nil
|
||
case <-ctx.Done():
|
||
return ctx.Err()
|
||
}
|
||
}
|
||
|
||
func releaseTranscodeSlot() {
|
||
select {
|
||
case <-transcodeSem:
|
||
default:
|
||
}
|
||
}
|
||
|
||
func tailString(s string, max int) string {
|
||
s = strings.TrimSpace(s)
|
||
if len(s) <= max {
|
||
return s
|
||
}
|
||
return s[len(s)-max:]
|
||
}
|
||
|
||
func runFFmpeg(ctx context.Context, args []string) error {
|
||
cmd := exec.CommandContext(ctx, "ffmpeg", args...)
|
||
|
||
var buf bytes.Buffer
|
||
cmd.Stdout = &buf
|
||
cmd.Stderr = &buf
|
||
|
||
err := cmd.Run()
|
||
if err == nil {
|
||
return nil
|
||
}
|
||
|
||
// Wenn ctx abgebrochen wurde (Timeout oder Cancel), gib Output trotzdem mit aus.
|
||
if ctx.Err() != nil {
|
||
return fmt.Errorf("ffmpeg aborted: %v (output=%s)", ctx.Err(), tailString(buf.String(), 4000))
|
||
}
|
||
|
||
return fmt.Errorf("ffmpeg failed: %w (output=%s)", err, tailString(buf.String(), 4000))
|
||
}
|
||
|
||
// -------------------------
|
||
// Public entry used by recordVideo
|
||
// -------------------------
|
||
|
||
// maybeTranscodeForRequest inspects "resolution" query param.
|
||
// If quality is "auto" (or empty), it returns original outPath unchanged.
|
||
// Otherwise it ensures cached transcode exists & is fresh, and returns the cached path.
|
||
func maybeTranscodeForRequest(rctx context.Context, originalPath string, resolution string, startSec int) (string, error) {
|
||
if startSec < 0 {
|
||
startSec = 0
|
||
}
|
||
// optional: auf 2 Sekunden runter runden, passt zu GOP=60 (~2s bei 30fps)
|
||
startSec = (startSec / 2) * 2
|
||
|
||
prof, ok := profileFromResolution(resolution)
|
||
if !ok {
|
||
return "", fmt.Errorf("bad resolution %q", resolution)
|
||
}
|
||
if strings.EqualFold(prof.Name, "ORIGINAL") || prof.Height <= 0 {
|
||
return originalPath, nil
|
||
}
|
||
|
||
// ensure ffmpeg is present
|
||
if err := ensureFFmpegAvailable(); err != nil {
|
||
return "", err
|
||
}
|
||
|
||
needScale := true
|
||
|
||
if prof.Height > 0 {
|
||
if err := ensureFFprobeAvailable(); err == nil {
|
||
pctx, cancel := context.WithTimeout(rctx, 5*time.Second)
|
||
defer cancel()
|
||
|
||
if srcH, err := getVideoHeightCached(pctx, originalPath); err == nil && srcH > 0 {
|
||
// Quelle <= Ziel => kein Downscale nötig
|
||
if srcH <= prof.Height+8 {
|
||
needScale = false
|
||
|
||
// ✅ WICHTIG: wenn startSec==0, liefern wir wirklich Original (keine Cache-Datei bauen)
|
||
if startSec == 0 {
|
||
return originalPath, nil
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Need doneAbs for cache root
|
||
s := getSettings()
|
||
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
||
if err != nil || strings.TrimSpace(doneAbs) == "" {
|
||
return "", fmt.Errorf("doneDir missing or invalid")
|
||
}
|
||
|
||
// canonicalID = basename stem without ext and without "HOT "
|
||
base := filepath.Base(originalPath)
|
||
stem := strings.TrimSuffix(base, filepath.Ext(base))
|
||
canonicalID := stripHotPrefix(stem)
|
||
canonicalID = strings.TrimSpace(canonicalID)
|
||
if canonicalID == "" {
|
||
return "", fmt.Errorf("canonical id empty")
|
||
}
|
||
|
||
qualityKey := strings.ToLower(strings.TrimSpace(prof.Name))
|
||
cacheOut := transcodeCachePath(doneAbs, canonicalID, qualityKey, startSec)
|
||
|
||
// fast path: already exists & fresh
|
||
if isCacheFresh(originalPath, cacheOut) {
|
||
return cacheOut, nil
|
||
}
|
||
|
||
// singleflight key: input + cacheOut
|
||
key := originalPath + "|" + cacheOut
|
||
|
||
_, err, _ = transcodeSF.Do(key, func() (any, error) {
|
||
// check again inside singleflight
|
||
if isCacheFresh(originalPath, cacheOut) {
|
||
return nil, nil
|
||
}
|
||
|
||
// If stale exists, remove (best-effort)
|
||
_ = os.Remove(cacheOut)
|
||
|
||
// ensure dir
|
||
if err := os.MkdirAll(filepath.Dir(cacheOut), 0o755); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// timeout for transcode
|
||
// ✅ NICHT an rctx hängen, sonst killt Client-Abbruch ffmpeg beim Quality-Wechsel
|
||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
|
||
defer cancel()
|
||
|
||
if err := acquireTranscodeSlot(ctx); err != nil {
|
||
return nil, err
|
||
}
|
||
defer releaseTranscodeSlot()
|
||
|
||
// ✅ Temp muss eine "echte" Video-Endung haben, sonst kann ffmpeg das Format nicht wählen
|
||
tmp := cacheOut + ".part.mp4"
|
||
_ = os.Remove(tmp)
|
||
|
||
// ffmpeg args
|
||
var args []string
|
||
if needScale {
|
||
args = buildFFmpegArgs(originalPath, tmp, prof, startSec)
|
||
} else {
|
||
// ✅ nativer Seek: schneiden ohne re-encode
|
||
args = buildFFmpegCopySegmentArgs(originalPath, tmp, startSec)
|
||
}
|
||
|
||
if err := runFFmpeg(ctx, args); err != nil {
|
||
_ = os.Remove(tmp)
|
||
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||
return nil, fmt.Errorf("transcode timeout: %w", err)
|
||
}
|
||
return nil, err
|
||
}
|
||
|
||
// validate tmp
|
||
if _, ok := fileUsable(tmp); !ok {
|
||
_ = os.Remove(tmp)
|
||
return nil, fmt.Errorf("transcode output invalid")
|
||
}
|
||
|
||
// atomic replace
|
||
_ = os.Remove(cacheOut)
|
||
if err := os.Rename(tmp, cacheOut); err != nil {
|
||
_ = os.Remove(tmp)
|
||
return nil, err
|
||
}
|
||
|
||
return nil, nil
|
||
})
|
||
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
// final validate
|
||
if _, ok := fileUsable(cacheOut); !ok {
|
||
return "", fmt.Errorf("transcode cache missing after build")
|
||
}
|
||
|
||
return cacheOut, nil
|
||
}
|
||
|
||
// -------------------------
|
||
// ffmpeg profiles
|
||
// -------------------------
|
||
|
||
func buildFFmpegArgs(inPath, outPath string, prof TranscodeProfile, startSec int) []string {
|
||
// You can tune these defaults:
|
||
// - CRF: lower => better quality, bigger file (1080p ~22, 720p ~23, 480p ~24/25)
|
||
// - preset: veryfast is good for on-demand
|
||
crf := "23"
|
||
h := prof.Height
|
||
switch {
|
||
case h >= 2160:
|
||
crf = "20"
|
||
case h >= 1440:
|
||
crf = "21"
|
||
case h >= 1080:
|
||
crf = "22"
|
||
case h >= 720:
|
||
crf = "23"
|
||
case h >= 480:
|
||
crf = "25"
|
||
case h >= 360:
|
||
crf = "27"
|
||
default:
|
||
crf = "29"
|
||
}
|
||
|
||
// Keyframes: choose a stable value; if you want dynamic based on fps you can extend later.
|
||
gop := "60"
|
||
|
||
// ✅ Für fertige MP4-Dateien: NICHT fragmentieren.
|
||
// faststart reicht, damit "moov" vorne liegt.
|
||
movflags := "+faststart"
|
||
|
||
// scale keeps aspect ratio, ensures even width
|
||
vf := fmt.Sprintf("scale=-2:%d", prof.Height)
|
||
|
||
// sanitize start
|
||
if startSec < 0 {
|
||
startSec = 0
|
||
}
|
||
// optional: align to small buckets to reduce cache fragmentation (and match GOP-ish seeking)
|
||
// startSec = (startSec / 2) * 2
|
||
|
||
args := []string{
|
||
"-hide_banner",
|
||
"-loglevel", "error",
|
||
"-nostdin",
|
||
"-y",
|
||
}
|
||
|
||
// ✅ Startposition: VOR "-i" => schnelles Seek zum nächsten Keyframe (gut für on-demand)
|
||
// (Wenn du frame-genau willst: "-ss" NACH "-i", ist aber deutlich langsamer.)
|
||
if startSec > 0 {
|
||
args = append(args, "-ss", strconv.Itoa(startSec))
|
||
}
|
||
|
||
args = append(args,
|
||
"-i", inPath,
|
||
|
||
// ✅ robust: falls Audio fehlt, trotzdem kein Fehler
|
||
"-map", "0:v:0?",
|
||
"-map", "0:a:0?",
|
||
"-sn",
|
||
|
||
"-vf", vf,
|
||
|
||
"-c:v", "libx264",
|
||
"-preset", "veryfast",
|
||
"-crf", crf,
|
||
|
||
"-pix_fmt", "yuv420p",
|
||
|
||
"-max_muxing_queue_size", "1024",
|
||
|
||
"-g", gop,
|
||
"-keyint_min", gop,
|
||
"-sc_threshold", "0",
|
||
|
||
// Audio nur wenn vorhanden (wegen "-map 0:a:0?")
|
||
"-c:a", "aac",
|
||
"-b:a", "128k",
|
||
"-ac", "2",
|
||
|
||
"-movflags", movflags,
|
||
|
||
outPath,
|
||
)
|
||
|
||
return args
|
||
}
|
||
|
||
func buildFFmpegCopySegmentArgs(inPath, outPath string, startSec int) []string {
|
||
args := []string{
|
||
"-hide_banner",
|
||
"-loglevel", "error",
|
||
"-nostdin",
|
||
"-y",
|
||
}
|
||
|
||
if startSec > 0 {
|
||
args = append(args, "-ss", strconv.Itoa(startSec))
|
||
}
|
||
|
||
args = append(args,
|
||
"-i", inPath,
|
||
"-map", "0:v:0?",
|
||
"-map", "0:a:0?",
|
||
"-sn",
|
||
|
||
// ✅ kein re-encode
|
||
"-c", "copy",
|
||
|
||
// ✅ fürs normale File: moov nach vorne
|
||
"-movflags", "+faststart",
|
||
|
||
outPath,
|
||
)
|
||
|
||
return args
|
||
}
|
||
|
||
func buildFFmpegStreamArgs(inPath string, prof TranscodeProfile) []string {
|
||
// Stash streamt MP4 als fragmented MP4 mit empty_moov
|
||
// (kein default_base_moof für "plain mp4 stream").
|
||
movflags := "frag_keyframe+empty_moov"
|
||
|
||
// Stash-ähnliche CRF-Werte
|
||
crf := "25"
|
||
switch strings.ToUpper(strings.TrimSpace(prof.Name)) {
|
||
case "HIGH", "1080P":
|
||
crf = "23"
|
||
case "MEDIUM", "720P":
|
||
crf = "25"
|
||
case "LOW", "480P":
|
||
crf = "27"
|
||
}
|
||
|
||
args := []string{
|
||
"-hide_banner",
|
||
"-loglevel", "error",
|
||
"-nostdin",
|
||
// "-y" ist bei pipe egal, kann aber bleiben – ich lasse es weg wie im Beispiel
|
||
}
|
||
|
||
// Input
|
||
args = append(args, "-i", inPath)
|
||
|
||
// robust: Video/Audio optional
|
||
args = append(args,
|
||
"-map", "0:v:0?",
|
||
"-map", "0:a:0?",
|
||
"-sn",
|
||
)
|
||
|
||
// Scale nur wenn wir wirklich runterskalieren wollen
|
||
if prof.Height > 0 {
|
||
vf := fmt.Sprintf("scale=-2:%d", prof.Height)
|
||
args = append(args, "-vf", vf)
|
||
}
|
||
|
||
// Video
|
||
args = append(args,
|
||
"-c:v", "libx264",
|
||
"-preset", "veryfast",
|
||
"-crf", crf,
|
||
"-pix_fmt", "yuv420p",
|
||
"-sc_threshold", "0",
|
||
"-max_muxing_queue_size", "1024",
|
||
)
|
||
|
||
// Audio (nur wenn vorhanden wegen map 0:a:0?)
|
||
args = append(args,
|
||
"-c:a", "aac",
|
||
"-b:a", "128k",
|
||
"-ac", "2",
|
||
)
|
||
|
||
// MP4 stream flags
|
||
args = append(args,
|
||
"-movflags", movflags,
|
||
"-f", "mp4",
|
||
"pipe:", // wichtig: wie im Beispiel
|
||
)
|
||
|
||
return args
|
||
}
|
||
|
||
// -------------------------
|
||
// Cleanup helper
|
||
// -------------------------
|
||
|
||
func removeTranscodesForID(doneAbs, canonicalID string) {
|
||
_ = os.RemoveAll(filepath.Join(doneAbs, ".transcodes", canonicalID))
|
||
}
|