568 lines
13 KiB
Go
568 lines
13 KiB
Go
// backend\teaser.go
|
|
|
|
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"math"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
// ============================================================
|
|
// Teaser generation (preview.mp4)
|
|
// Route:
|
|
// - /api/generated/teaser?id=<assetID>
|
|
// ============================================================
|
|
|
|
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 generateTeaserChunkMP4(
|
|
ctx context.Context,
|
|
src, out string,
|
|
start, dur float64,
|
|
opts TeaserPreviewOptions,
|
|
) error {
|
|
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", "2",
|
|
)
|
|
|
|
if opts.UseVsync2 {
|
|
args = append(args, "-vsync", "2")
|
|
}
|
|
|
|
args = append(args,
|
|
"-map", "0:a:0",
|
|
"-c:a", "aac",
|
|
"-b:a", opts.AudioBitrate,
|
|
"-ac", "2",
|
|
"-shortest",
|
|
)
|
|
|
|
args = append(args, "-movflags", "+faststart", tmp)
|
|
|
|
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{
|
|
HideWindow: true,
|
|
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
|
|
}
|
|
|
|
var stderr bytes.Buffer
|
|
cmd.Stderr = &stderr
|
|
if err := cmd.Run(); err != nil {
|
|
_ = os.Remove(tmp)
|
|
return appErrorf("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) {
|
|
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 {
|
|
opts.Audio = true
|
|
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"
|
|
}
|
|
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)
|
|
fmt.Fprintf(&fc, "[%d:a]aresample=48000,aformat=channel_layouts=stereo,asetpts=PTS-STARTPTS[a%d];", i, i)
|
|
}
|
|
for i := range starts {
|
|
fmt.Fprintf(&fc, "[v%d][a%d]", i, i)
|
|
}
|
|
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]")
|
|
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")
|
|
}
|
|
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...)
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{
|
|
HideWindow: true,
|
|
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
|
|
}
|
|
|
|
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)
|
|
return appErrorf("ffmpeg teaser preview failed: %v (%s)", err, strings.TrimSpace(stderr.String()))
|
|
}
|
|
|
|
_ = 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...)
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{
|
|
HideWindow: true,
|
|
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
|
|
}
|
|
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
_ = os.Remove(tmp)
|
|
return appErrorf("ffmpeg teaser failed: %v (%s)", err, strings.TrimSpace(string(out)))
|
|
}
|
|
|
|
_ = 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 {
|
|
if err2 := generateTeaserMP4(genCtx, outPath, previewPath, 0, 8); err2 != nil {
|
|
http.Error(
|
|
w,
|
|
"konnte preview nicht erzeugen: "+err.Error()+" (fallback ebenfalls fehlgeschlagen: "+err2.Error()+")",
|
|
http.StatusInternalServerError,
|
|
)
|
|
return
|
|
}
|
|
}
|
|
|
|
serveTeaserFile(w, r, previewPath)
|
|
}
|