2290 lines
54 KiB
Go
2290 lines
54 KiB
Go
// 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)
|
||
}
|