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