// 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//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//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) }