nsfwapp/backend/teaser_preview_ffmpeg.go
2026-02-20 18:18:59 +01:00

453 lines
9.4 KiB
Go

package main
import (
"bufio"
"bytes"
"context"
"fmt"
"math"
"os"
"os/exec"
"strconv"
"strings"
"time"
)
// Minimale Segmentdauer, damit ffmpeg nicht mit zu kurzen Schnipseln zickt.
const minSegmentDuration = 0.50 // Sekunden
type TeaserPreviewOptions struct {
Segments int
SegmentDuration float64
Width int
Preset string
CRF int
// wird von uns "hart" auf true gesetzt (Audio ist NICHT optional)
Audio bool
AudioBitrate string
UseVsync2 bool
}
// stepSizeAndOffset verteilt die Startpunkte über das Video.
// Rückgabe: stepSize, offset (beide in Sekunden).
func (o TeaserPreviewOptions) stepSizeAndOffset(dur float64) (float64, float64) {
if dur <= 0 {
return 0, 0
}
n := o.Segments
if n < 1 {
n = 1
}
segDur := o.SegmentDuration
if segDur <= 0 {
segDur = 1
}
if segDur < minSegmentDuration {
segDur = minSegmentDuration
}
// letzter sinnvoller Start (kleiner Sicherheitsabstand)
maxStart := dur - 0.05 - segDur
if maxStart < 0 {
maxStart = 0
}
// 1 Segment -> Mitte
if n == 1 {
return 0, maxStart * 0.5
}
// kleine Ränder, damit nicht immer ganz am Anfang/Ende
margin := 0.05 * maxStart
if margin < 0 {
margin = 0
}
span := maxStart - 2*margin
if span < 0 {
span = maxStart
margin = 0
}
step := 0.0
if n > 1 {
step = span / float64(n-1)
}
return step, margin
}
func generateTeaserClipsMP4(ctx context.Context, srcPath, outPath string, clipLenSec float64, maxClips int) error {
return generateTeaserClipsMP4WithProgress(ctx, srcPath, outPath, clipLenSec, maxClips, nil)
}
func generateTeaserClipsMP4WithProgress(
ctx context.Context,
srcPath, outPath string,
clipLenSec float64,
maxClips int,
onRatio func(r float64),
) error {
// kompatible Defaults aus deiner Signatur -> Options
opts := TeaserPreviewOptions{
Segments: maxClips,
SegmentDuration: clipLenSec,
// stash-like Defaults
Width: 640,
Preset: "veryfast",
CRF: 21,
Audio: true,
AudioBitrate: "128k",
UseVsync2: false,
}
return generateTeaserPreviewMP4WithProgress(ctx, srcPath, outPath, opts, onRatio)
}
func generateTeaserChunkMP4(ctx context.Context, src, out string, start, dur float64, opts TeaserPreviewOptions) error {
// ✅ Audio ist Pflicht (nicht optional)
opts.Audio = true
tmp := strings.TrimSuffix(out, ".mp4") + ".part.mp4"
segDur := dur
if segDur < minSegmentDuration {
segDur = minSegmentDuration
}
args := []string{
"-y", "-hide_banner", "-loglevel", "error",
}
args = append(args, ffmpegInputTol...)
args = append(args,
"-ss", fmt.Sprintf("%.3f", start),
"-t", fmt.Sprintf("%.3f", segDur),
"-i", src,
"-map", "0:v:0",
"-c:v", "libx264",
"-pix_fmt", "yuv420p",
"-profile:v", "high",
"-level", "4.2",
"-preset", opts.Preset,
"-crf", strconv.Itoa(opts.CRF),
"-threads", "4",
)
if opts.UseVsync2 {
args = append(args, "-vsync", "2")
}
if opts.Audio {
args = append(args,
"-map", "0:a:0", // Audio Pflicht
"-c:a", "aac",
"-b:a", opts.AudioBitrate,
"-ac", "2",
"-shortest",
)
} else {
args = append(args, "-an")
}
args = append(args, "-movflags", "+faststart", tmp)
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("ffmpeg teaser chunk failed: %v (%s)", err, strings.TrimSpace(stderr.String()))
}
_ = os.Remove(out)
return os.Rename(tmp, out)
}
func computeTeaserStarts(dur float64, opts TeaserPreviewOptions) (starts []float64, segDur float64, usedSegments int) {
// opts normalisieren wie in generateTeaserPreviewMP4WithProgress
if opts.SegmentDuration <= 0 {
opts.SegmentDuration = 1
}
if opts.Segments <= 0 {
opts.Segments = 18
}
segDur = opts.SegmentDuration
if segDur < minSegmentDuration {
segDur = minSegmentDuration
}
// Kurzvideo-Fallback: wenn Video kürzer als Segments*SegmentDuration -> 1 Segment über ganze Dauer
if dur > 0 && dur < segDur*float64(opts.Segments) {
opts.Segments = 1
segDur = dur
}
usedSegments = opts.Segments
// Dauer unbekannt: Start 0
if !(dur > 0) {
return []float64{0}, segDur, 1
}
stepSize, offset := opts.stepSizeAndOffset(dur)
starts = make([]float64, 0, opts.Segments)
for i := 0; i < opts.Segments; i++ {
t := offset + float64(i)*stepSize
maxStart := math.Max(0, dur-0.05-segDur)
if t < 0 {
t = 0
}
if t > maxStart {
t = maxStart
}
if t < 0.05 {
t = 0.05
}
starts = append(starts, t)
}
return starts, segDur, usedSegments
}
func generateTeaserPreviewMP4WithProgress(
ctx context.Context,
srcPath, outPath string,
opts TeaserPreviewOptions,
onRatio func(r float64),
) error {
// ✅ Audio ist Pflicht (nicht optional)
opts.Audio = true
// Defaults
if opts.SegmentDuration <= 0 {
opts.SegmentDuration = 1
}
if opts.Segments <= 0 {
opts.Segments = 18
}
if opts.Width <= 0 {
opts.Width = 640
}
if opts.Preset == "" {
opts.Preset = "veryfast"
}
if opts.CRF <= 0 {
opts.CRF = 21
}
if opts.AudioBitrate == "" {
opts.AudioBitrate = "128k"
}
segDur := opts.SegmentDuration
if segDur < minSegmentDuration {
segDur = minSegmentDuration
}
// Dauer holen (einmalig; wird gecached)
dur, _ := durationSecondsCached(ctx, srcPath)
// Kurzvideo-Fallback wie "die andere":
// Wenn Video kürzer als Segments*SegmentDuration -> Single Preview über komplette Dauer
if dur > 0 && dur < segDur*float64(opts.Segments) {
// als 1 Segment behandeln, Duration = dur
opts.Segments = 1
segDur = dur
}
// Wenn Dauer unbekannt/zu klein: ab 0 ein Stück
if !(dur > 0) {
if onRatio != nil {
onRatio(0)
}
// hier könntest du auch segDur verwenden; ich nehme min(8, segDur) ähnlich wie vorher
err := generateTeaserChunkMP4(ctx, srcPath, outPath, 0, math.Min(8, segDur), opts)
if onRatio != nil {
onRatio(1)
}
return err
}
starts, segDurComputed, _ := computeTeaserStarts(dur, opts)
// segDur ist später im Code benutzt -> segDur damit überschreiben:
segDur = segDurComputed
expectedOutSec := float64(len(starts)) * segDur
tmp := strings.TrimSuffix(outPath, ".mp4") + ".part.mp4"
args := []string{
"-y",
"-nostats",
"-progress", "pipe:1",
"-hide_banner",
"-loglevel", "error",
}
// Inputs: pro Segment eigener -ss/-t/-i (wie bei dir)
for _, t := range starts {
args = append(args, ffmpegInputTol...)
args = append(args,
"-ss", fmt.Sprintf("%.3f", t),
"-t", fmt.Sprintf("%.3f", segDur),
"-i", srcPath,
)
}
// filter_complex bauen
var fc strings.Builder
for i := range starts {
// stash-like: ScaleWidth(640), pix_fmt yuv420p, profile high/level 4.2 später in output args
fmt.Fprintf(&fc,
"[%d:v]scale=%d:-2,setsar=1,setpts=PTS-STARTPTS[v%d];",
i, opts.Width, i,
)
if opts.Audio {
// dein “concat-safe” Audio normalisieren (gute Idee)
fmt.Fprintf(&fc,
"[%d:a]aresample=48000,aformat=channel_layouts=stereo,asetpts=PTS-STARTPTS[a%d];",
i, i,
)
}
}
// interleaved concat inputs
for i := range starts {
if opts.Audio {
fmt.Fprintf(&fc, "[v%d][a%d]", i, i)
} else {
fmt.Fprintf(&fc, "[v%d]", i)
}
}
if opts.Audio {
fmt.Fprintf(&fc, "concat=n=%d:v=1:a=1[v][a]", len(starts))
} else {
fmt.Fprintf(&fc, "concat=n=%d:v=1:a=0[v]", len(starts))
}
args = append(args, "-filter_complex", fc.String())
// map outputs
args = append(args, "-map", "[v]")
if opts.Audio {
args = append(args, "-map", "[a]")
}
// Video encode (stash-like)
args = append(args,
"-c:v", "libx264",
"-pix_fmt", "yuv420p",
"-profile:v", "high",
"-level", "4.2",
"-preset", opts.Preset,
"-crf", strconv.Itoa(opts.CRF),
"-threads", "4",
)
if opts.UseVsync2 {
args = append(args, "-vsync", "2")
}
// Audio encode optional (stash-like 128k), plus dein -ac 2
if opts.Audio {
args = append(args,
"-c:a", "aac",
"-b:a", opts.AudioBitrate,
"-ac", "2",
"-shortest",
)
}
args = append(args, "-movflags", "+faststart", tmp)
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Start(); err != nil {
return err
}
sc := bufio.NewScanner(stdout)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
var lastSent float64
var lastAt time.Time
send := func(outSec float64, force bool) {
if onRatio == nil {
return
}
if expectedOutSec > 0 && outSec > 0 {
r := outSec / expectedOutSec
if r < 0 {
r = 0
}
if r > 1 {
r = 1
}
if r-lastSent < 0.01 && !force {
return
}
if !lastAt.IsZero() && time.Since(lastAt) < 150*time.Millisecond && !force {
return
}
lastSent = r
lastAt = time.Now()
onRatio(r)
return
}
if force {
onRatio(1)
}
}
var outSec float64
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
k, v := parts[0], parts[1]
switch k {
case "out_time_ms":
if n, perr := strconv.ParseInt(strings.TrimSpace(v), 10, 64); perr == nil && n > 0 {
outSec = float64(n) / 1_000_000.0
send(outSec, false)
}
case "out_time":
if s := parseFFmpegOutTime(v); s > 0 {
outSec = s
send(outSec, false)
}
case "progress":
if strings.TrimSpace(v) == "end" {
send(outSec, true)
}
}
}
if err := cmd.Wait(); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("ffmpeg teaser preview failed: %v (%s)", err, strings.TrimSpace(stderr.String()))
}
_ = os.Remove(outPath)
return os.Rename(tmp, outPath)
}