nsfwapp/backend/preview.go
2026-03-16 12:46:38 +01:00

2290 lines
54 KiB
Go
Raw Permalink 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/preview.go
package main
import (
"bufio"
"bytes"
"context"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"html"
"image"
"image/color"
"image/draw"
"image/jpeg"
"image/png"
"io"
"math"
"math/rand"
"net/http"
"net/url"
"os"
"os/exec"
"path"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/image/font"
"golang.org/x/image/font/basicfont"
"golang.org/x/image/math/fixed"
)
// NOTE:
// Diese Datei ist ein "Zusammenzug" deiner bisherigen preview_* Dateien.
// Sie referenziert weiterhin vorhandene Functions/Globals aus deinem Backend, z.B.:
// - resolvePathRelativeToApp, getSettings, ensureAssetsForVideo, generatedThumbFile
// - atomicWriteFile, ensureGeneratedDirs, ensureGeneratedDir
// - durationSecondsCached, parseFFmpegOutTime, ffmpegInputTol
// - jobs, jobsMu, RecordJob, previewSem, thumbSem, JobRunning, notifyJobsChanged
// - sanitizeID, findFinishedFileByID, stripHotPrefix, assetIDForJob, generatedThumbJPGFile
// Bitte diese Abhängigkeiten NICHT löschen preview.go nutzt sie.
// ============================================================
// Shared wiring
// ============================================================
// coverModelStore wird von routes.go gesetzt (du rufst setCoverModelStore(store)).
var coverModelStore *ModelStore
func setCoverModelStore(s *ModelStore) { coverModelStore = s }
var errCoverNotSupported = errors.New("cover not supported")
// ============================================================
// Covers: generated/covers/<category>.<ext> + info.json
// Routes:
// - /api/generated/cover?category=...&refresh=1&model=...&src=...
// - /api/generated/coverinfo/list
// ============================================================
type coverInfo struct {
Category string `json:"category"`
Model string `json:"model,omitempty"`
Src string `json:"src,omitempty"`
GeneratedAt string `json:"generatedAt"`
}
func normalizeCoverSrc(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
// Windows -> URL-artig
s2 := strings.ReplaceAll(s, "\\", "/")
// Wenn es schon wie ein Web-Pfad aussieht, so lassen
if strings.HasPrefix(s2, "/generated/") || strings.HasPrefix(s2, "http://") || strings.HasPrefix(s2, "https://") {
return s2
}
// Wenn es ein lokaler Pfad ist, versuche den /generated/ Teil zu extrahieren
if i := strings.Index(s2, "/generated/"); i >= 0 {
return s2[i:]
}
return s2
}
func coversRoot() (string, error) {
return resolvePathRelativeToApp(filepath.Join("generated", "covers"))
}
func ensureCoversDir() (string, error) {
root, err := coversRoot()
if err != nil {
return "", err
}
if strings.TrimSpace(root) == "" {
return "", fmt.Errorf("covers root ist leer")
}
if err := os.MkdirAll(root, 0o755); err != nil {
return "", err
}
return root, nil
}
func coverInfoPathForKey(key string) (string, error) {
root, err := coversRoot()
if err != nil {
return "", err
}
return filepath.Join(root, key+".info.json"), nil
}
func writeCoverInfoBestEffort(key string, info coverInfo) {
p, err := coverInfoPathForKey(key)
if err != nil {
return
}
b, err := json.MarshalIndent(info, "", " ")
if err != nil {
return
}
_ = os.MkdirAll(filepath.Dir(p), 0o755)
_ = os.WriteFile(p, b, 0o644)
}
func readCoverInfoBestEffort(key string) (coverInfo, bool) {
p, err := coverInfoPathForKey(key)
if err != nil {
return coverInfo{}, false
}
b, err := os.ReadFile(p)
if err != nil || len(b) == 0 {
return coverInfo{}, false
}
var ci coverInfo
if json.Unmarshal(b, &ci) != nil {
return coverInfo{}, false
}
return ci, true
}
func drawLabel(img draw.Image, text string) {
text = strings.TrimSpace(text)
if text == "" {
return
}
face := basicfont.Face7x13
// Layout
const margin = 10
const padX = 10
const padY = 8
b := img.Bounds()
maxTextW := (b.Dx() - 2*margin) - 2*padX
if maxTextW <= 0 {
return
}
measure := func(s string) int {
d := &font.Drawer{Face: face}
return d.MeasureString(s).Ceil()
}
label := text
if w := measure(label); w > maxTextW {
ellipsis := "…"
rs := []rune(text)
if len(rs) == 0 {
return
}
lo, hi := 0, len(rs)
best := ""
for lo <= hi {
mid := (lo + hi) / 2
cand := string(rs[:mid]) + ellipsis
if measure(cand) <= maxTextW {
best = cand
lo = mid + 1
} else {
hi = mid - 1
}
}
if best == "" {
label = ellipsis
} else {
label = best
}
}
d := &font.Drawer{Face: face}
textW := d.MeasureString(label).Ceil()
textH := face.Metrics().Height.Ceil()
ascent := face.Metrics().Ascent.Ceil()
x0 := b.Min.X + margin
y1 := b.Max.Y - margin
y0 := y1 - (textH + 2*padY)
x1 := x0 + (textW + 2*padX)
maxX1 := b.Max.X - margin
if x1 > maxX1 {
shift := x1 - maxX1
x0 -= shift
x1 -= shift
if x0 < b.Min.X+margin {
x0 = b.Min.X + margin
x1 = maxX1
}
}
minY0 := b.Min.Y + margin
if y0 < minY0 {
y0 = minY0
y1 = y0 + (textH + 2*padY)
if y1 > b.Max.Y-margin {
return
}
}
rect := image.Rect(x0, y0, x1, y1)
bg := image.NewUniform(color.RGBA{0, 0, 0, 170})
draw.Draw(img, rect, bg, image.Point{}, draw.Over)
border := image.NewUniform(color.RGBA{255, 255, 255, 35})
draw.Draw(img, image.Rect(rect.Min.X, rect.Min.Y, rect.Max.X, rect.Min.Y+1), border, image.Point{}, draw.Over)
draw.Draw(img, image.Rect(rect.Min.X, rect.Max.Y-1, rect.Max.X, rect.Max.Y), border, image.Point{}, draw.Over)
draw.Draw(img, image.Rect(rect.Min.X, rect.Min.Y, rect.Min.X+1, rect.Max.Y), border, image.Point{}, draw.Over)
draw.Draw(img, image.Rect(rect.Max.X-1, rect.Min.Y, rect.Max.X, rect.Max.Y), border, image.Point{}, draw.Over)
tx := x0 + padX
ty := y0 + padY + ascent
shadow := &font.Drawer{Dst: img, Src: image.NewUniform(color.RGBA{0, 0, 0, 200}), Face: face, Dot: fixed.P(tx+1, ty+1)}
shadow.DrawString(label)
fg := &font.Drawer{Dst: img, Src: image.NewUniform(color.RGBA{255, 255, 255, 235}), Face: face, Dot: fixed.P(tx, ty)}
fg.DrawString(label)
}
func splitTagsLoose(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
parts := strings.FieldsFunc(raw, func(r rune) bool {
switch r {
case '\n', ',', ';', '|':
return true
}
return false
})
out := make([]string, 0, len(parts))
seen := map[string]struct{}{}
for _, p := range parts {
t := strings.TrimSpace(p)
if t == "" {
continue
}
low := strings.ToLower(t)
if _, ok := seen[low]; ok {
continue
}
seen[low] = struct{}{}
out = append(out, t)
}
return out
}
func hasTag(tagsRaw string, want string) bool {
want = strings.ToLower(strings.TrimSpace(want))
if want == "" {
return false
}
for _, t := range splitTagsLoose(tagsRaw) {
if strings.ToLower(strings.TrimSpace(t)) == want {
return true
}
}
return false
}
type coverModel struct {
Key string
Tags string
}
func listModelsForCovers() ([]coverModel, error) {
if coverModelStore == nil {
return nil, fmt.Errorf("model store not set")
}
ms := coverModelStore.List()
out := make([]coverModel, 0, len(ms))
for _, m := range ms {
key := strings.TrimSpace(m.ModelKey)
if key == "" {
continue
}
out = append(out, coverModel{Key: key, Tags: m.Tags})
}
return out, nil
}
func pickRandomThumbForCategory(ctx context.Context, category string) (thumbPath string, err error) {
category = strings.TrimSpace(category)
if category == "" {
return "", fmt.Errorf("category empty")
}
select {
case <-ctx.Done():
return "", ctx.Err()
default:
}
models, err := listModelsForCovers()
if err != nil {
return "", err
}
cands := make([]coverModel, 0, 64)
for _, m := range models {
key := strings.TrimSpace(m.Key)
if key == "" {
continue
}
if hasTag(m.Tags, category) {
cands = append(cands, coverModel{Key: key, Tags: m.Tags})
}
}
if len(cands) == 0 {
return "", fmt.Errorf("no model with tag")
}
rand.Shuffle(len(cands), func(i, j int) { cands[i], cands[j] = cands[j], cands[i] })
s := getSettings()
doneAbs, derr := resolvePathRelativeToApp(s.DoneDir)
if derr != nil || strings.TrimSpace(doneAbs) == "" {
return "", fmt.Errorf("doneDir resolve failed: %v", derr)
}
type candFile struct {
videoPath string
id string
}
isVideo := func(name string) bool {
low := strings.ToLower(name)
if strings.Contains(low, ".part") || strings.Contains(low, ".tmp") {
return false
}
ext := strings.ToLower(filepath.Ext(name))
return ext == ".mp4" || ext == ".ts"
}
for _, m := range cands {
select {
case <-ctx.Done():
return "", ctx.Err()
default:
}
modelKey := strings.TrimSpace(m.Key)
if modelKey == "" {
continue
}
dirs := []string{filepath.Join(doneAbs, modelKey), filepath.Join(doneAbs, "keep", modelKey)}
files := make([]candFile, 0, 128)
for _, d := range dirs {
ents, err := os.ReadDir(d)
if err != nil {
continue
}
for _, e := range ents {
if e.IsDir() {
continue
}
name := e.Name()
if !isVideo(name) {
continue
}
full := filepath.Join(d, name)
stem := strings.TrimSuffix(name, filepath.Ext(name))
id := stripHotPrefix(strings.TrimSpace(stem))
if id == "" {
continue
}
files = append(files, candFile{videoPath: full, id: id})
}
}
if len(files) == 0 {
continue
}
cf := files[rand.Intn(len(files))]
_ = ensureAssetsForVideo(cf.videoPath)
tp, terr := generatedThumbFile(cf.id)
if terr != nil {
continue
}
if fi, serr := os.Stat(tp); serr == nil && !fi.IsDir() && fi.Size() > 0 {
return tp, nil
}
}
return "", fmt.Errorf("no downloads/thumbs for category")
}
var coverKeyRe = regexp.MustCompile(`[^a-z0-9._-]+`)
func sanitizeCoverKey(category string) (string, error) {
c := strings.ToLower(strings.TrimSpace(category))
if c == "" {
sum := sha1.Sum([]byte(category))
c = "tag_" + hex.EncodeToString(sum[:8])
}
if c == "" {
return "", fmt.Errorf("category fehlt")
}
c = strings.ReplaceAll(c, " ", "_")
c = coverKeyRe.ReplaceAllString(c, "_")
c = strings.Trim(c, "._-")
if c == "" {
return "", fmt.Errorf("category ungültig")
}
if len(c) > 120 {
c = c[:120]
}
return c, nil
}
func detectImageExt(contentType string, b []byte) (ext string, ct string) {
ct = strings.ToLower(strings.TrimSpace(contentType))
switch {
case strings.Contains(ct, "image/jpeg") || strings.Contains(ct, "image/jpg"):
return ".jpg", "image/jpeg"
case strings.Contains(ct, "image/png"):
return ".png", "image/png"
case strings.Contains(ct, "image/gif"):
return ".gif", "image/gif"
}
if len(b) >= 3 && b[0] == 0xFF && b[1] == 0xD8 && b[2] == 0xFF {
return ".jpg", "image/jpeg"
}
if len(b) >= 8 && bytes.Equal(b[:8], []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}) {
return ".png", "image/png"
}
if len(b) >= 12 && string(b[:4]) == "RIFF" && string(b[8:12]) == "WEBP" {
return ".jpg", "image/jpeg"
}
if len(b) >= 6 && (string(b[:6]) == "GIF87a" || string(b[:6]) == "GIF89a") {
return ".gif", "image/gif"
}
return ".jpg", "image/jpeg"
}
func coverPathForCategory(key string, ext string) (string, error) {
root, err := coversRoot()
if err != nil {
return "", err
}
if strings.TrimSpace(root) == "" {
return "", fmt.Errorf("covers root ist leer")
}
if ext == "" {
ext = ".jpg"
}
return filepath.Join(root, key+ext), nil
}
func findExistingCoverFile(key string) (string, os.FileInfo, bool) {
root, err := coversRoot()
if err != nil || strings.TrimSpace(root) == "" {
return "", nil, false
}
ext := []string{".jpg", ".png", ".gif"}
for _, e := range ext {
p := filepath.Join(root, key+e)
if fi, err := os.Stat(p); err == nil && !fi.IsDir() && fi.Size() > 0 {
return p, fi, true
}
}
return "", nil, false
}
func downloadBytes(ctx context.Context, rawURL string, ua string) ([]byte, string, error) {
rawURL = strings.TrimSpace(rawURL)
if rawURL == "" {
return nil, "", fmt.Errorf("src fehlt")
}
// local: only /generated/...
if strings.HasPrefix(rawURL, "/") {
clean := path.Clean(rawURL)
if !strings.HasPrefix(clean, "/generated/") {
return nil, "", fmt.Errorf("src ungültig")
}
if strings.Contains(clean, "..") {
return nil, "", fmt.Errorf("src ungültig")
}
rel := strings.TrimPrefix(clean, "/")
abs, err := resolvePathRelativeToApp(rel)
if err != nil || strings.TrimSpace(abs) == "" {
return nil, "", fmt.Errorf("src ungültig")
}
f, err := os.Open(abs)
if err != nil {
return nil, "", fmt.Errorf("download failed: %v", err)
}
defer f.Close()
b, err := io.ReadAll(io.LimitReader(f, 10*1024*1024))
if err != nil {
return nil, "", fmt.Errorf("download failed: %v", err)
}
if len(b) == 0 {
return nil, "", fmt.Errorf("download empty")
}
ext := strings.ToLower(filepath.Ext(abs))
ct := "application/octet-stream"
switch ext {
case ".jpg", ".jpeg":
ct = "image/jpeg"
case ".png":
ct = "image/png"
case ".gif":
ct = "image/gif"
}
return b, ct, nil
}
u, err := url.Parse(rawURL)
if err != nil || u.Scheme == "" || u.Host == "" {
return nil, "", fmt.Errorf("src ungültig")
}
if u.Scheme != "http" && u.Scheme != "https" {
return nil, "", fmt.Errorf("src schema nicht erlaubt")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, "", err
}
if strings.TrimSpace(ua) == "" {
ua = "Mozilla/5.0"
}
req.Header.Set("User-Agent", ua)
req.Header.Set("Accept", "image/*,*/*;q=0.8")
client := &http.Client{Timeout: 12 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, "", fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
}
b, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024))
if err != nil {
return nil, "", err
}
if len(b) == 0 {
return nil, "", fmt.Errorf("download empty")
}
return b, resp.Header.Get("Content-Type"), nil
}
var coverBatchMu sync.Mutex
var (
coverBatchInflight int
coverBatchStarted time.Time
coverBatchTotal int
coverBatchForced int
coverBatchMiss int
coverBatchErrors int
coverBatchNoThumb int
coverBatchDecodeErr int
)
func coverBatchEnter(force bool) {
coverBatchMu.Lock()
defer coverBatchMu.Unlock()
if coverBatchInflight == 0 {
coverBatchStarted = time.Now()
coverBatchTotal = 0
coverBatchForced = 0
coverBatchMiss = 0
coverBatchErrors = 0
coverBatchNoThumb = 0
coverBatchDecodeErr = 0
}
coverBatchInflight++
coverBatchTotal++
if force {
coverBatchForced++
} else {
coverBatchMiss++
}
}
func coverBatchLeave(outcome string, status int) {
coverBatchMu.Lock()
defer coverBatchMu.Unlock()
if status >= 400 {
coverBatchErrors++
}
switch outcome {
case "no-thumb":
coverBatchNoThumb++
case "decode-failed-no-overlay":
coverBatchDecodeErr++
}
coverBatchInflight--
}
var reModelFromStem = regexp.MustCompile(`^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}`)
func inferModelFromStem(stem string) string {
stem = stripHotPrefix(strings.TrimSpace(stem))
if stem == "" {
return ""
}
m := reModelFromStem.FindStringSubmatch(stem)
if len(m) >= 2 {
return strings.TrimSpace(m[1])
}
return ""
}
func inferModelFromThumbLike(srcOrPath string) string {
s := strings.TrimSpace(srcOrPath)
if s == "" {
return ""
}
s = strings.ReplaceAll(s, `\\`, `/`)
if u, err := url.Parse(s); err == nil && u != nil && u.Scheme != "" && u.Host != "" {
s = u.Path
}
base := path.Base(s)
lb := strings.ToLower(base)
if strings.HasPrefix(lb, "thumbs.") {
id := path.Base(path.Dir(s))
return inferModelFromStem(id)
}
stem := strings.TrimSuffix(base, path.Ext(base))
return inferModelFromStem(stem)
}
type coverInfoListItem struct {
Category string `json:"category"`
Model string `json:"model,omitempty"`
GeneratedAt string `json:"generatedAt,omitempty"`
HasCover bool `json:"hasCover"`
}
func generatedCoverInfoList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "Nur GET/HEAD erlaubt", http.StatusMethodNotAllowed)
return
}
root, err := coversRoot()
if err != nil {
http.Error(w, "covers root: "+err.Error(), http.StatusInternalServerError)
return
}
entries, err := os.ReadDir(root)
if err != nil {
http.Error(w, "covers dir: "+err.Error(), http.StatusInternalServerError)
return
}
byKey := map[string]*coverInfoListItem{}
ensure := func(key string) *coverInfoListItem {
if v, ok := byKey[key]; ok {
return v
}
v := &coverInfoListItem{Category: key}
byKey[key] = v
return v
}
isCoverExt := func(ext string) bool {
switch strings.ToLower(ext) {
case ".jpg", ".jpeg", ".png", ".gif":
return true
default:
return false
}
}
for _, e := range entries {
name := e.Name()
lower := strings.ToLower(name)
if strings.HasSuffix(lower, ".info.json") {
key := strings.TrimSuffix(name, ".info.json")
if ci, ok := readCoverInfoBestEffort(key); ok {
v := ensure(key)
if strings.TrimSpace(ci.Category) != "" {
v.Category = strings.TrimSpace(ci.Category)
}
if strings.TrimSpace(ci.Model) != "" {
v.Model = strings.TrimSpace(ci.Model)
}
if strings.TrimSpace(ci.GeneratedAt) != "" {
v.GeneratedAt = strings.TrimSpace(ci.GeneratedAt)
}
}
continue
}
ext := filepath.Ext(name)
if isCoverExt(ext) {
key := strings.TrimSuffix(name, ext)
v := ensure(key)
v.HasCover = true
}
}
for _, v := range byKey {
if !v.HasCover {
v.Model = ""
v.GeneratedAt = ""
}
if strings.TrimSpace(v.Category) == "" {
v.Category = ""
}
}
keys := make([]string, 0, len(byKey))
for k := range byKey {
keys = append(keys, k)
}
sort.Strings(keys)
out := make([]coverInfoListItem, 0, len(keys))
for _, k := range keys {
out = append(out, *byKey[k])
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
_ = json.NewEncoder(w).Encode(out)
}
func generatedCover(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "Nur GET/HEAD erlaubt", http.StatusMethodNotAllowed)
return
}
category := r.URL.Query().Get("category")
key, err := sanitizeCoverKey(category)
if err != nil {
http.Error(w, "category ungültig: "+err.Error(), http.StatusBadRequest)
return
}
refresh := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("refresh")))
force := refresh == "1" || refresh == "true" || refresh == "yes"
modelQ := strings.TrimSpace(r.URL.Query().Get("model"))
modelExplicit := modelQ != ""
model := modelQ
src := strings.TrimSpace(r.URL.Query().Get("src"))
fallbackModel := ""
if ci, ok := readCoverInfoBestEffort(key); ok {
if m := strings.TrimSpace(ci.Model); m != "" {
fallbackModel = m
}
}
if model == "" {
model = fallbackModel
}
if !modelExplicit && src != "" {
if m := inferModelFromThumbLike(src); m != "" {
model = m
}
}
reqID := strconv.FormatInt(time.Now().UnixNano(), 36)
setDebugHeaders := func(cache string) {
w.Header().Set("X-Cover-Key", key)
w.Header().Set("X-Cover-Category", category)
if model != "" {
w.Header().Set("X-Cover-Model", model)
}
w.Header().Set("X-Cover-Cache", cache)
w.Header().Set("X-Request-Id", reqID)
}
if !force {
if model != "" {
if ci, ok := readCoverInfoBestEffort(key); ok {
if strings.TrimSpace(ci.Model) != model {
force = true
}
} else {
force = true
}
}
if !force {
if p, fi, ok := findExistingCoverFile(key); ok {
setDebugHeaders("HIT")
if model != "" {
ci, ok := readCoverInfoBestEffort(key)
if !ok {
ci = coverInfo{Category: category}
}
ci.Category = category
ci.Model = strings.TrimSpace(model)
ci.GeneratedAt = time.Now().UTC().Format(time.RFC3339Nano)
writeCoverInfoBestEffort(key, ci)
}
w.Header().Set("Cache-Control", "public, max-age=31536000")
w.Header().Set("X-Content-Type-Options", "nosniff")
ext := strings.ToLower(filepath.Ext(p))
switch ext {
case ".png":
w.Header().Set("Content-Type", "image/png")
case ".jpg", ".jpeg":
w.Header().Set("Content-Type", "image/jpeg")
case ".gif":
w.Header().Set("Content-Type", "image/gif")
default:
w.Header().Set("Content-Type", "image/jpeg")
}
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
f, err := os.Open(p)
if err != nil {
http.NotFound(w, r)
return
}
defer f.Close()
http.ServeContent(w, r, filepath.Base(p), fi.ModTime(), f)
return
}
}
}
cacheStatus := "MISS"
if force {
cacheStatus = "FORCED"
}
setDebugHeaders(cacheStatus)
coverBatchEnter(force)
start := time.Now()
status := http.StatusOK
outcome := "ok"
defer func() {
w.Header().Set("X-Cover-Gen-Ms", strconv.FormatInt(time.Since(start).Milliseconds(), 10))
coverBatchLeave(outcome, status)
}()
if _, err := ensureCoversDir(); err != nil {
status = http.StatusInternalServerError
outcome = "covers-dir"
http.Error(w, "covers-dir nicht verfügbar: "+err.Error(), status)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
var (
raw []byte
mimeType string
ext string
)
thumbPath := ""
usedSrc := ""
if src != "" {
var derr error
raw, mimeType, derr = downloadBytes(ctx, src, r.Header.Get("User-Agent"))
usedSrc = normalizeCoverSrc(src)
if derr != nil {
status = http.StatusBadRequest
outcome = "src-download"
http.Error(w, "src download failed: "+derr.Error(), status)
return
}
ext, mimeType = detectImageExt(mimeType, raw)
if len(raw) == 0 {
status = http.StatusBadRequest
outcome = "src-empty"
http.Error(w, "src leer", status)
return
}
if model == "" {
if m := inferModelFromThumbLike(src); m != "" {
model = m
w.Header().Set("X-Cover-Model", model)
}
}
} else {
var perr error
thumbPath, perr = pickRandomThumbForCategory(ctx, category)
if perr != nil {
if p, fi, ok := findExistingCoverFile(key); ok {
outcome = "fallback-existing-cover"
status = http.StatusOK
w.Header().Set("Cache-Control", "public, max-age=600")
w.Header().Set("X-Content-Type-Options", "nosniff")
ext2 := strings.ToLower(filepath.Ext(p))
switch ext2 {
case ".png":
w.Header().Set("Content-Type", "image/png")
case ".jpg":
w.Header().Set("Content-Type", "image/jpeg")
case ".gif":
w.Header().Set("Content-Type", "image/gif")
default:
w.Header().Set("Content-Type", "image/jpeg")
}
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
f, err := os.Open(p)
if err != nil {
servePreviewStatusSVG(w, "No Cover", status)
return
}
defer f.Close()
http.ServeContent(w, r, filepath.Base(p), fi.ModTime(), f)
return
}
outcome = "no-thumb"
status = http.StatusNotFound
if r.Method == http.MethodHead {
w.WriteHeader(status)
return
}
servePreviewStatusSVG(w, "No Cover", status)
return
}
usedSrc = normalizeCoverSrc(thumbPath)
raw, err = os.ReadFile(thumbPath)
if err != nil || len(raw) == 0 {
status = http.StatusInternalServerError
outcome = "thumb-read"
http.Error(w, "cover read fehlgeschlagen", status)
return
}
ext = ".jpg"
mimeType = "image/jpeg"
if model == "" {
if m := inferModelFromThumbLike(thumbPath); m != "" {
model = m
w.Header().Set("X-Cover-Model", model)
}
}
}
if !modelExplicit {
if m := inferModelFromThumbLike(usedSrc); m != "" {
model = m
w.Header().Set("X-Cover-Model", model)
}
}
img, _, derr := image.Decode(bytes.NewReader(raw))
if derr == nil && img != nil {
rgba := image.NewRGBA(img.Bounds())
draw.Draw(rgba, rgba.Bounds(), img, img.Bounds().Min, draw.Src)
if strings.TrimSpace(model) != "" {
drawLabel(rgba, model)
}
var buf bytes.Buffer
switch strings.ToLower(ext) {
case ".png":
_ = png.Encode(&buf, rgba)
raw = buf.Bytes()
ext = ".png"
mimeType = "image/png"
default:
_ = jpeg.Encode(&buf, rgba, &jpeg.Options{Quality: 85})
raw = buf.Bytes()
ext = ".jpg"
mimeType = "image/jpeg"
}
} else {
outcome = "decode-failed-no-overlay"
}
root, _ := coversRoot()
for _, e := range []string{".jpg", ".png", ".gif"} {
_ = os.Remove(filepath.Join(root, key+e))
}
_ = os.Remove(filepath.Join(root, key+".info.json"))
dst, err := coverPathForCategory(key, ext)
if err != nil {
status = http.StatusInternalServerError
outcome = "cover-path"
http.Error(w, "cover path: "+err.Error(), status)
return
}
if err := atomicWriteFile(dst, raw); err != nil {
status = http.StatusInternalServerError
outcome = "cover-write"
http.Error(w, "cover write: "+err.Error(), status)
return
}
writeCoverInfoBestEffort(key, coverInfo{
Category: category,
Model: strings.TrimSpace(model),
Src: strings.TrimSpace(usedSrc),
GeneratedAt: time.Now().UTC().Format(time.RFC3339Nano),
})
w.Header().Set("Cache-Control", "public, max-age=600")
w.Header().Set("Content-Type", mimeType)
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Cover-Bytes", strconv.Itoa(len(raw)))
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write(raw)
}
// ============================================================
// Status SVG (Preview placeholder)
// ============================================================
func servePreviewStatusSVG(w http.ResponseWriter, label string, status int) {
w.Header().Set("Content-Type", "image/svg+xml; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Content-Type-Options", "nosniff")
if status <= 0 {
status = http.StatusOK
}
title := html.EscapeString(strings.TrimSpace(label))
if title == "" {
title = "Preview"
}
svg := `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 180" preserveAspectRatio="xMidYMid slice">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="rgba(99,102,241,0.10)"/>
<stop offset="1" stop-color="rgba(14,165,233,0.08)"/>
</linearGradient>
<radialGradient id="vig" cx="50%" cy="45%" r="75%">
<stop offset="0" stop-color="rgba(0,0,0,0)"/>
<stop offset="1" stop-color="rgba(0,0,0,0.18)"/>
</radialGradient>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="6" stdDeviation="8" flood-color="rgba(0,0,0,0.18)"/>
</filter>
</defs>
<rect x="0" y="0" width="320" height="180" rx="18" fill="rgba(17,24,39,0.06)"/>
<rect x="0" y="0" width="320" height="180" rx="18" fill="url(#bg)"/>
<rect x="0" y="0" width="320" height="180" rx="18" fill="url(#vig)"/>
<g filter="url(#shadow)">
<rect x="18" y="18" width="284" height="144" rx="16"
fill="rgba(255,255,255,0.72)"
stroke="rgba(0,0,0,0.08)"/>
<rect x="18" y="18" width="284" height="144" rx="16"
fill="rgba(255,255,255,0)"
stroke="rgba(99,102,241,0.18)"
stroke-width="2"
stroke-dasharray="6 6"/>
</g>
<g transform="translate(160 70)">
<circle r="20" fill="rgba(17,24,39,0.08)" stroke="rgba(0,0,0,0.08)"/>
<path d="M-10 6 L-4 0 L2 6 L10 -2" fill="none" stroke="rgba(17,24,39,0.55)" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M-10 -6 H10" fill="none" stroke="rgba(17,24,39,0.35)" stroke-width="2.4" stroke-linecap="round"/>
<path d="M-12 12 L12 -12" fill="none" stroke="rgba(239,68,68,0.55)" stroke-width="2.6" stroke-linecap="round"/>
</g>
<text x="160" y="118" text-anchor="middle"
font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto"
font-size="16" font-weight="750"
fill="rgba(17,24,39,0.88)">` + title + `</text>
<text x="160" y="140" text-anchor="middle"
font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto"
font-size="11.5" font-weight="650"
fill="rgba(75,85,99,0.82)">Preview nicht verfügbar</text>
</svg>
`
w.WriteHeader(status)
_, _ = w.Write([]byte(svg))
}
// ============================================================
// JPG extraction + preview endpoint
// Route:
// - /api/preview?id=<jobID> (returns preview.jpg / 204 / svg)
// - /api/preview?id=<jobID>&file=preview.jpg
// ============================================================
// --- JPG extraction helpers ---
func extractLastFrameJPG(path string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
cmd := exec.CommandContext(
ctx,
ffmpegPath,
"-hide_banner",
"-loglevel", "error",
"-sseof", "-0.25",
"-i", path,
"-map", "0:v:0",
"-an",
"-sn",
"-dn",
"-frames:v", "1",
"-vf", "scale=720:-2:flags=fast_bilinear",
"-vcodec", "mjpeg",
"-q:v", "4",
"-f", "image2pipe",
"pipe:1",
)
var out bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if ctx.Err() == context.DeadlineExceeded {
return nil, fmt.Errorf("ffmpeg last-frame jpg: timeout")
}
return nil, fmt.Errorf("ffmpeg last-frame jpg: %w (%s)", err, strings.TrimSpace(stderr.String()))
}
b := out.Bytes()
if len(b) == 0 {
return nil, fmt.Errorf("ffmpeg last-frame jpg: empty output")
}
return b, nil
}
func extractFrameAtTimeJPG(path string, seconds float64) ([]byte, error) {
if seconds < 0 {
seconds = 0
}
seek := fmt.Sprintf("%.3f", seconds)
cmd := exec.Command(
ffmpegPath,
"-hide_banner", "-loglevel", "error",
"-ss", seek,
"-i", path,
"-frames:v", "1",
"-vf", "scale=720:-2",
"-vcodec", "mjpeg",
"-q:v", "4",
"-f", "image2pipe",
"pipe:1",
)
var out bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("ffmpeg frame-at-time jpg: %w (%s)", err, strings.TrimSpace(stderr.String()))
}
b := out.Bytes()
if len(b) == 0 {
return nil, fmt.Errorf("ffmpeg frame-at-time jpg: empty output")
}
return b, nil
}
func extractLastFrameJPGScaled(path string, width int, quality int) ([]byte, error) {
if width <= 0 {
width = 320
}
if quality <= 0 || quality > 100 {
quality = 70
}
qv := "5"
if quality >= 80 {
qv = "3"
} else if quality >= 65 {
qv = "5"
} else {
qv = "7"
}
cmd := exec.Command(
ffmpegPath,
"-hide_banner", "-loglevel", "error",
"-sseof", "-0.25",
"-i", path,
"-frames:v", "1",
"-vf", fmt.Sprintf("scale=%d:-2", width),
"-vcodec", "mjpeg",
"-q:v", qv,
"-f", "image2pipe",
"pipe:1",
)
var out bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("ffmpeg last-frame scaled jpg: %w (%s)", err, strings.TrimSpace(stderr.String()))
}
b := out.Bytes()
if len(b) == 0 {
return nil, fmt.Errorf("ffmpeg last-frame scaled jpg: empty output")
}
return b, nil
}
func extractFirstFrameJPGScaled(path string, width int, quality int) ([]byte, error) {
if width <= 0 {
width = 320
}
if quality <= 0 || quality > 100 {
quality = 70
}
cmd := exec.Command(
ffmpegPath,
"-hide_banner", "-loglevel", "error",
"-ss", "0",
"-i", path,
"-frames:v", "1",
"-vf", fmt.Sprintf("scale=%d:-2", width),
"-vcodec", "mjpeg",
"-q:v", "5",
"-f", "image2pipe",
"pipe:1",
)
var out bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("ffmpeg first-frame scaled jpg: %w (%s)", err, strings.TrimSpace(stderr.String()))
}
b := out.Bytes()
if len(b) == 0 {
return nil, fmt.Errorf("ffmpeg first-frame scaled jpg: empty output")
}
return b, nil
}
func latestPreviewSegment(previewDir string) (string, error) {
entries, err := os.ReadDir(previewDir)
if err != nil {
return "", err
}
var best string
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if !strings.HasPrefix(name, "seg_low_") && !strings.HasPrefix(name, "seg_hq_") {
continue
}
if best == "" || name > best {
best = name
}
}
if best == "" {
return "", fmt.Errorf("kein Preview-Segment in %s", previewDir)
}
return filepath.Join(previewDir, best), nil
}
func extractLastFrameFromPreviewDirThumbJPG(previewDir string) ([]byte, error) {
seg, err := latestPreviewSegment(previewDir)
if err != nil {
return nil, err
}
img, err := extractLastFrameJPGScaled(seg, 320, 70)
if err == nil && len(img) > 0 {
return img, nil
}
return extractFirstFrameJPGScaled(seg, 320, 70)
}
func extractLastFrameFromPreviewDirJPG(previewDir string) ([]byte, error) {
seg, err := latestPreviewSegment(previewDir)
if err != nil {
return nil, err
}
img, err := extractLastFrameJPG(seg)
if err != nil {
return extractFirstFrameJPGScaled(seg, 720, 75)
}
return img, nil
}
func serveLivePreviewJPGFile(w http.ResponseWriter, r *http.Request, path string) {
f, err := os.Open(path)
if err != nil {
http.NotFound(w, r)
return
}
defer f.Close()
st, err := f.Stat()
if err != nil || st.IsDir() || st.Size() == 0 {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Cache-Control", "no-store")
http.ServeContent(w, r, "preview.jpg", st.ModTime(), f)
}
func servePreviewJPGFile(w http.ResponseWriter, r *http.Request, path string) {
f, err := os.Open(path)
if err != nil {
http.NotFound(w, r)
return
}
defer f.Close()
st, err := f.Stat()
if err != nil || st.IsDir() || st.Size() == 0 {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Cache-Control", "public, max-age=600")
http.ServeContent(w, r, filepath.Base(path), st.ModTime(), f)
}
func servePreviewJPGBytes(w http.ResponseWriter, b []byte) {
if len(b) == 0 {
w.WriteHeader(http.StatusNoContent)
return
}
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Cache-Control", "public, max-age=60")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(b)
}
func serveLivePreviewJPGBytes(w http.ResponseWriter, b []byte) {
if len(b) == 0 {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusNoContent)
return
}
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(b)
}
func servePreviewJPGAlias(w http.ResponseWriter, r *http.Request, id string) {
jobsMu.Lock()
job := jobs[id]
jobsMu.Unlock()
if job != nil {
assetID := assetIDForJob(job)
if assetID != "" {
if jpgPath, err := generatedThumbJPGFile(assetID); err == nil {
if st, err := os.Stat(jpgPath); err == nil && !st.IsDir() && st.Size() > 0 {
if job.Status == JobRunning {
serveLivePreviewJPGFile(w, r, jpgPath)
} else {
servePreviewJPGFile(w, r, jpgPath)
}
return
}
}
}
if job.Status == JobRunning {
job.previewMu.Lock()
cached := job.previewJPG
job.previewMu.Unlock()
if len(cached) > 0 {
serveLivePreviewJPGBytes(w, cached)
return
}
}
servePreviewStatusSVG(w, "Preview", http.StatusOK)
return
}
assetID := stripHotPrefix(strings.TrimSpace(id))
if assetID == "" {
http.NotFound(w, r)
return
}
if jpgPath, err := generatedThumbJPGFile(assetID); err == nil {
if st, err := os.Stat(jpgPath); err == nil && !st.IsDir() && st.Size() > 0 {
servePreviewJPGFile(w, r, jpgPath)
return
}
}
http.NotFound(w, r)
}
func recordPreview(w http.ResponseWriter, r *http.Request) {
// Standard: rewrite soll auf /api/preview zeigen
recordPreviewWithBase(w, r, "/api/preview")
}
func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath string) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
id := strings.TrimSpace(r.URL.Query().Get("id"))
if id == "" {
id = strings.TrimSpace(r.URL.Query().Get("name"))
}
if id == "" {
http.Error(w, "id fehlt", http.StatusBadRequest)
return
}
// file serving
if file := strings.TrimSpace(r.URL.Query().Get("file")); file != "" {
low := strings.ToLower(strings.TrimSpace(file))
// ✅ preview.jpg weiterhin hier behandeln
if low == "preview.jpg" {
servePreviewJPGAlias(w, r, id)
return
}
// ✅ alles andere (m3u8/ts/m4s/...) liegt jetzt in live.go
recordPreviewFile(w, r)
return
}
// JPG preview (running jobs have live thumb behavior)
jobsMu.Lock()
job, ok := jobs[id]
jobsMu.Unlock()
if ok {
if job.Status == JobRunning {
assetID := assetIDForJob(job)
if assetID != "" {
if jpgPath, err := generatedThumbJPGFile(assetID); err == nil {
if st, err := os.Stat(jpgPath); err == nil && !st.IsDir() && st.Size() > 0 {
serveLivePreviewJPGFile(w, r, jpgPath)
return
}
}
}
}
job.previewMu.Lock()
cached := job.previewJPG
cachedAt := job.previewJPGAt
fresh := len(cached) > 0 && !cachedAt.IsZero() && time.Since(cachedAt) < 8*time.Second
if !fresh && !job.previewGen {
job.previewGen = true
go func(j *RecordJob) {
defer func() {
j.previewMu.Lock()
j.previewGen = false
j.previewMu.Unlock()
}()
var img []byte
var genErr error
previewDir := strings.TrimSpace(j.PreviewDir)
if previewDir != "" {
img, genErr = extractLastFrameFromPreviewDirJPG(previewDir)
}
if genErr != nil || len(img) == 0 {
outPath := strings.TrimSpace(j.Output)
if outPath != "" {
outPath = filepath.Clean(outPath)
if !filepath.IsAbs(outPath) {
if abs, err := resolvePathRelativeToApp(outPath); err == nil {
outPath = abs
}
}
if fi, err := os.Stat(outPath); err == nil && !fi.IsDir() && fi.Size() > 0 {
img, genErr = extractLastFrameJPG(outPath)
if genErr != nil {
img, _ = extractFirstFrameJPGScaled(outPath, 720, 75)
}
}
}
}
if len(img) > 0 {
j.previewMu.Lock()
j.previewJPG = img
j.previewJPGAt = time.Now()
j.previewMu.Unlock()
}
}(job)
}
out := cached
job.previewMu.Unlock()
if len(out) > 0 {
serveLivePreviewJPGBytes(w, out)
return
}
jobsMu.Lock()
state := strings.TrimSpace(job.PreviewState)
jobsMu.Unlock()
if state == "private" {
servePreviewStatusSVG(w, "Private", http.StatusOK)
return
}
if state == "offline" {
servePreviewStatusSVG(w, "Offline", http.StatusOK)
return
}
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusNoContent)
return
}
// Finished file preview
servePreviewForFinishedFile(w, r, id)
}
func updateLiveThumbJPGOnce(ctx context.Context, job *RecordJob) {
jobsMu.Lock()
status := job.Status
previewDir := job.PreviewDir
out := job.Output
jobsMu.Unlock()
if status != JobRunning {
return
}
assetID := assetIDForJob(job)
thumbPath, err := generatedThumbJPGFile(assetID)
if err != nil {
return
}
if st, err := os.Stat(thumbPath); err == nil && st.Size() > 0 {
if time.Since(st.ModTime()) < 10*time.Second {
return
}
}
if thumbSem != nil {
thumbCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
if err := thumbSem.Acquire(thumbCtx); err != nil {
return
}
defer thumbSem.Release()
}
var img []byte
if previewDir != "" {
if b, err := extractLastFrameFromPreviewDirThumbJPG(previewDir); err == nil && len(b) > 0 {
img = b
}
}
if len(img) == 0 && out != "" {
if b, err := extractLastFrameJPGScaled(out, 320, 70); err == nil && len(b) > 0 {
img = b
}
}
if len(img) == 0 {
return
}
_ = atomicWriteFile(thumbPath, img)
}
func startLiveThumbJPGLoop(ctx context.Context, job *RecordJob) {
jobsMu.Lock()
if job.LiveThumbStarted {
jobsMu.Unlock()
return
}
job.LiveThumbStarted = true
jobsMu.Unlock()
go func() {
updateLiveThumbJPGOnce(ctx, job)
for {
select {
case <-ctx.Done():
return
case <-time.After(10 * time.Second):
jobsMu.Lock()
st := job.Status
jobsMu.Unlock()
if st != JobRunning {
return
}
updateLiveThumbJPGOnce(ctx, job)
}
}
}()
}
func servePreviewForFinishedFile(w http.ResponseWriter, r *http.Request, id string) {
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
}
if tStr := strings.TrimSpace(r.URL.Query().Get("t")); tStr != "" {
if sec, err := strconv.ParseFloat(tStr, 64); err == nil && sec >= 0 {
if sec < 0 {
sec = 0
}
img, err := extractFrameAtTimeJPG(outPath, sec)
if err == nil && len(img) > 0 {
servePreviewJPGBytes(w, img)
return
}
}
}
thumbPath := filepath.Join(assetDir, "preview.jpg")
if fi, err := os.Stat(thumbPath); err == nil && !fi.IsDir() && fi.Size() > 0 {
servePreviewJPGFile(w, r, thumbPath)
return
}
genCtx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
// ✅ Immer letztes Frame bevorzugen
img, err := extractLastFrameJPG(outPath)
if err != nil || len(img) == 0 {
// Fallback: kurz vor Ende, falls Duration verfügbar
if dur, derr := durationSecondsCached(genCtx, outPath); derr == nil && dur > 0 {
t := dur - 0.25
if t < 0 {
t = 0
}
img, err = extractFrameAtTimeJPG(outPath, t)
}
// Letzter Fallback: erstes Frame
if err != nil || len(img) == 0 {
img, err = extractFirstFrameJPGScaled(outPath, 720, 75)
if err != nil || len(img) == 0 {
http.Error(w, "konnte preview nicht erzeugen", http.StatusInternalServerError)
return
}
}
}
_ = atomicWriteFile(thumbPath, img)
servePreviewJPGBytes(w, img)
}
// ============================================================
// Teaser generation (used by /api/generated/teaser)
// ============================================================
const minSegmentDuration = 0.75
const defaultTeaserSegments = 12
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", "4",
)
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...)
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) {
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 && dur < segDur*float64(opts.Segments) {
opts.Segments = 1
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", "4",
)
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...)
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 fmt.Errorf("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
}
// temp schreiben -> rename
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),
// Video
"-vf", "scale=720:-2",
"-map", "0:v:0",
// Audio (optional: falls kein Audio vorhanden ist, bricht ffmpeg NICHT ab)
"-map", "0:a:0",
"-c:a", "aac",
"-b:a", "128k",
"-ac", "2",
"-c:v", "libx264",
"-preset", "veryfast",
"-crf", "28",
"-pix_fmt", "yuv420p",
// Wenn Audio minimal kürzer/länger ist, sauber beenden
"-shortest",
"-movflags", "+faststart",
"-f", "mp4",
tmp,
)
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
if out, err := cmd.CombinedOutput(); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("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")
// ✅ NEU: noGenerate=1 -> niemals on-the-fly erzeugen, nur liefern wenn vorhanden
qNoGen := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("noGenerate")))
noGen := qNoGen == "1" || qNoGen == "true" || qNoGen == "yes"
// Cache hit (neu)
if fi, err := os.Stat(previewPath); err == nil && !fi.IsDir() && fi.Size() > 0 {
serveTeaserFile(w, r, previewPath)
return
}
// Legacy: generated/teaser/<id>_teaser.mp4 oder <id>.mp4
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
}
}
}
}
// ✅ NEU: wenn noGenerate aktiv und bisher kein Teaser gefunden -> 404
if noGen {
http.Error(w, "preview nicht verfügbar", http.StatusNotFound)
return
}
// Neu erzeugen
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, 1.0, 18); err != nil {
// Fallback: einzelner kurzer Teaser ab Anfang (trifft seltener kaputte Stellen)
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)
}