// backend\teaser.go package main import ( "bufio" "bytes" "context" "fmt" "math" "net/http" "os" "os/exec" "path/filepath" "strconv" "strings" "time" ) // ============================================================ // Teaser generation (preview.mp4) // Route: // - /api/generated/teaser?id= // ============================================================ const minSegmentDuration = 0.75 const defaultTeaserSegments = 18 type TeaserPreviewOptions struct { Segments int SegmentDuration float64 Width int Preset string CRF int Audio bool AudioBitrate string UseVsync2 bool } 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 } maxStart := dur - 0.05 - segDur if maxStart < 0 { maxStart = 0 } if n == 1 { return 0, maxStart * 0.5 } 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 { opts := TeaserPreviewOptions{ Segments: maxClips, SegmentDuration: clipLenSec, Width: 640, Preset: "veryfast", CRF: 21, Audio: true, AudioBitrate: "128k", UseVsync2: false, } return generateTeaserPreviewMP4WithProgress(ctx, srcPath, outPath, opts, onRatio) } func videoHasAudioStream(ctx context.Context, srcPath string) bool { srcPath = strings.TrimSpace(srcPath) if srcPath == "" { return false } probeCtx, cancel := context.WithTimeout(ctx, 8*time.Second) defer cancel() cmd := exec.CommandContext( probeCtx, ffprobePath, "-v", "error", "-select_streams", "a:0", "-show_entries", "stream=index", "-of", "csv=p=0", srcPath, ) hideCommandWindow(cmd) out, err := cmd.Output() if err != nil { return false } return strings.TrimSpace(string(out)) != "" } func teaserMissingAudioError(msg string) bool { msg = strings.ToLower(strings.TrimSpace(msg)) if msg == "" { return false } return strings.Contains(msg, "stream specifier ':a'") || strings.Contains(msg, "matches no streams") || strings.Contains(msg, "no such stream") || (strings.Contains(msg, "stream specifier") && strings.Contains(msg, ":a")) } func logTeaserGenerationFailure(srcPath string, outPath string, err error) { if err == nil { return } srcPath = strings.TrimSpace(srcPath) outPath = strings.TrimSpace(outPath) srcName := filepath.Base(srcPath) if strings.TrimSpace(srcName) == "" || srcName == "." { srcName = srcPath } outName := filepath.Base(outPath) if strings.TrimSpace(outName) == "" || outName == "." { outName = outPath } appLogln( "❌ teaser generation fehlgeschlagen:", srcName, "->", outName, "error:", err, ) } func logTeaserGenerationFallback(srcPath string, reason error) { if reason == nil { return } appLogln( "⚠️ teaser generation fallback ohne Audio:", filepath.Base(strings.TrimSpace(srcPath)), "reason:", reason, ) } func generateTeaserChunkMP4( ctx context.Context, src, out string, start, dur float64, opts TeaserPreviewOptions, ) error { hasAudio := 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", "2", ) if opts.UseVsync2 { args = append(args, "-vsync", "2") } if hasAudio { args = append(args, "-map", "0:a:0", "-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...) hideCommandWindow(cmd) var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { _ = os.Remove(tmp) msg := strings.TrimSpace(stderr.String()) wrappedErr := appErrorf("ffmpeg teaser chunk failed: %v (%s)", err, msg) logTeaserGenerationFailure(src, out, wrappedErr) return wrappedErr } _ = os.Remove(out) return os.Rename(tmp, out) } func computeTeaserStarts(dur float64, opts TeaserPreviewOptions) (starts []float64, segDur float64, usedSegments int) { if opts.SegmentDuration <= 0 { opts.SegmentDuration = 1 } if opts.Segments <= 0 { opts.Segments = defaultTeaserSegments } segDur = opts.SegmentDuration if segDur < minSegmentDuration { segDur = minSegmentDuration } if dur > 0 && segDur > dur { segDur = dur } usedSegments = opts.Segments 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 { if opts.SegmentDuration <= 0 { opts.SegmentDuration = 1 } if opts.Segments <= 0 { opts.Segments = defaultTeaserSegments } 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" } hasAudio := true segDur := opts.SegmentDuration if segDur < minSegmentDuration { segDur = minSegmentDuration } dur, _ := durationSecondsCached(ctx, srcPath) if dur > 0 && dur < segDur*float64(opts.Segments) { opts.Segments = 1 segDur = dur } if !(dur > 0) { if onRatio != nil { onRatio(0) } err := generateTeaserChunkMP4(ctx, srcPath, outPath, 0, math.Min(8, segDur), opts) if onRatio != nil { onRatio(1) } return err } starts, segDurComputed, _ := computeTeaserStarts(dur, opts) 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"} for _, t := range starts { args = append(args, ffmpegInputTol...) args = append(args, "-ss", fmt.Sprintf("%.3f", t), "-t", fmt.Sprintf("%.3f", segDur), "-i", srcPath) } var fc strings.Builder for i := range starts { fmt.Fprintf(&fc, "[%d:v]scale=%d:-2,setsar=1,setpts=PTS-STARTPTS[v%d];", i, opts.Width, i) if hasAudio { fmt.Fprintf(&fc, "[%d:a]aresample=48000,aformat=channel_layouts=stereo,asetpts=PTS-STARTPTS[a%d];", i, i) } } for i := range starts { if hasAudio { fmt.Fprintf(&fc, "[v%d][a%d]", i, i) } else { fmt.Fprintf(&fc, "[v%d]", i) } } if hasAudio { fmt.Fprintf(&fc, "concat=n=%d:v=1:a=1[v][a]", len(starts)) args = append(args, "-filter_complex", fc.String()) args = append(args, "-map", "[v]", "-map", "[a]") } else { fmt.Fprintf(&fc, "concat=n=%d:v=1:a=0[v]", len(starts)) args = append(args, "-filter_complex", fc.String()) args = append(args, "-map", "[v]", "-an") } args = append(args, "-c:v", "libx264", "-pix_fmt", "yuv420p", "-profile:v", "high", "-level", "4.2", "-preset", opts.Preset, "-crf", strconv.Itoa(opts.CRF), "-threads", "2", ) if opts.UseVsync2 { args = append(args, "-vsync", "2") } if hasAudio { 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...) hideCommandWindow(cmd) 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 outSec float64 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) } } 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) msg := strings.TrimSpace(stderr.String()) wrappedErr := appErrorf("ffmpeg teaser preview failed: %v (%s)", err, msg) logTeaserGenerationFailure(srcPath, outPath, wrappedErr) return wrappedErr } _ = os.Remove(outPath) return os.Rename(tmp, outPath) } func serveTeaserFile(w http.ResponseWriter, r *http.Request, path string) { f, err := openForReadShareDelete(path) if err != nil { http.Error(w, "datei öffnen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return } defer f.Close() fi, err := f.Stat() if err != nil || fi.IsDir() || fi.Size() == 0 { http.Error(w, "datei nicht gefunden", http.StatusNotFound) return } w.Header().Set("Cache-Control", "public, max-age=31536000") w.Header().Set("Content-Type", "video/mp4") http.ServeContent(w, r, filepath.Base(path), fi.ModTime(), f) } // tolerante Input-Flags für kaputte/abgeschnittene H264/TS Streams var ffmpegInputTol = []string{ "-fflags", "+discardcorrupt+genpts", "-err_detect", "ignore_err", "-max_error_rate", "1.0", } func generateTeaserMP4(ctx context.Context, srcPath, outPath string, startSec, durSec float64) error { if durSec <= 0 { durSec = 8 } if startSec < 0 { startSec = 0 } tmp := outPath + ".tmp.mp4" args := []string{ "-y", "-hide_banner", "-loglevel", "error", } args = append(args, ffmpegInputTol...) args = append(args, "-ss", fmt.Sprintf("%.3f", startSec), "-i", srcPath, "-t", fmt.Sprintf("%.3f", durSec), "-vf", "scale=720:-2", "-map", "0:v:0", "-map", "0:a:0", "-c:a", "aac", "-b:a", "128k", "-ac", "2", "-c:v", "libx264", "-preset", "veryfast", "-threads", "2", "-crf", "28", "-pix_fmt", "yuv420p", "-shortest", "-movflags", "+faststart", "-f", "mp4", "-threads", "2", tmp, ) cmd := exec.CommandContext(ctx, ffmpegPath, args...) hideCommandWindow(cmd) if out, err := cmd.CombinedOutput(); err != nil { _ = os.Remove(tmp) wrappedErr := appErrorf("ffmpeg teaser failed: %v (%s)", err, strings.TrimSpace(string(out))) logTeaserGenerationFailure(srcPath, outPath, wrappedErr) return wrappedErr } _ = os.Remove(outPath) return os.Rename(tmp, outPath) } func generatedTeaser(w http.ResponseWriter, r *http.Request) { id := strings.TrimSpace(r.URL.Query().Get("id")) if id == "" { http.Error(w, "id fehlt", http.StatusBadRequest) return } var err error id, err = sanitizeID(id) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } outPath, err := findFinishedFileByID(id) if err != nil { http.Error(w, "preview nicht verfügbar", http.StatusNotFound) return } if err := ensureGeneratedDirs(); err != nil { http.Error(w, "generated-dir nicht verfügbar: "+err.Error(), http.StatusInternalServerError) return } assetID := stripHotPrefix(id) if assetID == "" { assetID = id } assetDir, err := ensureGeneratedDir(assetID) if err != nil { http.Error(w, "generated-dir nicht verfügbar: "+err.Error(), http.StatusInternalServerError) return } previewPath := filepath.Join(assetDir, "preview.mp4") qNoGen := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("noGenerate"))) noGen := qNoGen == "1" || qNoGen == "true" || qNoGen == "yes" if fi, err := os.Stat(previewPath); err == nil && !fi.IsDir() && fi.Size() > 0 { serveTeaserFile(w, r, previewPath) return } if teaserLegacy, _ := generatedTeaserRoot(); strings.TrimSpace(teaserLegacy) != "" { cids := []string{assetID, id} for _, cid := range cids { candidates := []string{ filepath.Join(teaserLegacy, cid+"_teaser.mp4"), filepath.Join(teaserLegacy, cid+".mp4"), } for _, c := range candidates { if fi, err := os.Stat(c); err == nil && !fi.IsDir() && fi.Size() > 0 { if _, err2 := os.Stat(previewPath); os.IsNotExist(err2) { _ = os.MkdirAll(filepath.Dir(previewPath), 0o755) _ = os.Rename(c, previewPath) } if fi2, err2 := os.Stat(previewPath); err2 == nil && !fi2.IsDir() && fi2.Size() > 0 { serveTeaserFile(w, r, previewPath) return } serveTeaserFile(w, r, c) return } } } } if noGen { http.Error(w, "preview nicht verfügbar", http.StatusNotFound) return } if err := genSem.Acquire(r.Context()); err != nil { http.Error(w, "abgebrochen: "+err.Error(), http.StatusRequestTimeout) return } defer genSem.Release() genCtx, cancel := context.WithTimeout(r.Context(), 3*time.Minute) defer cancel() if err := generateTeaserClipsMP4(genCtx, outPath, previewPath, minSegmentDuration, defaultTeaserSegments); err != nil { appLogln( "⚠️ teaser clips generation fehlgeschlagen, versuche fallback:", filepath.Base(outPath), "error:", err, ) if err2 := generateTeaserMP4(genCtx, outPath, previewPath, 0, 8); err2 != nil { appLogln( "❌ teaser generation endgültig fehlgeschlagen:", filepath.Base(outPath), "clips-error:", err, "fallback-error:", err2, ) http.Error( w, "konnte preview nicht erzeugen: "+err.Error()+" (fallback ebenfalls fehlgeschlagen: "+err2.Error()+")", http.StatusInternalServerError, ) return } } serveTeaserFile(w, r, previewPath) }