// 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" "log" "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, generatedThumbWebPFile // 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/. + 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/webp"): return ".webp", "image/webp" 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 ".webp", "image/webp" } 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", ".webp", ".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 ".webp": ct = "image/webp" 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 log.Printf("[cover] BATCH START") } 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-- if coverBatchInflight <= 0 { dur := time.Since(coverBatchStarted).Round(time.Millisecond) log.Printf( "[cover] BATCH END total=%d miss=%d forced=%d errors=%d noThumb=%d decodeFail=%d took=%s", coverBatchTotal, coverBatchMiss, coverBatchForced, coverBatchErrors, coverBatchNoThumb, coverBatchDecodeErr, dur, ) coverBatchInflight = 0 } } 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", ".webp", ".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 ".webp": w.Header().Set("Content-Type", "image/webp") 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 ".webp": w.Header().Set("Content-Type", "image/webp") 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", ".webp", ".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 := ` ` + title + ` Preview nicht verfügbar ` w.WriteHeader(status) _, _ = w.Write([]byte(svg)) } // ============================================================ // WebP extraction + preview endpoint // Route: // - /api/preview?id= (returns preview.webp / 204 / svg) // - /api/preview?id=&file=preview.webp // ============================================================ // --- WebP extraction helpers --- func extractLastFrameWebP(path string) ([]byte, error) { cmd := exec.Command( ffmpegPath, "-hide_banner", "-loglevel", "error", "-sseof", "-0.1", "-i", path, "-frames:v", "1", "-vf", "scale=720:-2", "-quality", "75", "-f", "image2pipe", "-vcodec", "libwebp", "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 webp: %w (%s)", err, strings.TrimSpace(stderr.String())) } b := out.Bytes() if len(b) == 0 { return nil, fmt.Errorf("ffmpeg last-frame webp: empty output") } return b, nil } func extractFrameAtTimeWebP(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", "-quality", "75", "-f", "image2pipe", "-vcodec", "libwebp", "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 webp: %w (%s)", err, strings.TrimSpace(stderr.String())) } b := out.Bytes() if len(b) == 0 { return nil, fmt.Errorf("ffmpeg frame-at-time webp: empty output") } return b, nil } func extractLastFrameWebPScaled(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", "-sseof", "-0.25", "-i", path, "-frames:v", "1", "-vf", fmt.Sprintf("scale=%d:-2", width), "-quality", strconv.Itoa(quality), "-f", "image2pipe", "-vcodec", "libwebp", "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 webp: %w (%s)", err, strings.TrimSpace(stderr.String())) } b := out.Bytes() if len(b) == 0 { return nil, fmt.Errorf("ffmpeg last-frame scaled webp: empty output") } return b, nil } func extractFirstFrameWebPScaled(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), "-quality", strconv.Itoa(quality), "-f", "image2pipe", "-vcodec", "libwebp", "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 webp: %w (%s)", err, strings.TrimSpace(stderr.String())) } b := out.Bytes() if len(b) == 0 { return nil, fmt.Errorf("ffmpeg first-frame scaled webp: 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 extractLastFrameFromPreviewDirThumbWebP(previewDir string) ([]byte, error) { seg, err := latestPreviewSegment(previewDir) if err != nil { return nil, err } img, err := extractLastFrameWebPScaled(seg, 320, 70) if err == nil && len(img) > 0 { return img, nil } return extractFirstFrameWebPScaled(seg, 320, 70) } func extractLastFrameFromPreviewDirWebP(previewDir string) ([]byte, error) { seg, err := latestPreviewSegment(previewDir) if err != nil { return nil, err } img, err := extractLastFrameWebP(seg) if err != nil { return extractFirstFrameWebPScaled(seg, 720, 75) } return img, nil } func serveLivePreviewWebPFile(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/webp") w.Header().Set("Cache-Control", "no-store") http.ServeContent(w, r, "preview.webp", st.ModTime(), f) } func servePreviewWebPFile(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/webp") w.Header().Set("Cache-Control", "public, max-age=600") http.ServeContent(w, r, filepath.Base(path), st.ModTime(), f) } func servePreviewWebPBytes(w http.ResponseWriter, b []byte) { if len(b) == 0 { w.WriteHeader(http.StatusNoContent) return } w.Header().Set("Content-Type", "image/webp") w.Header().Set("Cache-Control", "public, max-age=60") w.WriteHeader(http.StatusOK) _, _ = w.Write(b) } func serveLivePreviewWebPBytes(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/webp") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(http.StatusOK) _, _ = w.Write(b) } func servePreviewWebPAlias(w http.ResponseWriter, r *http.Request, id string) { jobsMu.Lock() job := jobs[id] jobsMu.Unlock() if job != nil { assetID := assetIDForJob(job) if assetID != "" { if webpPath, err := generatedThumbWebPFile(assetID); err == nil { if st, err := os.Stat(webpPath); err == nil && !st.IsDir() && st.Size() > 0 { if job.Status == JobRunning { serveLivePreviewWebPFile(w, r, webpPath) } else { servePreviewWebPFile(w, r, webpPath) } return } } } if job.Status == JobRunning { job.previewMu.Lock() cached := job.previewWebp job.previewMu.Unlock() if len(cached) > 0 { serveLivePreviewWebPBytes(w, cached) return } } servePreviewStatusSVG(w, "Preview", http.StatusOK) return } assetID := stripHotPrefix(strings.TrimSpace(id)) if assetID == "" { http.NotFound(w, r) return } if webpPath, err := generatedThumbWebPFile(assetID); err == nil { if st, err := os.Stat(webpPath); err == nil && !st.IsDir() && st.Size() > 0 { servePreviewWebPFile(w, r, webpPath) 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 } // HLS / file serving if file := strings.TrimSpace(r.URL.Query().Get("file")); file != "" { low := strings.ToLower(file) if low == "preview.webp" { servePreviewWebPAlias(w, r, id) return } // ✅ Wichtig: HLS rewrite soll auf basePath zeigen (/api/preview oder /api/preview/live) servePreviewHLSFileWithBase(w, r, id, file, basePath) return } // WebP 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 webpPath, err := generatedThumbWebPFile(assetID); err == nil { if st, err := os.Stat(webpPath); err == nil && !st.IsDir() && st.Size() > 0 { serveLivePreviewWebPFile(w, r, webpPath) return } } } } job.previewMu.Lock() cached := job.previewWebp cachedAt := job.previewWebpAt 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 = extractLastFrameFromPreviewDirWebP(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 = extractLastFrameWebP(outPath) if genErr != nil { img, _ = extractFirstFrameWebPScaled(outPath, 720, 75) } } } } if len(img) > 0 { j.previewMu.Lock() j.previewWebp = img j.previewWebpAt = time.Now() j.previewMu.Unlock() } }(job) } out := cached job.previewMu.Unlock() if len(out) > 0 { serveLivePreviewWebPBytes(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 updateLiveThumbWebPOnce(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 := generatedThumbWebPFile(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 := extractLastFrameFromPreviewDirThumbWebP(previewDir); err == nil && len(b) > 0 { img = b } } if len(img) == 0 && out != "" { if b, err := extractLastFrameWebPScaled(out, 320, 70); err == nil && len(b) > 0 { img = b } } if len(img) == 0 { return } _ = atomicWriteFile(thumbPath, img) } func startLiveThumbWebPLoop(ctx context.Context, job *RecordJob) { jobsMu.Lock() if job.LiveThumbStarted { jobsMu.Unlock() return } job.LiveThumbStarted = true jobsMu.Unlock() go func() { updateLiveThumbWebPOnce(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 } updateLiveThumbWebPOnce(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 { secI := int64(sec + 0.5) if secI < 0 { secI = 0 } framePath := filepath.Join(assetDir, fmt.Sprintf("t_%d.webp", secI)) if fi, err := os.Stat(framePath); err == nil && !fi.IsDir() && fi.Size() > 0 { servePreviewWebPFile(w, r, framePath) return } img, err := extractFrameAtTimeWebP(outPath, float64(secI)) if err == nil && len(img) > 0 { _ = atomicWriteFile(framePath, img) servePreviewWebPBytes(w, img) return } } } thumbPath := filepath.Join(assetDir, "preview.webp") if fi, err := os.Stat(thumbPath); err == nil && !fi.IsDir() && fi.Size() > 0 { servePreviewWebPFile(w, r, thumbPath) return } genCtx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() // ✅ Immer letztes Frame bevorzugen img, err := extractLastFrameWebP(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 = extractFrameAtTimeWebP(outPath, t) } // Letzter Fallback: erstes Frame if err != nil || len(img) == 0 { img, err = extractFirstFrameWebPScaled(outPath, 720, 75) if err != nil || len(img) == 0 { http.Error(w, "konnte preview nicht erzeugen", http.StatusInternalServerError) return } } } _ = atomicWriteFile(thumbPath, img) servePreviewWebPBytes(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/_teaser.mp4 oder .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) }