nsfwapp/backend/transcode.go
2026-02-12 11:33:21 +01:00

620 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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))
}