nsfwapp/backend/preview_webp.go
2026-03-02 18:19:18 +01:00

729 lines
18 KiB
Go

// backend\preview_webp.go
package main
import (
"bytes"
"context"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
// ------------------------------------------------------------
// Frame extraction helpers (WebP only)
// ------------------------------------------------------------
// extractLastFrameWebP extrahiert ein WebP aus dem letzten Frame der Datei.
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
}
// extractFrameAtTimeWebP extrahiert ein WebP an einer Zeitposition (Sekunden).
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
}
// extractLastFrameWebPScaled extrahiert ein WebP aus dem letzten Frame und skaliert auf width (Höhe automatisch).
// quality: 0..100 (ffmpeg -quality)
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
}
// extractFirstFrameWebPScaled extrahiert ein WebP aus dem ersten Frame und skaliert auf width.
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
}
// sucht das "neueste" Preview-Segment (seg_low_XXXXX.ts / seg_hq_XXXXX.ts)
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
}
// extractLastFrameFromPreviewDirThumbWebP erzeugt ein kleines WebP aus dem letzten Preview-Segment.
func extractLastFrameFromPreviewDirThumbWebP(previewDir string) ([]byte, error) {
seg, err := latestPreviewSegment(previewDir)
if err != nil {
return nil, err
}
// low-res, notfalls fallback auf erstes Frame
img, err := extractLastFrameWebPScaled(seg, 320, 70)
if err == nil && len(img) > 0 {
return img, nil
}
return extractFirstFrameWebPScaled(seg, 320, 70)
}
// extractLastFrameFromPreviewDirWebP erzeugt ein WebP aus dem letzten Preview-Segment.
func extractLastFrameFromPreviewDirWebP(previewDir string) ([]byte, error) {
seg, err := latestPreviewSegment(previewDir)
if err != nil {
return nil, err
}
img, err := extractLastFrameWebP(seg)
if err != nil {
// extractFirstFrameWebP muss bei dir existieren oder du implementierst es analog wie oben;
// wenn du es nicht hast, nimm scaled-first als fallback.
return extractFirstFrameWebPScaled(seg, 720, 75)
}
return img, nil
}
// ------------------------------------------------------------
// Preview serving (webp only)
// ------------------------------------------------------------
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")
// finished previews dürfen cachen
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)
}
// ------------------------------------------------------------
// Preview alias: preview.webp / preview.webp (webp only)
// ------------------------------------------------------------
func servePreviewWebPAlias(w http.ResponseWriter, r *http.Request, id string) {
// 1) Wenn Job bekannt (id = job.ID): assetID aus Output ableiten
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
}
}
}
// Optional: running in-memory fallback (nur WebP)
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
}
// 2) Kein Job im RAM: id als assetID behandeln (finished files nach Neustart)
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 isHover(r *http.Request) bool {
v := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("hover")))
return v == "1" || v == "true" || v == "yes"
}
func touchPreview(job *RecordJob) {
if job == nil {
return
}
jobsMu.Lock()
job.previewLastHit = time.Now()
jobsMu.Unlock()
}
func ensurePreviewStarted(r *http.Request, job *RecordJob) {
if job == nil {
return
}
job.previewStartMu.Lock()
defer job.previewStartMu.Unlock()
jobsMu.Lock()
// läuft schon?
if job.previewCmd != nil && job.PreviewDir != "" {
job.previewLastHit = time.Now()
jobsMu.Unlock()
return
}
// brauchen M3U8 URL
m3u8 := strings.TrimSpace(job.PreviewM3U8)
cookie := strings.TrimSpace(job.PreviewCookie)
ua := strings.TrimSpace(job.PreviewUA)
jobsMu.Unlock()
if m3u8 == "" {
return
}
// eigener Context für Preview (WICHTIG: nicht der Recording ctx)
pctx, cancel := context.WithCancel(context.Background())
// PreviewDir temp
assetID := assetIDForJob(job)
pdir := filepath.Join(os.TempDir(), "rec_preview", assetID)
jobsMu.Lock()
job.PreviewDir = pdir
job.previewCancel = cancel
job.previewLastHit = time.Now()
jobsMu.Unlock()
_ = startPreviewHLS(pctx, job, m3u8, pdir, cookie, ua)
}
func recordPreview(w http.ResponseWriter, r *http.Request) {
// nur GET/HEAD erlauben
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 == "" {
// Alias: Frontend schickt "name"
id = strings.TrimSpace(r.URL.Query().Get("name"))
}
if id == "" {
http.Error(w, "id fehlt", http.StatusBadRequest)
return
}
// Image / HLS file requests abfangen
if file := strings.TrimSpace(r.URL.Query().Get("file")); file != "" {
low := strings.ToLower(file)
// ✅ NUR WEBP
if low == "preview.webp" || low == "preview.webp" {
servePreviewWebPAlias(w, r, id)
return
}
// HLS wie gehabt
servePreviewHLSFile(w, r, id, file)
return
}
// Schauen, ob wir einen Job mit dieser ID kennen (laufend oder gerade fertig)
jobsMu.Lock()
job, ok := jobs[id]
jobsMu.Unlock()
if ok {
// ✅ 0) Running: wenn generated/<assetID>/preview.webp existiert -> sofort ausliefern
// (kein ffmpeg pro HTTP-Request)
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
}
}
}
}
// ✅ Fallback: In-Memory-Cache (falls preview.webp noch nicht da ist)
job.previewMu.Lock()
cached := job.previewWebp
cachedAt := job.previewWebpAt
freshWindow := 8 * time.Second
fresh := len(cached) > 0 && !cachedAt.IsZero() && time.Since(cachedAt) < freshWindow
// Wenn nicht frisch, ggf. im Hintergrund aktualisieren (einmal gleichzeitig)
if !fresh && !job.previewGen {
job.previewGen = true
go func(j *RecordJob, jobID string) {
defer func() {
j.previewMu.Lock()
j.previewGen = false
j.previewMu.Unlock()
}()
var img []byte
var genErr error
// 1) aus Preview-Segmenten
previewDir := strings.TrimSpace(j.PreviewDir)
if previewDir != "" {
img, genErr = extractLastFrameFromPreviewDirWebP(previewDir)
}
// 2) Fallback: aus der Ausgabedatei
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 {
// fallback: erster Frame skaliert
img, _ = extractFirstFrameWebPScaled(outPath, 720, 75)
}
}
}
}
if len(img) > 0 {
j.previewMu.Lock()
j.previewWebp = img
j.previewWebpAt = time.Now()
j.previewMu.Unlock()
}
}(job, id)
}
// Wir liefern entweder ein frisches Bild, oder das zuletzt gecachte.
out := cached
job.previewMu.Unlock()
if len(out) > 0 {
serveLivePreviewWebPBytes(w, out) // no-store für laufende Jobs
return
}
// Wenn Preview definitiv nicht geht -> Placeholder statt 204
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
}
// noch kein Bild verfügbar -> 204 (Frontend zeigt Placeholder und retry)
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusNoContent)
return
}
// Kein Job im RAM → id als Dateistamm für fertige Downloads behandeln
servePreviewForFinishedFile(w, r, id)
}
// ------------------------------------------------------------
// Live thumbs generator (WebP)
// ------------------------------------------------------------
func updateLiveThumbWebPOnce(ctx context.Context, job *RecordJob) {
// Snapshot unter Lock holen
jobsMu.Lock()
status := job.Status
previewDir := job.PreviewDir
out := job.Output
jobsMu.Unlock()
if status != JobRunning {
return
}
// Zielpfad: generated/<assetID>/preview.webp
assetID := assetIDForJob(job)
thumbPath, err := generatedThumbWebPFile(assetID)
if err != nil {
return
}
// Wenn frisch genug: skip
if st, err := os.Stat(thumbPath); err == nil && st.Size() > 0 {
if time.Since(st.ModTime()) < 10*time.Second {
return
}
}
// Concurrency limit über thumbSem
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
// 1) bevorzugt aus Preview-Segmenten
if previewDir != "" {
if b, err := extractLastFrameFromPreviewDirThumbWebP(previewDir); err == nil && len(b) > 0 {
img = b
}
}
// 2) fallback aus Output-Datei
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) {
// einmalig starten
jobsMu.Lock()
if job.LiveThumbStarted {
jobsMu.Unlock()
return
}
job.LiveThumbStarted = true
jobsMu.Unlock()
go func() {
// sofort einmal versuchen
updateLiveThumbWebPOnce(ctx, job)
for {
delay := 10 * time.Second
select {
case <-ctx.Done():
return
case <-time.After(delay):
// Stoppen, sobald Job nicht mehr läuft
jobsMu.Lock()
st := job.Status
jobsMu.Unlock()
if st != JobRunning {
return
}
updateLiveThumbWebPOnce(ctx, job)
}
}
}()
}
// ------------------------------------------------------------
// Finished file preview (WebP only, no legacy jpg migration)
// ------------------------------------------------------------
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
}
// Assets immer auf "basename ohne HOT" ablegen
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
}
// Frame-Caching für t=... (WebP)
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")
// 1) Cache hit
if fi, err := os.Stat(thumbPath); err == nil && !fi.IsDir() && fi.Size() > 0 {
servePreviewWebPFile(w, r, thumbPath)
return
}
// 2) Neu erzeugen
genCtx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
var t float64 = 0
if dur, derr := durationSecondsCached(genCtx, outPath); derr == nil && dur > 0 {
t = dur * 0.5
}
img, err := extractFrameAtTimeWebP(outPath, t)
if err != nil || len(img) == 0 {
img, err = extractLastFrameWebP(outPath)
if err != nil || len(img) == 0 {
// fallback: erster Frame skaliert
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)
}