729 lines
18 KiB
Go
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, "thumbs.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: thumbs.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 == "thumbs.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>/thumbs.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 thumbs.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>/thumbs.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, "thumbs.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)
|
|
}
|