// 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 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 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: "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: /.transcodes////s.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)) }