This commit is contained in:
Linrador 2026-02-20 18:18:59 +01:00
parent 6cd9dcd41e
commit 478e2696da
52 changed files with 9171 additions and 6026 deletions

View File

@ -4,7 +4,9 @@ package main
import (
"context"
"errors"
"fmt"
"math"
"os"
"path/filepath"
"strings"
@ -40,94 +42,160 @@ func u64ToI64(x uint64) int64 {
return int64(x)
}
// -------------------------
// Asset layout helpers
// -------------------------
// Asset "ID" = Dateiname ohne Endung (immer OHNE "HOT " Prefix)
func assetIDFromVideoPath(videoPath string) string {
base := filepath.Base(strings.TrimSpace(videoPath))
if base == "" {
return ""
}
id := strings.TrimSuffix(base, filepath.Ext(base))
id = stripHotPrefix(id)
return strings.TrimSpace(id)
}
// Liefert die standardisierten Pfade (thumbs.webp / preview.mp4 / meta.json)
func assetPathsForID(id string) (assetDir, thumbPath, previewPath, metaPath string, err error) {
id = strings.TrimSpace(id)
if id == "" {
return "", "", "", "", fmt.Errorf("empty id")
}
assetDir, err = ensureGeneratedDir(id)
if err != nil || strings.TrimSpace(assetDir) == "" {
return "", "", "", "", fmt.Errorf("generated dir: %v", err)
}
thumbPath = filepath.Join(assetDir, "thumbs.webp")
previewPath = filepath.Join(assetDir, "preview.mp4")
metaPath, _ = metaJSONPathForAssetID(id) // bevorzugt generated/meta/<id>/meta.json
if strings.TrimSpace(metaPath) == "" {
metaPath = filepath.Join(assetDir, "meta.json")
}
return assetDir, thumbPath, previewPath, metaPath, nil
}
type ensuredMeta struct {
durSec float64
vw, vh int
fps float64
sourceURL string
ok bool // ok = duration + props vorhanden
}
// Stellt sicher:
// - Duration ist berechnet (cache ok)
// - Props (w/h/fps) sind drin (ffprobe wenn nötig)
// - meta.json wird (best-effort) geschrieben, inkl. sourceURL
func ensureVideoMeta(ctx context.Context, videoPath, metaPath, sourceURL string, vfi os.FileInfo) (ensuredMeta, error) {
out := ensuredMeta{sourceURL: strings.TrimSpace(sourceURL)}
videoPath = strings.TrimSpace(videoPath)
metaPath = strings.TrimSpace(metaPath)
if videoPath == "" || metaPath == "" {
return out, nil
}
if vfi == nil || vfi.IsDir() || vfi.Size() <= 0 {
return out, nil
}
// 1) Try cache/meta first (inkl. w/h/fps)
if d, mw, mh, mfps, ok := readVideoMeta(metaPath, vfi); ok {
out.durSec, out.vw, out.vh, out.fps = d, mw, mh, mfps
} else {
// 2) Duration berechnen
dctx, cancel := context.WithTimeout(ctx, 6*time.Second)
d, derr := durationSecondsCached(dctx, videoPath)
cancel()
if derr == nil && d > 0 {
out.durSec = d
}
}
// 3) Props ggf. nachziehen (ffprobe)
if out.durSec > 0 && (out.vw <= 0 || out.vh <= 0 || out.fps <= 0) {
pctx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
if durSem != nil {
if err := durSem.Acquire(pctx); err == nil {
out.vw, out.vh, out.fps, _ = probeVideoProps(pctx, videoPath)
durSem.Release()
}
} else {
out.vw, out.vh, out.fps, _ = probeVideoProps(pctx, videoPath)
}
}
// 4) meta.json schreiben/aktualisieren (best-effort)
if out.durSec > 0 {
_ = writeVideoMeta(metaPath, vfi, out.durSec, out.vw, out.vh, out.fps, out.sourceURL)
}
out.ok = out.durSec > 0 && out.vw > 0 && out.vh > 0
return out, nil
}
type EnsureAssetsResult struct {
Skipped bool
ThumbGenerated bool
PreviewGenerated bool
MetaOK bool
}
// Public wrappers (kompatibel zu deinem bisherigen API)
func ensureAssetsForVideo(videoPath string) error {
// Default: keine SourceURL (für Covers egal)
return ensureAssetsForVideoWithProgress(videoPath, "", nil)
}
// Optional: für Stellen, wo du die URL hast (z.B. Postwork / Jobs)
func ensureAssetsForVideoWithSource(videoPath string, sourceURL string) error {
return ensureAssetsForVideoWithProgress(videoPath, sourceURL, nil)
}
// onRatio: 0..1 (Assets-Gesamtfortschritt)
func ensureAssetsForVideoWithProgress(videoPath string, sourceURL string, onRatio func(r float64)) error {
ctx := context.Background()
_, err := ensureAssetsForVideoWithProgressCtx(ctx, videoPath, sourceURL, onRatio)
return err
}
// Task/Bulk sollte diesen Context-aware Call nutzen.
func ensureAssetsForVideoWithProgressCtx(ctx context.Context, videoPath string, sourceURL string, onRatio func(r float64)) (EnsureAssetsResult, error) {
res, err := ensureAssetsForVideoDetailed(ctx, videoPath, sourceURL, onRatio)
return res, err
}
// Core: generiert thumbs/preview/meta und sagt zurück was passiert ist.
func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceURL string, onRatio func(r float64)) (EnsureAssetsResult, error) {
var out EnsureAssetsResult
videoPath = strings.TrimSpace(videoPath)
if videoPath == "" {
return nil
return out, nil
}
fi, statErr := os.Stat(videoPath)
if statErr != nil || fi.IsDir() || fi.Size() <= 0 {
return nil
return out, nil
}
// ✅ ID = Dateiname ohne Endung (immer OHNE "HOT " Prefix)
base := filepath.Base(videoPath)
id := strings.TrimSuffix(base, filepath.Ext(base))
id = stripHotPrefix(id)
if strings.TrimSpace(id) == "" {
return nil
id := assetIDFromVideoPath(videoPath)
if id == "" {
return out, nil
}
assetDir, gerr := ensureGeneratedDir(id)
if gerr != nil || strings.TrimSpace(assetDir) == "" {
return fmt.Errorf("generated dir: %v", gerr)
_, thumbPath, previewPath, metaPath, perr := assetPathsForID(id)
if perr != nil {
return out, perr
}
metaPath := filepath.Join(assetDir, "meta.json")
// ---- Meta / Duration + Props (Width/Height/FPS/Resolution) ----
durSec := 0.0
vw, vh := 0, 0
fps := 0.0
// 1) Try cache (Meta) first (inkl. w/h/fps)
if d, mw, mh, mfps, ok := readVideoMeta(metaPath, fi); ok {
durSec, vw, vh, fps = d, mw, mh, mfps
} else {
// 2) Duration berechnen
dctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
d, derr := durationSecondsCached(dctx, videoPath)
cancel()
if derr == nil && d > 0 {
durSec = d
}
}
// 3) Wenn wir Duration haben, aber Props fehlen -> ffprobe holen und Voll-Meta schreiben
// (damit resolution wirklich in meta.json landet)
if durSec > 0 && (vw <= 0 || vh <= 0 || fps <= 0) {
pctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
// optional: durSem für ffprobe begrenzen (du hast es global)
if durSem != nil {
if err := durSem.Acquire(pctx); err == nil {
vw, vh, fps, _ = probeVideoProps(pctx, videoPath)
durSem.Release()
} else {
// wenn Acquire fehlschlägt, best-effort ohne props
}
} else {
vw, vh, fps, _ = probeVideoProps(pctx, videoPath)
}
}
// 4) Meta schreiben/aktualisieren:
// - schreibt resolution (über formatResolution) nur wenn vw/vh > 0
// - schreibt sourceURL wenn vorhanden
if durSec > 0 {
_ = writeVideoMeta(metaPath, fi, durSec, vw, vh, fps, sourceURL)
}
// Gewichte: thumbs klein, preview groß
const (
thumbsW = 0.25
previewW = 0.75
)
progress := func(r float64) {
if onRatio == nil {
return
@ -141,90 +209,163 @@ func ensureAssetsForVideoWithProgress(videoPath string, sourceURL string, onRati
onRatio(r)
}
// Vorher-Checks (für Result)
thumbBefore := func() bool {
if tfi, err := os.Stat(thumbPath); err == nil && !tfi.IsDir() && tfi.Size() > 0 {
return true
}
return false
}()
previewBefore := func() bool {
if pfi, err := os.Stat(previewPath); err == nil && !pfi.IsDir() && pfi.Size() > 0 {
return true
}
return false
}()
// Meta sicherstellen (dedupliziert)
meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi)
out.MetaOK = meta.ok
// Wenn alles da ist: skipped
if thumbBefore && previewBefore && meta.ok {
out.Skipped = true
progress(1)
return out, nil
}
// Gewichte: thumbs klein, preview groß
const (
thumbsW = 0.25
previewW = 0.75
)
progress(0)
// ----------------
// Thumbs
// Thumbs (WebP-only)
// ----------------
thumbPath := filepath.Join(assetDir, "thumbs.jpg")
if tfi, err := os.Stat(thumbPath); err == nil && !tfi.IsDir() && tfi.Size() > 0 {
if thumbBefore {
progress(thumbsW)
} else {
progress(0.05)
genCtx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
func() {
genCtx, cancel := context.WithTimeout(ctx, 45*time.Second)
defer cancel()
if err := thumbSem.Acquire(genCtx); err != nil {
// best-effort
progress(thumbsW)
goto PREVIEW
}
defer thumbSem.Release()
// Acquire; wenn Context cancelled → Fehler zurück
if err := thumbSem.Acquire(genCtx); err != nil {
// wenn ctx cancelled -> hart zurück, sonst best-effort weiter
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return
}
return
}
defer thumbSem.Release()
progress(0.10)
progress(0.10)
t := 0.0
if durSec > 0 {
t = durSec * 0.5
}
t := 0.0
if meta.durSec > 0 {
t = meta.durSec * 0.5
}
progress(0.15)
progress(0.15)
img, e1 := extractFrameAtTimeJPEG(videoPath, t)
if e1 != nil || len(img) == 0 {
img, e1 = extractLastFrameJPEG(videoPath)
img, e1 := extractFrameAtTimeWebP(videoPath, t)
if e1 != nil || len(img) == 0 {
img, e1 = extractFirstFrameJPEG(videoPath)
img, e1 = extractLastFrameWebP(videoPath)
if e1 != nil || len(img) == 0 {
img, e1 = extractFirstFrameWebPScaled(videoPath, 720, 75)
}
}
}
progress(0.20)
progress(0.20)
if e1 == nil && len(img) > 0 {
if err := atomicWriteFile(thumbPath, img); err != nil {
fmt.Println("⚠️ thumb write:", err)
if e1 == nil && len(img) > 0 {
if err := atomicWriteFile(thumbPath, img); err == nil {
out.ThumbGenerated = true
} else {
fmt.Println("⚠️ thumb write:", err)
}
}
}
}()
progress(thumbsW)
}
PREVIEW:
// ----------------
// Preview
// ----------------
previewPath := filepath.Join(assetDir, "preview.mp4")
if pfi, err := os.Stat(previewPath); err == nil && !pfi.IsDir() && pfi.Size() > 0 {
if previewBefore {
progress(1)
return nil
return out, nil
}
genCtx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
func() {
genCtx, cancel := context.WithTimeout(ctx, 3*time.Minute)
defer cancel()
progress(thumbsW + 0.02)
progress(thumbsW + 0.02)
if err := genSem.Acquire(genCtx); err != nil {
progress(1)
return nil
}
defer genSem.Release()
progress(thumbsW + 0.05)
if err := generateTeaserClipsMP4WithProgress(genCtx, videoPath, previewPath, 1.0, 18, func(r float64) {
if r < 0 {
r = 0
if err := genSem.Acquire(genCtx); err != nil {
return
}
if r > 1 {
r = 1
defer genSem.Release()
progress(thumbsW + 0.05)
if err := generateTeaserClipsMP4WithProgress(genCtx, videoPath, previewPath, 1.0, 18, func(r float64) {
if r < 0 {
r = 0
}
if r > 1 {
r = 1
}
progress(thumbsW + r*previewW)
}); err != nil {
fmt.Println("⚠️ preview clips:", err)
return
}
progress(thumbsW + r*previewW)
}); err != nil {
fmt.Println("⚠️ preview clips:", err)
}
out.PreviewGenerated = true
// ✅ Preview-Clips (Starts + Dur) in meta.json schreiben (best-effort)
func() {
// nur wenn wir die Original-Dauer kennen
if !(meta.durSec > 0) {
return
}
// muss identisch zu generateTeaserClipsMP4WithProgress Defaults sein
opts := TeaserPreviewOptions{
Segments: 18,
SegmentDuration: 1.0,
Width: 640,
Preset: "veryfast",
CRF: 21,
Audio: true,
AudioBitrate: "128k",
UseVsync2: false,
}
starts, segDur, _ := computeTeaserStarts(meta.durSec, opts)
clips := make([]previewClip, 0, len(starts))
for _, s := range starts {
clips = append(clips, previewClip{
StartSeconds: math.Round(s*1000) / 1000, // 3 decimals wie ffmpeg arg
DurationSeconds: math.Round(segDur*1000) / 1000, // 3 decimals
})
}
// Originalvideo-fi (nicht preview-fi!), damit Validierung konsistent bleibt
_ = writeVideoMetaWithPreviewClips(metaPath, fi, meta.durSec, meta.vw, meta.vh, meta.fps, meta.sourceURL, clips)
}()
}()
progress(1)
return nil
return out, nil
}

View File

@ -11,6 +11,7 @@ import (
"io"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"time"
@ -57,6 +58,13 @@ type ChaturbateOnlineRoomLite struct {
CurrentShow string `json:"current_show"`
ChatRoomURL string `json:"chat_room_url"`
ImageURL string `json:"image_url"`
// fürs Filtern
Gender string `json:"gender"`
Country string `json:"country"`
NumUsers int `json:"num_users"`
IsHD bool `json:"is_hd"`
Tags []string `json:"tags"`
}
type chaturbateCache struct {
@ -85,6 +93,74 @@ var (
cbRefreshInFlight bool
)
func normalizeList(in []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(in))
for _, s := range in {
s = strings.ToLower(strings.TrimSpace(s))
if s == "" || seen[s] {
continue
}
seen[s] = true
out = append(out, s)
}
sort.Strings(out)
return out
}
func keysOfSet(m map[string]bool) []string {
if len(m) == 0 {
return nil
}
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
func toSet(list []string) map[string]bool {
if len(list) == 0 {
return nil
}
m := make(map[string]bool, len(list))
for _, s := range list {
m[s] = true
}
return m
}
func tagsAnyMatch(tags []string, allowed map[string]bool) bool {
if len(allowed) == 0 {
return true
}
for _, t := range tags {
t = strings.ToLower(strings.TrimSpace(t))
if allowed[t] {
return true
}
}
return false
}
func derefInt(p *int) string {
if p == nil {
return "any"
}
return strconv.Itoa(*p)
}
func derefBool(p *bool) string {
if p == nil {
return "any"
}
if *p {
return "true"
}
return "false"
}
// setChaturbateOnlineModelStore wird einmal beim Startup aufgerufen.
func setChaturbateOnlineModelStore(store *ModelStore) {
cbModelStore = store
@ -161,6 +237,12 @@ func indexLiteByUser(rooms []ChaturbateRoom) map[string]ChaturbateOnlineRoomLite
CurrentShow: rm.CurrentShow,
ChatRoomURL: rm.ChatRoomURL,
ImageURL: rm.ImageURL,
Gender: rm.Gender,
Country: rm.Country,
NumUsers: rm.NumUsers,
IsHD: rm.IsHD,
Tags: rm.Tags,
}
}
return m
@ -298,9 +380,17 @@ func setCachedOnline(key string, body []byte) {
}
type cbOnlineReq struct {
Q []string `json:"q"` // usernames
Show []string `json:"show"` // public/private/hidden/away
Refresh bool `json:"refresh"`
Q []string `json:"q"` // usernames
Show []string `json:"show"` // public/private/hidden/away
// neue Filter
Gender []string `json:"gender"` // m/f/c/t/s ... (was die API liefert)
Country []string `json:"country"` // country codes/names (wie in API)
MinUsers *int `json:"minUsers"` // Mindestviewer
IsHD *bool `json:"isHD"` // true/false
TagsAny []string `json:"tagsAny"` // mind. ein Tag matcht
Refresh bool `json:"refresh"`
}
func hashKey(parts ...string) string {
@ -327,6 +417,19 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
var users []string
var shows []string
// ---------------------------
// Filter state (muss vor GET/POST da sein)
// ---------------------------
var (
allowedShow map[string]bool
allowedGender map[string]bool
allowedCountry map[string]bool
allowedTagsAny map[string]bool
minUsers *int
isHD *bool
)
if r.Method == http.MethodPost {
r.Body = http.MaxBytesReader(w, r.Body, 8<<20)
@ -346,6 +449,18 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
wantRefresh = req.Refresh
// ✅ neue Filter übernehmen (POST)
genders := normalizeList(req.Gender)
countries := normalizeList(req.Country)
tagsAny := normalizeList(req.TagsAny)
minUsers = req.MinUsers
isHD = req.IsHD
allowedGender = toSet(genders)
allowedCountry = toSet(countries)
allowedTagsAny = toSet(tagsAny)
// normalize users
seenU := map[string]bool{}
for _, u := range req.Q {
@ -369,6 +484,7 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
shows = append(shows, s)
}
sort.Strings(shows)
allowedShow = toSet(shows)
} else {
// GET (legacy)
qRefresh := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("refresh")))
@ -401,6 +517,43 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
}
sort.Strings(shows)
}
// ✅ gender=...
qGender := strings.TrimSpace(r.URL.Query().Get("gender"))
if qGender != "" {
genders := normalizeList(strings.Split(qGender, ","))
allowedGender = toSet(genders)
}
// ✅ country=...
qCountry := strings.TrimSpace(r.URL.Query().Get("country"))
if qCountry != "" {
countries := normalizeList(strings.Split(qCountry, ","))
allowedCountry = toSet(countries)
}
// ✅ tagsAny=...
qTagsAny := strings.TrimSpace(r.URL.Query().Get("tagsAny"))
if qTagsAny != "" {
tagsAny := normalizeList(strings.Split(qTagsAny, ","))
allowedTagsAny = toSet(tagsAny)
}
// ✅ minUsers=123
qMinUsers := strings.TrimSpace(r.URL.Query().Get("minUsers"))
if qMinUsers != "" {
if n, err := strconv.Atoi(qMinUsers); err == nil {
minUsers = &n
}
}
// ✅ isHD=1/true/yes
qIsHD := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("isHD")))
if qIsHD != "" {
b := (qIsHD == "1" || qIsHD == "true" || qIsHD == "yes")
isHD = &b
}
allowedShow = toSet(shows)
}
// ---------------------------
@ -409,19 +562,21 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
// ---------------------------
onlySpecificUsers := len(users) > 0
// show allow-set
allowedShow := map[string]bool{}
for _, s := range shows {
allowedShow[s] = true
}
// ---------------------------
// Response Cache (2s)
// ---------------------------
cacheKey := "cb_online:" + hashKey(
fmt.Sprintf("enabled=%v", enabled),
"users="+strings.Join(users, ","),
"show="+strings.Join(shows, ","),
"show="+strings.Join(keysOfSet(allowedShow), ","),
// ✅ neue Filter in den Key!
"gender="+strings.Join(keysOfSet(allowedGender), ","),
"country="+strings.Join(keysOfSet(allowedCountry), ","),
"tagsAny="+strings.Join(keysOfSet(allowedTagsAny), ","),
"minUsers="+derefInt(minUsers),
"isHD="+derefBool(isHD),
fmt.Sprintf("refresh=%v", wantRefresh),
"lite=1",
)
@ -463,21 +618,6 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
liteByUser := cb.LiteByUser // map[usernameLower]ChaturbateRoomLite
cbMu.RUnlock()
// ✅ total = Anzahl online rooms (ggf. show-gefiltert), ohne sie auszuliefern
total := 0
if liteByUser != nil {
if len(allowedShow) == 0 {
total = len(liteByUser)
} else {
for _, rm := range liteByUser {
s := strings.ToLower(strings.TrimSpace(rm.CurrentShow))
if allowedShow[s] {
total++
}
}
}
}
// ---------------------------
// Refresh/Bootstrap-Strategie:
// - Handler blockiert NICHT auf Remote-Fetch (Performance!)
@ -550,6 +690,65 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
ImageURL string `json:"image_url"`
}
matches := func(rm ChaturbateOnlineRoomLite) bool {
if len(allowedShow) > 0 {
s := strings.ToLower(strings.TrimSpace(rm.CurrentShow))
if !allowedShow[s] {
return false
}
}
if len(allowedGender) > 0 {
g := strings.ToLower(strings.TrimSpace(rm.Gender))
if !allowedGender[g] {
return false
}
}
if len(allowedCountry) > 0 {
c := strings.ToLower(strings.TrimSpace(rm.Country))
if !allowedCountry[c] {
return false
}
}
if minUsers != nil && rm.NumUsers < *minUsers {
return false
}
if isHD != nil && rm.IsHD != *isHD {
return false
}
if len(allowedTagsAny) > 0 && !tagsAnyMatch(rm.Tags, allowedTagsAny) {
return false
}
return true
}
// ✅ total = Anzahl online rooms (gefiltert), ohne sie auszuliefern
total := 0
if liteByUser != nil {
noExtraFilters :=
len(allowedShow) == 0 &&
len(allowedGender) == 0 &&
len(allowedCountry) == 0 &&
len(allowedTagsAny) == 0 &&
minUsers == nil &&
isHD == nil
if noExtraFilters {
total = len(liteByUser)
} else {
for _, rm := range liteByUser {
if matches(rm) {
total++
}
}
}
}
outRooms := make([]outRoom, 0, len(users))
if onlySpecificUsers && liteByUser != nil {
@ -558,12 +757,8 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
if !ok {
continue
}
// show filter
if len(allowedShow) > 0 {
s := strings.ToLower(strings.TrimSpace(rm.CurrentShow))
if !allowedShow[s] {
continue
}
if !matches(rm) {
continue
}
outRooms = append(outRooms, outRoom{
Username: rm.Username,

View File

@ -22,6 +22,10 @@ type cleanupResp struct {
// Orphans cleanup (previews/thumbs/generated ohne passende Video-Datei)
OrphanIDsScanned int `json:"orphanIdsScanned"`
OrphanIDsRemoved int `json:"orphanIdsRemoved"`
// ✅ NEU: Generated-GC separat (nicht in orphanIds reinmischen)
GeneratedOrphansChecked int `json:"generatedOrphansChecked"`
GeneratedOrphansRemoved int `json:"generatedOrphansRemoved"`
}
// Optional: falls du später Threshold per Body überschreiben willst.
@ -78,8 +82,8 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
// ✅ Beim manuellen Aufräumen: Generated-GC synchron laufen lassen,
// damit die Zahlen in der JSON-Response landen.
gcStats := triggerGeneratedGarbageCollectorSync()
resp.OrphanIDsScanned += gcStats.Checked
resp.OrphanIDsRemoved += gcStats.Removed
resp.GeneratedOrphansChecked = gcStats.Checked
resp.GeneratedOrphansRemoved = gcStats.Removed
resp.DeletedBytesHuman = formatBytesSI(resp.DeletedBytes)
writeJSON(w, http.StatusOK, resp)

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -21,7 +21,7 @@ type generatedGCStats struct {
// Läuft synchron und liefert Zahlen zurück (für /api/settings/cleanup Response).
func triggerGeneratedGarbageCollectorSync() generatedGCStats {
// gleiches "nur 1 GC gleichzeitig" Verhalten wie async
// nur 1 GC gleichzeitig
if !atomic.CompareAndSwapInt32(&generatedGCRunning, 0, 1) {
fmt.Println("🧹 [gc] skip: already running")
return generatedGCStats{}

File diff suppressed because it is too large Load Diff

View File

@ -14,6 +14,11 @@ import (
// generated/meta/<id>/meta.json
// --------------------------
type previewClip struct {
StartSeconds float64 `json:"startSeconds"`
DurationSeconds float64 `json:"durationSeconds"`
}
type videoMeta struct {
Version int `json:"version"`
DurationSeconds float64 `json:"durationSeconds"`
@ -25,7 +30,8 @@ type videoMeta struct {
FPS float64 `json:"fps,omitempty"`
Resolution string `json:"resolution,omitempty"` // z.B. "1920x1080"
SourceURL string `json:"sourceUrl,omitempty"`
SourceURL string `json:"sourceUrl,omitempty"`
PreviewClips []previewClip `json:"previewClips,omitempty"`
UpdatedAtUnix int64 `json:"updatedAtUnix"`
}
@ -73,30 +79,23 @@ func readVideoMeta(metaPath string, fi os.FileInfo) (dur float64, w int, h int,
}
func readVideoMetaDuration(metaPath string, fi os.FileInfo) (float64, bool) {
d, _, _, _, ok := readVideoMeta(metaPath, fi)
return d, ok
m, ok := readVideoMetaIfValid(metaPath, fi)
if !ok || m == nil || m.DurationSeconds <= 0 {
return 0, false
}
return m.DurationSeconds, true
}
func readVideoMetaSourceURL(metaPath string, fi os.FileInfo) (string, bool) {
b, err := os.ReadFile(metaPath)
if err != nil || len(b) == 0 {
m, ok := readVideoMetaIfValid(metaPath, fi)
if !ok || m == nil {
return "", false
}
var m videoMeta
if err := json.Unmarshal(b, &m); err == nil && (m.Version == 2 || m.Version == 1) {
if m.FileSize != fi.Size() || m.FileModUnix != fi.ModTime().Unix() {
return "", false
}
u := strings.TrimSpace(m.SourceURL)
if u == "" {
return "", false
}
return u, true
u := strings.TrimSpace(m.SourceURL)
if u == "" {
return "", false
}
// altes v1 ohne SourceURL -> keine URL
return "", false
return u, true
}
// Voll-Write (wenn du dur + props schon hast)
@ -124,17 +123,49 @@ func writeVideoMeta(metaPath string, fi os.FileInfo, dur float64, w int, h int,
return atomicWriteFile(metaPath, buf)
}
func writeVideoMetaWithPreviewClips(metaPath string, fi os.FileInfo, dur float64, w int, h int, fps float64, sourceURL string, clips []previewClip) error {
if strings.TrimSpace(metaPath) == "" || dur <= 0 {
return nil
}
m := videoMeta{
Version: 2,
DurationSeconds: dur,
FileSize: fi.Size(),
FileModUnix: fi.ModTime().Unix(),
VideoWidth: w,
VideoHeight: h,
FPS: fps,
Resolution: formatResolution(w, h),
SourceURL: strings.TrimSpace(sourceURL),
PreviewClips: clips,
UpdatedAtUnix: time.Now().Unix(),
}
buf, err := json.Marshal(m)
if err != nil {
return err
}
buf = append(buf, '\n')
return atomicWriteFile(metaPath, buf)
}
// Duration-only Write (ohne props)
func writeVideoMetaDuration(metaPath string, fi os.FileInfo, dur float64, sourceURL string) error {
return writeVideoMeta(metaPath, fi, dur, 0, 0, 0, sourceURL)
}
func generatedMetaFile(id string) (string, error) {
dir, err := generatedDirForID(id) // erzeugt KEIN Verzeichnis
if err != nil {
return "", err
func generatedMetaFile(assetID string) (string, error) {
assetID = stripHotPrefix(strings.TrimSpace(assetID))
if assetID == "" {
return "", fmt.Errorf("empty assetID")
}
return filepath.Join(dir, "meta.json"), nil
// exakt wie beim Schreiben normalisieren
id, err := sanitizeID(assetID)
if err != nil || id == "" {
return "", fmt.Errorf("invalid assetID: %w", err)
}
return metaJSONPathForAssetID(id)
}
// ✅ Neu: /generated/meta/<id>/...
@ -176,7 +207,7 @@ func generatedThumbFile(id string) (string, error) {
if err != nil {
return "", err
}
return filepath.Join(dir, "thumbs.jpg"), nil
return filepath.Join(dir, "thumbs.webp"), nil
}
func generatedPreviewFile(id string) (string, error) {

Binary file not shown.

1177
backend/preview_covers.go Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +1,5 @@
// backend\preview_hls.go
package main
import (
@ -384,8 +386,8 @@ func startPreviewHLS(ctx context.Context, job *RecordJob, m3u8URL, previewDir, h
jobsMu.Unlock()
}()
// ✅ Live thumb writer starten (schreibt generated/<jobId>/thumbs.jpg regelmäßig neu)
startLiveThumbLoop(ctx, job)
// ✅ Live thumb writer starten (schreibt generated/<jobId>/thumbs.webp regelmäßig neu)
startLiveThumbWebPLoop(ctx, job)
return nil
}

View File

@ -1,135 +0,0 @@
package main
import (
"bytes"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
)
func prunePreviewCacheDir(previewDir string, maxFrames int, maxAge time.Duration) {
entries, err := os.ReadDir(previewDir)
if err != nil {
return
}
type frame struct {
path string
mt time.Time
}
now := time.Now()
var frames []frame
for _, e := range entries {
name := e.Name()
path := filepath.Join(previewDir, name)
// .part Dateien immer weg
if strings.HasSuffix(name, ".part") {
_ = os.Remove(path)
continue
}
// optional: preview.jpg neu erzeugen lassen, wenn uralt
if name == "preview.jpg" {
if info, err := e.Info(); err == nil {
if maxAge > 0 && now.Sub(info.ModTime()) > maxAge {
_ = os.Remove(path)
}
}
continue
}
// Nur t_*.jpg verwalten
if strings.HasPrefix(name, "t_") && strings.HasSuffix(name, ".jpg") {
info, err := e.Info()
if err != nil {
continue
}
// alte Frames löschen
if maxAge > 0 && now.Sub(info.ModTime()) > maxAge {
_ = os.Remove(path)
continue
}
frames = append(frames, frame{path: path, mt: info.ModTime()})
}
}
// Anzahl begrenzen: älteste zuerst löschen
if maxFrames > 0 && len(frames) > maxFrames {
sort.Slice(frames, func(i, j int) bool { return frames[i].mt.Before(frames[j].mt) })
toDelete := len(frames) - maxFrames
for i := 0; i < toDelete; i++ {
_ = os.Remove(frames[i].path)
}
}
}
func servePreviewJPEGBytes(w http.ResponseWriter, img []byte) {
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Cache-Control", "public, max-age=31536000")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(img)
}
func servePreviewJPEGBytesNoStore(w http.ResponseWriter, img []byte) {
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Cache-Control", "no-store, max-age=0")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(img)
}
func serveLivePreviewJPEGBytes(w http.ResponseWriter, img []byte) {
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Cache-Control", "no-store, max-age=0, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(img)
}
func servePreviewJPEGFile(w http.ResponseWriter, r *http.Request, path string) {
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Cache-Control", "public, max-age=31536000")
w.Header().Set("X-Content-Type-Options", "nosniff")
http.ServeFile(w, r, path)
}
func extractFirstFrameJPEG(path string) ([]byte, error) {
cmd := exec.Command(
ffmpegPath,
"-hide_banner",
"-loglevel", "error",
"-i", path,
"-frames:v", "1",
"-vf", "scale=720:-2",
"-q:v", "10",
"-f", "image2pipe",
"-vcodec", "mjpeg",
"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: %w (%s)", err, strings.TrimSpace(stderr.String()))
}
return out.Bytes(), nil
}

View File

@ -5,7 +5,6 @@ import (
"bytes"
"net/url"
"path"
"regexp"
"strings"
)
@ -99,36 +98,3 @@ func rewriteAttrURI(line, base string) string {
return line[:start] + repl + line[end:]
}
func rewriteQuotedURI(line, id string) string {
re := regexp.MustCompile(`URI="([^"]+)"`)
return re.ReplaceAllStringFunc(line, func(m string) string {
sub := re.FindStringSubmatch(m)
if len(sub) != 2 {
return m
}
u := sub[1]
uu := strings.TrimSpace(u)
if uu == "" || strings.HasPrefix(uu, "http://") || strings.HasPrefix(uu, "https://") || strings.HasPrefix(uu, "/") {
return m
}
repl := "/api/record/preview?id=" + url.QueryEscape(id) + "&file=" + url.QueryEscape(uu)
return `URI="` + repl + `"`
})
}
func rewriteM3U8ToPreviewEndpoint(m3u8 string, id string) string {
lines := strings.Split(m3u8, "\n")
escapedID := url.QueryEscape(id)
for i, line := range lines {
l := strings.TrimSpace(line)
if l == "" || strings.HasPrefix(l, "#") {
continue
}
// Segment/URI-Zeilen umschreiben
lines[i] = "/api/record/preview?id=" + escapedID + "&file=" + url.QueryEscape(l)
}
return strings.Join(lines, "\n")
}

744
backend/preview_webp.go Normal file
View File

@ -0,0 +1,744 @@
// 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 {
// dynamische Frequenz: je mehr aktive Jobs, desto langsamer (weniger Last)
jobsMu.Lock()
nRunning := 0
for _, j := range jobs {
if j != nil && j.Status == JobRunning {
nRunning++
}
}
jobsMu.Unlock()
delay := 12 * time.Second
if nRunning >= 6 {
delay = 18 * time.Second
}
if nRunning >= 12 {
delay = 25 * 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)
}

View File

@ -40,6 +40,17 @@ type doneListResponse struct {
PageSize int `json:"pageSize,omitempty"`
}
type doneMetaFileResp struct {
File string `json:"file"`
MetaExists bool `json:"metaExists"`
DurationSeconds float64 `json:"durationSeconds,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
FPS float64 `json:"fps,omitempty"`
SourceURL string `json:"sourceUrl,omitempty"`
Error string `json:"error,omitempty"`
}
type doneMetaResp struct {
Count int `json:"count"`
}
@ -153,9 +164,11 @@ func writeSSE(w http.ResponseWriter, data []byte) {
}
func handleDoneStream(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Connection", "keep-alive")
// wichtig für nginx / reverse proxies
w.Header().Set("X-Accel-Buffering", "no")
flusher, ok := w.(http.Flusher)
if !ok {
@ -163,23 +176,36 @@ func handleDoneStream(w http.ResponseWriter, r *http.Request) {
return
}
ch := make(chan []byte, 16)
// pro client ein channel
ch := make(chan []byte, 32)
doneHub.add(ch)
defer doneHub.remove(ch)
// optional: initial ping/hello, damit Client sofort "lebt"
fmt.Fprintf(w, "event: doneChanged\ndata: {\"type\":\"doneChanged\",\"seq\":%d,\"ts\":%d}\n\n",
atomic.LoadUint64(&doneSeq), time.Now().UnixMilli())
// ✅ KEIN doneChanged als hello nur Kommentar
fmt.Fprintf(w, ": hello seq=%d ts=%d\n\n", atomic.LoadUint64(&doneSeq), time.Now().UnixMilli())
flusher.Flush()
ctx := r.Context()
ping := time.NewTicker(15 * time.Second)
defer ping.Stop()
for {
select {
case <-ctx.Done():
return
case b := <-ch:
// wichtig: event-name setzen -> Client kann addEventListener("doneChanged", ...)
fmt.Fprintf(w, "event: doneChanged\ndata: %s\n\n", b)
case <-ping.C:
// ✅ Keepalive als Kommentar (triggert keine addEventListener("doneChanged"))
fmt.Fprintf(w, ": ping ts=%d\n\n", time.Now().UnixMilli())
flusher.Flush()
case b, ok := <-ch:
if !ok {
return
}
// ✅ nur echte Changes als doneChanged
fmt.Fprintf(w, "event: doneChanged\n")
fmt.Fprintf(w, "data: %s\n\n", b)
flusher.Flush()
}
}
@ -233,6 +259,100 @@ func (t *rwTrack) Write(p []byte) (int, error) {
return t.ResponseWriter.Write(p)
}
// ensureMetaJSONForPlayback erzeugt generated/meta/<id>/meta.json falls sie fehlt.
// Best-effort: wenn es nicht geht (FFprobe fehlt, Fehler, etc.), wird Playback nicht verhindert.
func ensureMetaJSONForPlayback(ctx context.Context, videoPath string) {
// nur mp4 (nach TS-remux ist es mp4)
if strings.ToLower(filepath.Ext(videoPath)) != ".mp4" {
return
}
// ID: basename ohne Ext + ohne HOT
base := strings.TrimSuffix(filepath.Base(videoPath), filepath.Ext(videoPath))
id := stripHotPrefix(base)
id = strings.TrimSpace(id)
if id == "" {
return
}
metaPath, err := generatedMetaFile(id)
if err != nil || strings.TrimSpace(metaPath) == "" {
return
}
// existiert schon?
if fi, err := os.Stat(metaPath); err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 {
return
}
// Video stat für spätere Checks / Meta-Write
vfi, err := os.Stat(videoPath)
if err != nil || vfi == nil || vfi.IsDir() || vfi.Size() == 0 {
return
}
// Versuche Meta aus dem Video zu extrahieren (FFprobe)
// -> du hast bereits ensureFFprobeAvailable(), getVideoHeightCached(), durationSecondsCached(), etc.
if err := ensureFFprobeAvailable(); err != nil {
return
}
// kleiner Timeout: wir wollen Playback nicht “ewig” blockieren
pctx, cancel := context.WithTimeout(ctx, 4*time.Second)
defer cancel()
// Dauer
dur, derr := durationSecondsCached(pctx, videoPath)
if derr != nil || dur <= 0 {
// best-effort: nicht blockieren
dur = 0
}
// Height (und daraus evtl. Width) falls du schon Width helper hast, nimm den.
h, _ := getVideoHeightCached(pctx, videoPath)
// FPS optional wenn du einen Cache/helper hast, nimm ihn; sonst 0 lassen.
fps := 0.0
// Quelle URL ist bei done-files oft nur in meta; wenn unbekannt, leer lassen.
srcURL := ""
// Sicherstellen, dass Ordner existiert
_ = os.MkdirAll(filepath.Dir(metaPath), 0o755)
// Format: passe an deine readVideoMeta(...) / readVideoMetaDuration(...) Parser-Struktur an!
// Ich nehme hier eine sehr typische Struktur an:
type videoMeta struct {
DurationSeconds float64 `json:"durationSeconds,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
FPS float64 `json:"fps,omitempty"`
SourceURL string `json:"sourceUrl,omitempty"`
// optional: file info / updatedAt
UpdatedAtUnix int64 `json:"updatedAtUnix,omitempty"`
FileSizeBytes int64 `json:"fileSizeBytes,omitempty"`
}
m := videoMeta{
DurationSeconds: dur,
Width: 0,
Height: h,
FPS: fps,
SourceURL: srcURL,
UpdatedAtUnix: time.Now().Unix(),
FileSizeBytes: vfi.Size(),
}
// Atomisch schreiben (damit parallele Requests kein kaputtes JSON sehen)
tmp := metaPath + ".tmp"
if b, err := json.MarshalIndent(m, "", " "); err == nil {
_ = os.WriteFile(tmp, b, 0o644)
_ = os.Rename(tmp, metaPath)
} else {
_ = os.Remove(tmp)
}
}
func recordVideo(w http.ResponseWriter, r *http.Request) {
// ---- wrap writer to detect "already wrote" ----
tw := &rwTrack{ResponseWriter: w}
@ -485,15 +605,13 @@ func recordVideo(w http.ResponseWriter, r *http.Request) {
// ---- convert progress fraction to seconds (if needed) ----
if startSec == 0 && startFrac > 0 && startFrac < 1.0 {
// ffprobe duration (cached)
if err := ensureFFprobeAvailable(); err == nil {
pctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
dur, derr := getVideoDurationSecondsCached(pctx, outPath)
cancel()
pctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if derr == nil && dur > 0 {
startSec = int(startFrac * dur)
}
// ✅ nutzt deinen zentralen Cache + (wenn du es wie empfohlen ergänzt hast) durSem-Limit
dur, derr := durationSecondsCached(pctx, outPath)
if derr == nil && dur > 0 {
startSec = int(startFrac * dur)
}
}
@ -540,6 +658,9 @@ func recordVideo(w http.ResponseWriter, r *http.Request) {
}
}
// ✅ NEU: meta.json sicherstellen (best effort), bevor wir ausliefern/transcoden
ensureMetaJSONForPlayback(r.Context(), outPath)
// ---- Quality / Transcode handling ----
w.Header().Set("Cache-Control", "no-store")
@ -682,7 +803,7 @@ func serveTranscodedStreamAt(ctx context.Context, w http.ResponseWriter, inPath
// ffmpeg args (mit -ss vor -i)
args := buildFFmpegStreamArgsAt(inPath, prof, startSec)
cmd := exec.CommandContext(ctx, "ffmpeg", args...)
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
stdout, err := cmd.StdoutPipe()
if err != nil {
@ -1085,6 +1206,224 @@ type doneIndexCache struct {
var doneCache doneIndexCache
func normalizeQueryModel(raw string) string {
s := strings.TrimSpace(raw)
if s == "" {
return ""
}
s = strings.TrimPrefix(s, "http://")
s = strings.TrimPrefix(s, "https://")
// letzter URL-Segment, falls jemand ".../modelname" übergibt
if strings.Contains(s, "/") {
parts := strings.Split(s, "/")
for i := len(parts) - 1; i >= 0; i-- {
p := strings.TrimSpace(parts[i])
if p != "" {
s = p
break
}
}
}
// falls "host:model" übergeben wird
if strings.Contains(s, ":") {
parts := strings.Split(s, ":")
s = strings.TrimSpace(parts[len(parts)-1])
}
s = strings.TrimPrefix(s, "@")
return strings.ToLower(strings.TrimSpace(s))
}
func recordDoneMeta(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
return
}
// ✅ NEW: File-Mode: /api/record/done/meta?file=XYZ.mp4
if raw := strings.TrimSpace(r.URL.Query().Get("file")); raw != "" {
file, err := url.QueryUnescape(raw)
if err != nil {
http.Error(w, "ungültiger file", http.StatusBadRequest)
return
}
file = strings.TrimSpace(file)
// nur Basename erlauben (kein Traversal)
if !isSafeBasename(file) {
http.Error(w, "ungültiger file", http.StatusBadRequest)
return
}
ext := strings.ToLower(filepath.Ext(file))
if ext != ".mp4" && ext != ".ts" {
http.Error(w, "nicht erlaubt", http.StatusForbidden)
return
}
s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil {
http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
if strings.TrimSpace(doneAbs) == "" {
http.Error(w, "doneDir ist leer", http.StatusBadRequest)
return
}
// Datei in done/ oder keep/ finden
full, _, fi, err := resolveDoneFileByName(doneAbs, file)
if err != nil || fi == nil || fi.IsDir() || fi.Size() == 0 {
http.Error(w, "datei nicht gefunden", http.StatusNotFound)
return
}
// optional: TS -> MP4 remux (meta soll sich auf abspielbare MP4 beziehen)
outPath := filepath.Clean(strings.TrimSpace(full))
if strings.ToLower(filepath.Ext(outPath)) == ".ts" {
if newOut, rerr := maybeRemuxTS(outPath); rerr == nil && strings.TrimSpace(newOut) != "" {
outPath = filepath.Clean(strings.TrimSpace(newOut))
if nfi, serr := os.Stat(outPath); serr == nil && nfi != nil {
fi = nfi
}
}
}
// ✅ best-effort meta.json erzeugen
ensureMetaJSONForPlayback(r.Context(), outPath)
// Response-Shape: bewusst "fertig" fürs Frontend
type doneMetaFileResp struct {
File string `json:"file"`
MetaExists bool `json:"metaExists"`
DurationSeconds float64 `json:"durationSeconds,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
FPS float64 `json:"fps,omitempty"`
SourceURL string `json:"sourceUrl,omitempty"`
Error string `json:"error,omitempty"`
}
resp := doneMetaFileResp{File: filepath.Base(outPath)}
// meta lesen (wenn vorhanden)
id := stripHotPrefix(strings.TrimSuffix(filepath.Base(outPath), filepath.Ext(outPath)))
if strings.TrimSpace(id) != "" {
if mp, merr := generatedMetaFile(id); merr == nil && strings.TrimSpace(mp) != "" {
if mfi, serr := os.Stat(mp); serr == nil && mfi != nil && !mfi.IsDir() && mfi.Size() > 0 {
resp.MetaExists = true
if dur, w2, h2, fps2, ok := readVideoMeta(mp, fi); ok {
resp.DurationSeconds = dur
resp.Width = w2
resp.Height = h2
resp.FPS = fps2
}
if u, ok := readVideoMetaSourceURL(mp, fi); ok {
resp.SourceURL = u
}
}
}
}
// fallback: wenn Meta existiert aber Duration fehlt -> zentralen Cache/ffprobe nutzen
if resp.DurationSeconds <= 0 {
pctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if d, derr := durationSecondsCached(pctx, outPath); derr == nil && d > 0 {
resp.DurationSeconds = d
}
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(resp)
return
}
// ---------------------
// ✅ ORIGINAL: Count-Mode (wie vorher)
// ---------------------
// optional: includeKeep (falls du später mal brauchst)
qKeep := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("includeKeep")))
includeKeep := qKeep == "1" || qKeep == "true" || qKeep == "yes"
// optional: model filter (falls du später mal brauchst)
qModel := normalizeQueryModel(r.URL.Query().Get("model"))
s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil {
http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
curSeq := atomic.LoadUint64(&doneSeq)
now := time.Now()
// Cache rebuild (wie in recordDoneList; Count kommt aus Index)
doneCache.mu.Lock()
needRebuild := doneCache.seq != curSeq ||
doneCache.doneAbs != doneAbs ||
now.Sub(doneCache.builtAt) > 30*time.Second
if needRebuild {
if _, err := os.Stat(doneAbs); err != nil && os.IsNotExist(err) {
doneCache.items = nil
doneCache.sortedIdx = make(map[string][]int, 16)
modes := []string{
"completed_desc", "completed_asc",
"file_asc", "file_desc",
"duration_asc", "duration_desc",
"size_asc", "size_desc",
}
for _, m := range modes {
doneCache.sortedIdx["0|"+m] = []int{}
doneCache.sortedIdx["1|"+m] = []int{}
}
doneCache.seq = curSeq
doneCache.doneAbs = doneAbs
doneCache.builtAt = now
} else {
items, sorted := buildDoneIndex(doneAbs)
doneCache.items = items
doneCache.sortedIdx = sorted
doneCache.seq = curSeq
doneCache.doneAbs = doneAbs
doneCache.builtAt = now
}
}
items := doneCache.items
sortedAll := doneCache.sortedIdx
doneCache.mu.Unlock()
// Count bestimmen
count := 0
if qModel == "" {
incKey := "0"
if includeKeep {
incKey = "1"
}
count = len(sortedAll[incKey+"|completed_desc"])
} else {
for _, it := range items {
if !includeKeep && it.fromKeep {
continue
}
if it.modelKey == qModel {
count++
}
}
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(doneMetaResp{Count: count})
}
func recordDoneList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
@ -1096,35 +1435,6 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
includeKeep := qKeep == "1" || qKeep == "true" || qKeep == "yes"
// ✅ NEU: optionaler Model-Filter (Pagination dann "pro Model" sinnvoll)
normalizeQueryModel := func(raw string) string {
s := strings.TrimSpace(raw)
if s == "" {
return ""
}
s = strings.TrimPrefix(s, "http://")
s = strings.TrimPrefix(s, "https://")
// letzter URL-Segment, falls jemand "…/modelname" übergibt
if strings.Contains(s, "/") {
parts := strings.Split(s, "/")
for i := len(parts) - 1; i >= 0; i-- {
p := strings.TrimSpace(parts[i])
if p != "" {
s = p
break
}
}
}
// falls "host:model" übergeben wird
if strings.Contains(s, ":") {
parts := strings.Split(s, ":")
s = strings.TrimSpace(parts[len(parts)-1])
}
s = strings.TrimPrefix(s, "@")
return strings.ToLower(strings.TrimSpace(s))
}
qModel := normalizeQueryModel(r.URL.Query().Get("model"))
// optional: Pagination (1-based). Wenn page/pageSize fehlen -> wie vorher: komplette Liste
@ -1168,56 +1478,6 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
qWithCount := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("withCount")))
withCount := qWithCount == "1" || qWithCount == "true" || qWithCount == "yes"
// ✅ .trash niemals als "done item" zählen/listen
isTrashOutput := func(p string) bool {
pp := strings.ToLower(filepath.ToSlash(strings.TrimSpace(p)))
return strings.Contains(pp, "/.trash/") || strings.HasSuffix(pp, "/.trash")
}
isTrashPath := func(full string) bool {
p := strings.ReplaceAll(full, "\\", "/")
return strings.Contains(p, "/.trash/") || strings.HasSuffix(p, "/.trash")
}
// --- helpers (ModelKey aus Filename/Dir ableiten) ---
modelFromStem := func(stem string) string {
// stem: lower, ohne ext, ohne HOT
if stem == "" {
return ""
}
if m := startedAtFromFilenameRe.FindStringSubmatch(stem); m != nil {
return strings.ToLower(strings.TrimSpace(m[1]))
}
// fallback: alles vor letztem "_" (oder kompletter stem)
if i := strings.LastIndex(stem, "_"); i > 0 {
return strings.ToLower(strings.TrimSpace(stem[:i]))
}
return strings.ToLower(strings.TrimSpace(stem))
}
modelFromFullPath := func(full string) string {
name := strings.ToLower(filepath.Base(full))
stem := strings.TrimSuffix(name, filepath.Ext(name))
stem = strings.TrimPrefix(stem, "hot ")
mk := modelFromStem(stem)
// fallback: wenn Dateiname nichts taugt, aus Ordner nehmen (/done/<model>/file)
if mk == "" {
parent := strings.ToLower(filepath.Base(filepath.Dir(full)))
parent = strings.TrimSpace(parent)
if parent != "" && parent != "keep" {
mk = parent
}
}
return mk
}
// helpers (Sort)
fileForSortName := func(filename string) string {
f := strings.ToLower(filename)
f = strings.TrimPrefix(f, "hot ")
return f
}
durationForSort := func(j *RecordJob) (sec float64, ok bool) {
if j.DurationSeconds > 0 {
return j.DurationSeconds, true
@ -1336,177 +1596,6 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
return
}
// --------- Cache rebuild (nur bei doneSeq-Change oder TTL) ---------
buildDoneIndex := func(doneAbs string) ([]doneIndexItem, map[string][]int) {
items := make([]doneIndexItem, 0, 2048)
addFile := func(full string, fi os.FileInfo, fromKeep bool) {
if fi == nil || fi.IsDir() || fi.Size() == 0 {
return
}
// ✅ .trash niemals zählen / zurückgeben
if isTrashPath(full) || isTrashOutput(full) {
return
}
name := filepath.Base(full)
ext := strings.ToLower(filepath.Ext(name))
if ext != ".mp4" && ext != ".ts" {
return
}
base := strings.TrimSuffix(name, filepath.Ext(name))
t := fi.ModTime()
// StartedAt aus Dateiname (Fallback: ModTime)
start := t
stem := base
if strings.HasPrefix(stem, "HOT ") {
stem = strings.TrimPrefix(stem, "HOT ")
}
if m := startedAtFromFilenameRe.FindStringSubmatch(stem); m != nil {
mm, _ := strconv.Atoi(m[2])
dd, _ := strconv.Atoi(m[3])
yy, _ := strconv.Atoi(m[4])
hh, _ := strconv.Atoi(m[5])
mi, _ := strconv.Atoi(m[6])
ss, _ := strconv.Atoi(m[7])
start = time.Date(yy, time.Month(mm), dd, hh, mi, ss, 0, time.Local)
}
dur := 0.0
srcURL := ""
// 1) meta.json aus generated/<id>/meta.json lesen (schnell)
id := stripHotPrefix(strings.TrimSuffix(filepath.Base(full), filepath.Ext(full)))
if strings.TrimSpace(id) != "" {
if mp, err := generatedMetaFile(id); err == nil {
if d, ok := readVideoMetaDuration(mp, fi); ok {
dur = d
}
if u, ok := readVideoMetaSourceURL(mp, fi); ok {
srcURL = u
}
}
}
// ✅ Kein Cache-only Fallback hier.
// Wenn meta fehlt, bleibt dur erstmal 0 und wird beim Ausliefern (Pagination) via ensureVideoMetaForFileBestEffort erzeugt.
ended := t
mk := modelFromFullPath(full)
fs := fileForSortName(name)
items = append(items, doneIndexItem{
job: &RecordJob{
ID: base,
Output: full,
SourceURL: srcURL,
Status: JobFinished,
StartedAt: start,
EndedAt: &ended,
DurationSeconds: dur,
SizeBytes: fi.Size(),
},
endedAt: ended,
fileSort: fs,
fromKeep: fromKeep,
modelKey: mk,
})
}
// scan one level: doneAbs + doneAbs/<sub>/*
scanRoot := func(root string, fromKeep bool, skipKeepDir bool) {
entries, err := os.ReadDir(root)
if err != nil {
return
}
for _, e := range entries {
if e.IsDir() {
// ✅ .trash Ordner niemals scannen
if strings.EqualFold(e.Name(), ".trash") {
continue
}
// ✅ keep nicht doppelt scannen (wenn root==doneAbs)
if skipKeepDir && e.Name() == "keep" {
continue
}
sub := filepath.Join(root, e.Name())
subEntries, err := os.ReadDir(sub)
if err != nil {
continue
}
for _, se := range subEntries {
if se.IsDir() {
continue
}
full := filepath.Join(sub, se.Name())
fi, err := se.Info()
if err != nil {
// fallback
fi2, err2 := os.Stat(full)
if err2 != nil {
continue
}
fi = fi2
}
addFile(full, fi, fromKeep)
}
continue
}
full := filepath.Join(root, e.Name())
fi, err := e.Info()
if err != nil {
fi2, err2 := os.Stat(full)
if err2 != nil {
continue
}
fi = fi2
}
addFile(full, fi, fromKeep)
}
}
// doneAbs ohne keep
scanRoot(doneAbs, false, true)
// keep (wenn existiert)
scanRoot(filepath.Join(doneAbs, "keep"), true, false)
// pre-sorted indices: includeKeep 0/1 und pro sortMode
sorted := make(map[string][]int)
buildSorted := func(inc bool, mode string) []int {
idx := make([]int, 0, len(items))
for i := range items {
if !inc && items[i].fromKeep {
continue
}
idx = append(idx, i)
}
sort.Slice(idx, func(a, b int) bool {
return compareIdx(items, mode, idx[a], idx[b])
})
return idx
}
modes := []string{
"completed_desc", "completed_asc",
"file_asc", "file_desc",
"duration_asc", "duration_desc",
"size_asc", "size_desc",
}
for _, m := range modes {
sorted["0|"+m] = buildSorted(false, m)
sorted["1|"+m] = buildSorted(true, m)
}
return items, sorted
}
// rebuild wenn doneSeq geändert oder TTL
curSeq := atomic.LoadUint64(&doneSeq)
now := time.Now()
@ -1520,9 +1609,16 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
// Wenn doneAbs nicht existiert: leere Daten im Cache
if _, err := os.Stat(doneAbs); err != nil && os.IsNotExist(err) {
doneCache.items = nil
doneCache.sortedIdx = map[string][]int{
"0|completed_desc": {},
"1|completed_desc": {},
doneCache.sortedIdx = make(map[string][]int, 16)
modes := []string{
"completed_desc", "completed_asc",
"file_asc", "file_desc",
"duration_asc", "duration_desc",
"size_asc", "size_desc",
}
for _, m := range modes {
doneCache.sortedIdx["0|"+m] = []int{}
doneCache.sortedIdx["1|"+m] = []int{}
}
doneCache.seq = curSeq
doneCache.doneAbs = doneAbs
@ -1611,29 +1707,26 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
// ✅ Kopie erzeugen (wichtig: keine Race/Mutations am Cache-Objekt)
c := *base
// ✅ Meta immer aus meta.json (ggf. generieren, wenn fehlt)
// Kurzes Timeout pro Item, damit eine Seite nicht "hängen" kann.
pctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
m, ok := ensureVideoMetaForFileBestEffort(pctx, c.Output, c.SourceURL)
cancel()
// Size immer korrekt setzen
if fi, err := os.Stat(c.Output); err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 {
c.SizeBytes = fi.Size()
}
// Wenn Meta ok: Felder IMMER daraus setzen
if ok && m != nil {
c.Meta = m
c.DurationSeconds = m.DurationSeconds
c.SizeBytes = m.FileSize
c.VideoWidth = m.VideoWidth
c.VideoHeight = m.VideoHeight
c.FPS = m.FPS
// SourceURL: wenn Job leer, aus Meta übernehmen
if strings.TrimSpace(c.SourceURL) == "" && strings.TrimSpace(m.SourceURL) != "" {
c.SourceURL = strings.TrimSpace(m.SourceURL)
}
} else {
// Falls wirklich gar keine Meta gebaut werden kann: wenigstens Size korrekt setzen
if fi, err := os.Stat(c.Output); err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 {
c.SizeBytes = fi.Size()
// Meta nur lesen, wenn es existiert (kein Generieren!)
id := stripHotPrefix(strings.TrimSuffix(filepath.Base(c.Output), filepath.Ext(c.Output)))
if id != "" {
if mp, err := generatedMetaFile(id); err == nil {
if fi, err := os.Stat(c.Output); err == nil && fi != nil && !fi.IsDir() {
if dur, w, h, fps, ok := readVideoMeta(mp, fi); ok {
c.DurationSeconds = dur
c.VideoWidth = w
c.VideoHeight = h
c.FPS = fps
}
if u, ok := readVideoMetaSourceURL(mp, fi); ok && strings.TrimSpace(c.SourceURL) == "" {
c.SourceURL = u
}
}
}
}
@ -2073,7 +2166,7 @@ func recordUnkeepVideo(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unkeep fehlgeschlagen (Datei wird gerade verwendet).", http.StatusConflict)
return
}
http.Error(w, "unkeep fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
http.Error(w, "unkeep fehlgeschlagen: "+file, http.StatusInternalServerError)
return
}
@ -2211,7 +2304,7 @@ func recordKeepVideo(w http.ResponseWriter, r *http.Request) {
http.Error(w, "keep fehlgeschlagen (Datei wird gerade verwendet).", http.StatusConflict)
return
}
http.Error(w, "keep fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
http.Error(w, "keep fehlgeschlagen: "+file, http.StatusInternalServerError)
return
}

View File

@ -73,13 +73,14 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
ctx, cancel := context.WithCancel(context.Background())
job := &RecordJob{
ID: jobID,
SourceURL: url,
Status: JobRunning,
StartedAt: startedAt,
Output: outPath, // ✅ sofort befüllt
Hidden: req.Hidden, // ✅ NEU
cancel: cancel,
ID: jobID,
SourceURL: url,
Status: JobRunning,
StartedAt: startedAt,
StartedAtMs: startedAt.UnixMilli(), // ✅ NEU
Output: outPath,
Hidden: req.Hidden,
cancel: cancel,
}
jobs[jobID] = job
@ -106,6 +107,20 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
now = time.Now()
}
// ✅ falls StartedAtMs aus irgendeinem Grund leer ist
if job.StartedAtMs == 0 {
base := job.StartedAt
if base.IsZero() {
base = time.Now()
jobsMu.Lock()
job.StartedAt = base
jobsMu.Unlock()
}
jobsMu.Lock()
job.StartedAtMs = base.UnixMilli()
jobsMu.Unlock()
}
// ✅ Phase für Recording explizit setzen (damit spätere Progress-Writer das erkennen können)
setJobProgress(job, "recording", 0)
notifyJobsChanged()
@ -198,6 +213,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
// EndedAt + Error speichern (kurz locken)
jobsMu.Lock()
job.EndedAt = &end
job.EndedAtMs = end.UnixMilli() // ✅ NEU
if errText != "" {
job.Error = errText
}
@ -205,13 +221,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
// ✅ WICHTIG: sofort Phase wechseln, damit Recorder-Progress danach nichts mehr “zurücksetzt”
job.Phase = "postwork"
/*
// ✅ Progress darf ab jetzt nicht mehr runtergehen (mind. Einstieg in Postwork)
if job.Progress < 70 {
job.Progress = 70
}
*/
out := strings.TrimSpace(job.Output)
jobsMu.Unlock()
notifyJobsChanged()
@ -288,9 +297,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
// - Zusätzlich: PostWorkKey setzen + initialen Queue-Status ins Job-JSON hängen.
jobsMu.Lock()
job.Phase = "postwork"
if job.Progress < 70 {
job.Progress = 70
}
job.PostWorkKey = postKey
// initialer Status (meist "missing", bis Enqueue done ist wir updaten direkt danach nochmal)
@ -401,7 +407,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
}
}
// 6) Assets (thumbs.jpg + preview.mp4)
// 6) Assets (thumbs.webp + preview.mp4)
const (
assetsStart = 86
assetsEnd = 99

View File

@ -47,6 +47,7 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
api.HandleFunc("/api/record/preview", recordPreview)
api.HandleFunc("/api/record/list", recordList)
api.HandleFunc("/api/record/stream", recordStream)
api.HandleFunc("/api/record/done/meta", recordDoneMeta)
api.HandleFunc("/api/record/video", recordVideo)
api.HandleFunc("/api/record/done", recordDoneList)
api.HandleFunc("/api/record/delete", recordDeleteVideo)
@ -65,6 +66,8 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
// Tasks
api.HandleFunc("/api/tasks/generate-assets", tasksGenerateAssets)
api.HandleFunc("/api/tasks/assets/stream", assetsStream)
// --------------------------
// 3) ModelStore
// --------------------------

401
backend/sse.go Normal file
View File

@ -0,0 +1,401 @@
// backend/sse.go
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"sync"
"sync/atomic"
"time"
)
// -------------------- SSE primitives --------------------
type sseHub struct {
mu sync.Mutex
clients map[chan []byte]struct{}
}
func newSSEHub() *sseHub {
return &sseHub{clients: map[chan []byte]struct{}{}}
}
func (h *sseHub) add(ch chan []byte) {
h.mu.Lock()
h.clients[ch] = struct{}{}
h.mu.Unlock()
}
func (h *sseHub) remove(ch chan []byte) {
h.mu.Lock()
delete(h.clients, ch)
h.mu.Unlock()
close(ch)
}
func (h *sseHub) broadcast(b []byte) {
h.mu.Lock()
defer h.mu.Unlock()
for ch := range h.clients {
// Non-blocking: langsame Clients droppen Updates (holen sich beim nächsten Update wieder ein)
select {
case ch <- b:
default:
}
}
}
// -------------------- SSE channels + notify --------------------
var (
// done changed stream (Client soll nur "refetch done" machen)
doneHub = newSSEHub()
doneNotify = make(chan struct{}, 1)
doneSeq uint64
// record jobs stream
recordJobsHub = newSSEHub()
recordJobsNotify = make(chan struct{}, 1)
// assets task stream
assetsHub = newSSEHub()
assetsNotify = make(chan struct{}, 1)
)
func notifyDoneChanged() {
select {
case doneNotify <- struct{}{}:
default:
}
}
func notifyJobsChanged() {
select {
case recordJobsNotify <- struct{}{}:
default:
}
}
func notifyAssetsChanged() {
select {
case assetsNotify <- struct{}{}:
default:
}
}
// initSSE startet die Debounce-Broadcaster.
// Wichtig: wird aus main.go init() aufgerufen.
func initSSE() {
// Debounced broadcaster (jobs)
go func() {
for range recordJobsNotify {
time.Sleep(40 * time.Millisecond)
for {
select {
case <-recordJobsNotify:
default:
goto SEND
}
}
SEND:
recordJobsHub.broadcast(jobsSnapshotJSON())
}
}()
// Debounced broadcaster (done changed)
go func() {
for range doneNotify {
time.Sleep(40 * time.Millisecond)
for {
select {
case <-doneNotify:
default:
goto SEND
}
}
SEND:
seq := atomic.AddUint64(&doneSeq, 1)
b := []byte(fmt.Sprintf(`{"type":"doneChanged","seq":%d,"ts":%d}`, seq, time.Now().UnixMilli()))
doneHub.broadcast(b)
}
}()
// ✅ Debounced broadcaster (assets task)
go func() {
for range assetsNotify {
time.Sleep(80 * time.Millisecond)
for {
select {
case <-assetsNotify:
default:
goto SEND
}
}
SEND:
b := assetsSnapshotJSON()
if len(b) > 0 {
assetsHub.broadcast(b)
}
}
}()
}
// -------------------- SSE: /api/record/stream --------------------
// jobsSnapshotJSON liefert die aktuelle (gefilterte) Job-Liste als JSON.
// Greift auf jobs/jobsMu aus main.go zu (gleiches Package).
func jobsSnapshotJSON() []byte {
jobsMu.Lock()
list := make([]*RecordJob, 0, len(jobs))
for _, j := range jobs {
// Hidden-Jobs niemals an die UI senden
if j == nil || j.Hidden {
continue
}
c := *j
c.cancel = nil // nicht serialisieren
list = append(list, &c)
}
jobsMu.Unlock()
// neueste zuerst
sort.Slice(list, func(i, j int) bool {
return list[i].StartedAt.After(list[j].StartedAt)
})
b, _ := json.Marshal(list)
return b
}
func recordStream(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
return
}
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming nicht unterstützt", http.StatusInternalServerError)
return
}
// SSE-Header
h := w.Header()
h.Set("Content-Type", "text/event-stream; charset=utf-8")
h.Set("Cache-Control", "no-cache, no-transform")
h.Set("Connection", "keep-alive")
h.Set("X-Accel-Buffering", "no") // hilfreich bei Reverse-Proxies
// sofort starten
w.WriteHeader(http.StatusOK)
writeEvent := func(event string, data []byte) bool {
// returns false => client weg / write error
if event != "" {
if _, err := fmt.Fprintf(w, "event: %s\n", event); err != nil {
return false
}
}
if len(data) > 0 {
if _, err := fmt.Fprintf(w, "data: %s\n\n", data); err != nil {
return false
}
} else {
// empty payload ok (nur terminator)
if _, err := io.WriteString(w, "\n"); err != nil {
return false
}
}
flusher.Flush()
return true
}
writeComment := func(msg string) bool {
if _, err := fmt.Fprintf(w, ": %s\n\n", msg); err != nil {
return false
}
flusher.Flush()
return true
}
// Reconnect-Hinweis
if _, err := fmt.Fprintf(w, "retry: 3000\n\n"); err != nil {
return
}
flusher.Flush()
// Channel + Hub
ch := make(chan []byte, 32)
recordJobsHub.add(ch)
defer recordJobsHub.remove(ch)
// Initialer Snapshot sofort
if b := jobsSnapshotJSON(); len(b) > 0 {
if !writeEvent("jobs", b) {
return
}
}
ctx := r.Context()
// Ping/Keepalive
ping := time.NewTicker(15 * time.Second)
defer ping.Stop()
for {
select {
case <-ctx.Done():
return
case b, ok := <-ch:
if !ok {
return
}
if len(b) == 0 {
continue
}
// Burst-Coalescing: wenn viele Updates schnell kommen, nur das neueste senden
last := b
drain:
for i := 0; i < 64; i++ {
select {
case nb, ok := <-ch:
if !ok {
return
}
if len(nb) > 0 {
last = nb
}
default:
break drain
}
}
if !writeEvent("jobs", last) {
return
}
case <-ping.C:
// Keepalive als Kommentar (stört nicht, hält Verbindungen offen)
if !writeComment(fmt.Sprintf("ping %d", time.Now().Unix())) {
return
}
}
}
}
// -------------------- SSE: /api/tasks/assets/stream --------------------
func assetsSnapshotJSON() []byte {
assetsTaskMu.Lock()
st := assetsTaskState
assetsTaskMu.Unlock()
b, _ := json.Marshal(st)
return b
}
func assetsStream(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
return
}
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming nicht unterstützt", http.StatusInternalServerError)
return
}
h := w.Header()
h.Set("Content-Type", "text/event-stream; charset=utf-8")
h.Set("Cache-Control", "no-cache, no-transform")
h.Set("Connection", "keep-alive")
h.Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
// Reconnect-Hinweis
fmt.Fprintf(w, "retry: 3000\n\n")
flusher.Flush()
writeEvent := func(event string, data []byte) bool {
if event != "" {
if _, err := fmt.Fprintf(w, "event: %s\n", event); err != nil {
return false
}
}
if _, err := fmt.Fprintf(w, "data: %s\n\n", data); err != nil {
return false
}
flusher.Flush()
return true
}
writeComment := func(msg string) bool {
if _, err := fmt.Fprintf(w, ": %s\n\n", msg); err != nil {
return false
}
flusher.Flush()
return true
}
ch := make(chan []byte, 32)
assetsHub.add(ch)
defer assetsHub.remove(ch)
// Initial Snapshot
if b := assetsSnapshotJSON(); len(b) > 0 {
if !writeEvent("state", b) {
return
}
}
ctx := r.Context()
ping := time.NewTicker(15 * time.Second)
defer ping.Stop()
for {
select {
case <-ctx.Done():
return
case b, ok := <-ch:
if !ok {
return
}
if len(b) == 0 {
continue
}
// coalesce
last := b
drain:
for i := 0; i < 64; i++ {
select {
case nb, ok := <-ch:
if !ok {
return
}
if len(nb) > 0 {
last = nb
}
default:
break drain
}
}
if !writeEvent("state", last) {
return
}
case <-ping.C:
if !writeComment(fmt.Sprintf("ping %d", time.Now().Unix())) {
return
}
}
}
}

View File

@ -1,7 +1,9 @@
// backend\tasks_assets.go
package main
import (
"context"
"errors"
"fmt"
"net/http"
"os"
@ -31,12 +33,30 @@ var assetsTaskMu sync.Mutex
var assetsTaskState AssetsTaskState
var assetsTaskCancel context.CancelFunc
// updateAssetsState mutiert den State atomar und triggert danach SSE notify.
// notifyAssetsChanged() muss außerhalb des Locks passieren.
func updateAssetsState(fn func(st *AssetsTaskState)) AssetsTaskState {
assetsTaskMu.Lock()
fn(&assetsTaskState)
st := assetsTaskState
assetsTaskMu.Unlock()
notifyAssetsChanged()
return st
}
func snapshotAssetsState() AssetsTaskState {
assetsTaskMu.Lock()
st := assetsTaskState
assetsTaskMu.Unlock()
return st
}
func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
assetsTaskMu.Lock()
st := assetsTaskState
assetsTaskMu.Unlock()
// GET bleibt als Fallback/Debug möglich (UI nutzt SSE)
st := snapshotAssetsState()
writeJSON(w, http.StatusOK, st)
return
@ -49,17 +69,28 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) {
return
}
// ✅ cancelbaren Context erzeugen
// cancelbarer Context (pro Run)
ctx, cancel := context.WithCancel(context.Background())
assetsTaskCancel = cancel
now := time.Now()
assetsTaskState = AssetsTaskState{
Running: true,
StartedAt: time.Now(),
Running: true,
Total: 0,
Done: 0,
GeneratedThumbs: 0,
GeneratedPreviews: 0,
Skipped: 0,
StartedAt: now,
FinishedAt: nil,
Error: "",
}
st := assetsTaskState
assetsTaskMu.Unlock()
// ✅ SSE: Start pushen
notifyAssetsChanged()
go runGenerateMissingAssets(ctx)
writeJSON(w, http.StatusOK, st)
@ -72,48 +103,60 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) {
assetsTaskMu.Unlock()
if !running || cancel == nil {
// nichts zu stoppen
w.WriteHeader(http.StatusNoContent)
return
}
// canceln: Worker merkt das beim nächsten ctx.Err() und beendet sauber
cancel()
// optional: sofortiges Feedback in state.error
assetsTaskMu.Lock()
if assetsTaskState.Running {
assetsTaskState.Error = "abgebrochen"
}
st := assetsTaskState
assetsTaskMu.Unlock()
// UI sofort informieren (ohne Running künstlich auf false zu setzen —
// das macht der Worker zuverlässig im finishWithErr(context.Canceled))
st := updateAssetsState(func(st *AssetsTaskState) {
if st.Running {
st.Error = "abgebrochen"
}
})
writeJSON(w, http.StatusOK, st)
return
default:
http.Error(w, "Nur GET/POST", http.StatusMethodNotAllowed)
http.Error(w, "Nur GET/POST/DELETE", http.StatusMethodNotAllowed)
return
}
}
func runGenerateMissingAssets(ctx context.Context) {
finishWithErr := func(err error) {
now := time.Now()
assetsTaskMu.Lock()
assetsTaskState.Running = false
assetsTaskState.FinishedAt = &now
if err != nil {
assetsTaskState.Error = err.Error()
}
assetsTaskMu.Unlock()
}
// Worker-Ende: CancelFunc zurücksetzen (pro Run)
defer func() {
assetsTaskMu.Lock()
assetsTaskCancel = nil
assetsTaskMu.Unlock()
}()
finishWithErr := func(err error) {
now := time.Now()
updateAssetsState(func(st *AssetsTaskState) {
st.Running = false
st.FinishedAt = &now
if err == nil {
// Erfolg: Error leeren
st.Error = ""
return
}
// stabiler Text für UI
if errors.Is(err, context.Canceled) {
st.Error = "abgebrochen"
} else {
st.Error = err.Error()
}
})
}
s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil || strings.TrimSpace(doneAbs) == "" {
@ -150,7 +193,6 @@ func runGenerateMissingAssets(ctx context.Context) {
return
}
// Dedupe
if _, ok := seen[full]; ok {
return
}
@ -164,7 +206,6 @@ func runGenerateMissingAssets(ctx context.Context) {
return
}
for _, e := range ents {
// .trash-Ordner nie scannen
if e.IsDir() && strings.EqualFold(e.Name(), ".trash") {
continue
}
@ -187,18 +228,20 @@ func runGenerateMissingAssets(ctx context.Context) {
}
}
// done + done/<model>/ + done/keep + done/keep/<model>/
// done + done/<model>/ + done/keep + done/keep/<model>/
scanOneLevel(doneAbs)
scanOneLevel(filepath.Join(doneAbs, "keep"))
assetsTaskMu.Lock()
assetsTaskState.Total = len(items)
assetsTaskState.Done = 0
assetsTaskState.GeneratedThumbs = 0
assetsTaskState.GeneratedPreviews = 0
assetsTaskState.Skipped = 0
assetsTaskState.Error = ""
assetsTaskMu.Unlock()
// ✅ Initialisierung: Total etc. + SSE Push
updateAssetsState(func(st *AssetsTaskState) {
st.Total = len(items)
st.Done = 0
st.GeneratedThumbs = 0
st.GeneratedPreviews = 0
st.Skipped = 0
// Start hat Error schon geleert — hier nur sicherheitshalber:
st.Error = ""
})
for i, it := range items {
if err := ctx.Err(); err != nil {
@ -206,175 +249,63 @@ func runGenerateMissingAssets(ctx context.Context) {
return
}
// ID aus Dateiname
base := strings.TrimSuffix(it.name, filepath.Ext(it.name))
id := stripHotPrefix(base)
if strings.TrimSpace(id) == "" {
assetsTaskMu.Lock()
assetsTaskState.Done = i + 1
assetsTaskMu.Unlock()
updateAssetsState(func(st *AssetsTaskState) {
st.Done = i + 1
})
continue
}
assetDir, derr := ensureGeneratedDir(id)
if derr != nil {
assetsTaskMu.Lock()
assetsTaskState.Error = "mindestens ein Eintrag konnte nicht verarbeitet werden (siehe Logs)"
assetsTaskState.Done = i + 1
assetsTaskMu.Unlock()
fmt.Println("⚠️ ensureGeneratedDir:", derr)
continue
}
thumbPath := filepath.Join(assetDir, "thumbs.jpg")
previewPath := filepath.Join(assetDir, "preview.mp4")
metaPath := filepath.Join(assetDir, "meta.json")
thumbOK := func() bool {
fi, err := os.Stat(thumbPath)
return err == nil && !fi.IsDir() && fi.Size() > 0
}()
previewOK := func() bool {
fi, err := os.Stat(previewPath)
return err == nil && !fi.IsDir() && fi.Size() > 0
}()
// Datei-Info (für Meta-Validierung)
// Datei-Info (validieren)
vfi, verr := os.Stat(it.path)
if verr != nil || vfi.IsDir() || vfi.Size() <= 0 {
assetsTaskMu.Lock()
assetsTaskState.Done = i + 1
assetsTaskMu.Unlock()
updateAssetsState(func(st *AssetsTaskState) {
st.Done = i + 1
})
continue
}
// ✅ SourceURL best-effort: aus bestehender meta.json, wenn vorhanden/valide
// Pfade einmalig über zentralen Helper
_, _, _, metaPath, perr := assetPathsForID(id)
if perr != nil {
updateAssetsState(func(st *AssetsTaskState) {
// UI bekommt stabilen Hinweis, aber Task läuft weiter
st.Error = "mindestens ein Eintrag konnte nicht verarbeitet werden (siehe Logs)"
st.Done = i + 1
})
fmt.Println("⚠️ assetPathsForID:", perr)
continue
}
// SourceURL best-effort: aus bestehender meta.json
sourceURL := ""
if u, ok := readVideoMetaSourceURL(metaPath, vfi); ok {
sourceURL = u
}
// ✅ Meta: Duration + Props (w/h/fps) => damit Resolution in meta.json landet
durSec := 0.0
vw, vh := 0, 0
fps := 0.0
// Wir wollen nicht nur "Duration ok", sondern auch Props ok.
// Sonst wird später fälschlich "skipped" und Resolution bleibt für immer leer.
metaOK := false
// 1) Versuch: komplette Meta lesen (Duration + w/h/fps)
if d, mw, mh, mfps, ok := readVideoMeta(metaPath, vfi); ok {
durSec, vw, vh, fps = d, mw, mh, mfps
} else {
// 2) Fallback: Duration berechnen
dctx, cancel := context.WithTimeout(ctx, 6*time.Second)
d, derr := durationSecondsCached(dctx, it.path)
cancel()
if derr == nil && d > 0 {
durSec = d
}
// Generate/Ensure (einheitliche Core-Funktion)
res, e := ensureAssetsForVideoWithProgressCtx(ctx, it.path, sourceURL, nil)
if e != nil {
finishWithErr(e)
return
}
// 3) Wenn wir Duration haben, aber Props fehlen: einmal ffprobe für Props
if durSec > 0 && (vw <= 0 || vh <= 0 || fps <= 0) {
pctx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
// optional: Semaphore verwenden (du hast durSem global)
if durSem != nil {
if err := durSem.Acquire(pctx); err == nil {
vw, vh, fps, _ = probeVideoProps(pctx, it.path)
durSem.Release()
}
} else {
vw, vh, fps, _ = probeVideoProps(pctx, it.path)
// ✅ Progress + Counters + SSE Push
updateAssetsState(func(st *AssetsTaskState) {
if res.Skipped {
st.Skipped++
}
}
// 4) Jetzt voll schreiben (inkl. Resolution via formatResolution)
if durSec > 0 {
_ = writeVideoMeta(metaPath, vfi, durSec, vw, vh, fps, sourceURL)
}
// Meta gilt nur als "OK", wenn Duration + Auflösung vorhanden ist
metaOK = durSec > 0 && vw > 0 && vh > 0
if thumbOK && previewOK && metaOK {
assetsTaskMu.Lock()
assetsTaskState.Skipped++
assetsTaskState.Done = i + 1
assetsTaskMu.Unlock()
continue
}
// ----------------
// Thumbs
// ----------------
if !thumbOK {
genCtx, cancel := context.WithTimeout(ctx, 45*time.Second)
if err := thumbSem.Acquire(genCtx); err != nil {
cancel()
finishWithErr(err)
return
if res.ThumbGenerated {
st.GeneratedThumbs++
}
cancel() // Timeout-Context freigeben, Semaphore bleibt gehalten
defer thumbSem.Release()
t := 0.0
if durSec > 0 {
t = durSec * 0.5
if res.PreviewGenerated {
st.GeneratedPreviews++
}
img, e1 := extractFrameAtTimeJPEG(it.path, t)
if e1 != nil || len(img) == 0 {
img, e1 = extractLastFrameJPEG(it.path)
if e1 != nil || len(img) == 0 {
img, e1 = extractFirstFrameJPEG(it.path)
}
}
// Release wurde defert, aber wir wollen pro Iteration releasen:
thumbSem.Release()
if e1 == nil && len(img) > 0 {
if err := atomicWriteFile(thumbPath, img); err == nil {
assetsTaskMu.Lock()
assetsTaskState.GeneratedThumbs++
assetsTaskMu.Unlock()
} else {
fmt.Println("⚠️ thumb write:", err)
}
}
}
// ----------------
// Preview
// ----------------
if !previewOK {
genCtx, cancel := context.WithTimeout(ctx, 3*time.Minute)
if err := genSem.Acquire(genCtx); err != nil {
cancel()
finishWithErr(err)
return
}
err := generateTeaserClipsMP4(genCtx, it.path, previewPath, 1.0, 18)
genSem.Release()
cancel()
if err == nil {
assetsTaskMu.Lock()
assetsTaskState.GeneratedPreviews++
assetsTaskMu.Unlock()
} else {
fmt.Println("⚠️ preview clips:", err)
}
}
assetsTaskMu.Lock()
assetsTaskState.Done = i + 1
assetsTaskMu.Unlock()
st.Done = i + 1
})
}
finishWithErr(nil)

View File

@ -165,6 +165,53 @@ func generateTeaserChunkMP4(ctx context.Context, src, out string, start, dur flo
return os.Rename(tmp, out)
}
func computeTeaserStarts(dur float64, opts TeaserPreviewOptions) (starts []float64, segDur float64, usedSegments int) {
// opts normalisieren wie in generateTeaserPreviewMP4WithProgress
if opts.SegmentDuration <= 0 {
opts.SegmentDuration = 1
}
if opts.Segments <= 0 {
opts.Segments = 18
}
segDur = opts.SegmentDuration
if segDur < minSegmentDuration {
segDur = minSegmentDuration
}
// Kurzvideo-Fallback: wenn Video kürzer als Segments*SegmentDuration -> 1 Segment über ganze Dauer
if dur > 0 && dur < segDur*float64(opts.Segments) {
opts.Segments = 1
segDur = dur
}
usedSegments = opts.Segments
// Dauer unbekannt: Start 0
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,
@ -223,26 +270,9 @@ func generateTeaserPreviewMP4WithProgress(
return err
}
// Startpunkte wie "die andere": offset + i*stepSize
stepSize, offset := opts.stepSizeAndOffset(dur)
starts := make([]float64, 0, opts.Segments)
for i := 0; i < opts.Segments; i++ {
t := offset + float64(i)*stepSize
// clamp: sicherstellen, dass wir nicht über Ende hinaus trimmen
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)
}
starts, segDurComputed, _ := computeTeaserStarts(dur, opts)
// segDur ist später im Code benutzt -> segDur damit überschreiben:
segDur = segDurComputed
expectedOutSec := float64(len(starts)) * segDur
tmp := strings.TrimSuffix(outPath, ".mp4") + ".part.mp4"

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>App</title>
<script type="module" crossorigin src="/assets/index-JupgTTdL.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-SqYhLYXQ.css">
<script type="module" crossorigin src="/assets/index-rrLyu52u.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cd67oQ3U.css">
</head>
<body>
<div id="root"></div>

View File

@ -401,12 +401,109 @@ export default function App() {
}
})
type DonePrefetch = {
key: string
items: RecordJob[]
ts: number
}
const donePrefetchRef = useRef<DonePrefetch | null>(null)
const donePrefetchInFlightRef = useRef(false)
// ✅ verhindert "pending forever": immer nur 1 done-fetch gleichzeitig
const doneFetchAbortRef = useRef<AbortController | null>(null)
const doneFetchInFlightRef = useRef(false)
const makePrefetchKey = (page: number, sort: DoneSortMode) => `${sort}::${page}`
const prefetchDonePage = useCallback(async (pageToFetch: number) => {
if (pageToFetch < 1) return
if (donePrefetchInFlightRef.current) return
const key = makePrefetchKey(pageToFetch, doneSort)
const cur = donePrefetchRef.current
if (cur?.key === key && Date.now() - cur.ts < 15_000) {
// frisch genug
return
}
donePrefetchInFlightRef.current = true
try {
const res = await fetch(
`/api/record/done?page=${pageToFetch}&pageSize=${DONE_PAGE_SIZE}&sort=${encodeURIComponent(doneSort)}`,
{ cache: 'no-store' as any }
)
if (!res.ok) return
const data = await res.json().catch(() => null)
const items = Array.isArray(data?.items)
? (data.items as RecordJob[])
: Array.isArray(data)
? (data as RecordJob[])
: []
donePrefetchRef.current = { key, items, ts: Date.now() }
} finally {
donePrefetchInFlightRef.current = false
}
}, [doneSort])
const loadDoneCount = useCallback(async () => {
try {
// ✅ leichtgewichtiger Endpoint (siehe Backend unten)
const res = await fetch(`/api/record/done/meta`, { cache: 'no-store' as any })
if (!res.ok) return
const data = await res.json().catch(() => null)
const countRaw = Number(data?.count ?? 0)
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0
setDoneCount(count)
setLastHeaderUpdateAtMs(Date.now())
} catch {
// ignore
}
}, [])
// ✅ sagt FinishedDownloads: "bitte ALL neu laden"
const requestFinishedReload = useCallback(() => {
window.dispatchEvent(new CustomEvent('finished-downloads:reload'))
}, [])
const loadJobs = useCallback(async () => {
try {
const res = await fetch('/api/record/list', { cache: 'no-store' as any })
if (!res.ok) return
const data = await res.json().catch(() => null)
// akzeptiere: Array oder { items: [] }
const items = Array.isArray(data)
? (data as RecordJob[])
: Array.isArray(data?.items)
? (data.items as RecordJob[])
: []
setJobs(items)
jobsRef.current = items
setLastHeaderUpdateAtMs(Date.now())
} catch {
// ignore
}
}, [])
useEffect(() => {
try {
window.localStorage.setItem(DONE_SORT_KEY, doneSort)
} catch {}
}, [doneSort])
useEffect(() => {
if (!authed) return
void loadDoneCount()
}, [authed, loadDoneCount])
const [playerModelKey, setPlayerModelKey] = useState<string | null>(null)
const [sourceUrl, setSourceUrl] = useState('')
const [jobs, setJobs] = useState<RecordJob[]>([])
@ -594,13 +691,6 @@ export default function App() {
const [assetNonce, setAssetNonce] = useState(0)
const bumpAssets = useCallback(() => setAssetNonce((n) => n + 1), [])
const assetsBumpTimerRef = useRef<number | null>(null)
const bumpAssetsTwice = useCallback(() => {
bumpAssets()
if (assetsBumpTimerRef.current) window.clearTimeout(assetsBumpTimerRef.current)
assetsBumpTimerRef.current = window.setTimeout(() => bumpAssets(), 3500)
}, [bumpAssets])
const [recSettings, setRecSettings] = useState<RecorderSettings>(DEFAULT_RECORDER_SETTINGS)
const recSettingsRef = useRef(recSettings)
@ -1108,251 +1198,37 @@ export default function App() {
localStorage.setItem(COOKIE_STORAGE_KEY, JSON.stringify(cookies))
}, [cookies, cookiesLoaded])
// ✅ done count polling über /api/record/done (kein /done/meta mehr)
useEffect(() => {
let cancelled = false
let t: number | undefined
const loadDoneCount = async () => {
try {
// pageSize=1 => minimaler Payload, zählt trotzdem korrekt
const res = await fetch(
`/api/record/done?page=1&pageSize=1&withCount=1&sort=${encodeURIComponent(doneSort)}`,
{ cache: 'no-store' as any }
)
if (!res.ok) return
const data = await res.json().catch(() => null)
const countRaw =
Number(data?.count ?? data?.totalCount ?? 0)
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0
if (!cancelled) {
setDoneCount(count)
setLastHeaderUpdateAtMs(Date.now())
}
} catch {
// ignore
} finally {
if (!cancelled) {
const ms = document.hidden ? 60_000 : 30_000
t = window.setTimeout(loadDoneCount, ms)
}
}
}
// 1x initial / bei sort-wechsel (für Badge)
void loadDoneCount()
const onVis = () => {
if (!document.hidden) void loadDoneCount()
}
document.addEventListener('visibilitychange', onVis)
void loadDoneCount()
return () => {
cancelled = true
if (t) window.clearTimeout(t)
document.removeEventListener('visibilitychange', onVis)
}
}, [doneSort])
useEffect(() => {
const maxPage = Math.max(1, Math.ceil(doneCount / DONE_PAGE_SIZE))
if (donePage > maxPage) setDonePage(maxPage)
}, [doneCount, donePage])
// jobs SSE / polling (mit "Job gestartet" Toast für Backend-Autostarts)
useEffect(() => {
if (!authed) return // ✅ WICHTIG: bei Logout alles stoppen
let cancelled = false
let es: EventSource | null = null
let fallbackTimer: number | null = null
let inFlight = false
const stopFallbackPolling = () => {
if (fallbackTimer) {
window.clearInterval(fallbackTimer)
fallbackTimer = null
}
}
const applyList = (list: any) => {
const arr = Array.isArray(list) ? (list as RecordJob[]) : []
if (cancelled) return
// --- vorheriger Snapshot für Status-Transitions ---
const prev = jobsRef.current
const prevStatusById = new Map<string, string>()
for (const j of Array.isArray(prev) ? prev : []) {
const id = String((j as any)?.id ?? '')
if (!id) continue
prevStatusById.set(id, String((j as any)?.status ?? ''))
}
// ✅ 0) Initial load: KEINE Toasts, aber als "gesehen" markieren (falls du später wieder Start-Toast einführen willst)
if (!jobsInitDoneRef.current) {
const seen: Record<string, true> = {}
for (const j of arr) {
const id = String((j as any)?.id ?? '')
if (id) seen[id] = true
}
startedToastByJobIdRef.current = seen
jobsInitDoneRef.current = true
}
// ✅ Finished/Stopped/Failed Transition zählen -> Count-Hint + Asset-Bump
const terminal = new Set(['finished', 'stopped', 'failed'])
let endedDelta = 0
for (const j of arr) {
const id = String((j as any)?.id ?? '')
if (!id) continue
const before = String(prevStatusById.get(id) ?? '').toLowerCase().trim()
const now = String((j as any)?.status ?? '').toLowerCase().trim()
if (!before || before === now) continue
// nur zählen, wenn wir "neu" in einen terminal state gehen
if (terminal.has(now) && !terminal.has(before)) endedDelta++
}
if (endedDelta > 0) {
window.dispatchEvent(
new CustomEvent('finished-downloads:count-hint', { detail: { delta: endedDelta } })
)
bumpAssetsTwice()
}
// ---- Queue-Info berechnen (Postwork-Warteschlange) ----
const statusLower = (j: any) => String(j?.status ?? '').toLowerCase().trim()
const isPostworkQueued = (j: any) => {
const s = statusLower(j)
return s === 'postwork' || s === 'queued_postwork' || s === 'waiting_postwork'
}
const ts = (j: any) =>
Number(
j?.endedAtMs ??
j?.endedAt ??
j?.createdAtMs ??
j?.createdAt ??
j?.startedAtMs ??
j?.startedAt ??
0
) || 0
const postworkQueue = arr
.filter(isPostworkQueued)
.slice()
.sort((a, b) => ts(a) - ts(b))
const postworkTotal = postworkQueue.length
const postworkPosById = new Map<string, number>()
for (let i = 0; i < postworkQueue.length; i++) {
const id = String((postworkQueue[i] as any)?.id ?? '')
if (id) postworkPosById.set(id, i + 1)
}
const arrWithQueue = arr.map((j: any) => {
const id = String(j?.id ?? '')
const pos = id ? postworkPosById.get(id) : undefined
if (!pos) return j
return {
...j,
postworkQueuePos: pos,
postworkQueueTotal: postworkTotal,
}
})
setJobs(arrWithQueue)
jobsRef.current = arrWithQueue
setPlayerJob((prevJob) => {
if (!prevJob) return prevJob
const updated = arrWithQueue.find((j) => j.id === prevJob.id)
if (updated) return updated
// wenn running und nicht mehr in list: player schließen, sonst stehen lassen
return prevJob.status === 'running' ? null : prevJob
})
}
const loadOnce = async () => {
if (cancelled || inFlight) return
inFlight = true
try {
const list = await apiJSON<RecordJob[]>('/api/record/list')
applyList(list)
} catch {
// ignore
} finally {
inFlight = false
}
}
const startFallbackPolling = () => {
if (fallbackTimer) return
fallbackTimer = window.setInterval(loadOnce, document.hidden ? 15000 : 5000)
}
void loadOnce()
es = new EventSource('/api/record/stream')
// ✅ wenn SSE wieder verbunden ist: Fallback-Polling stoppen
es.onopen = () => {
stopFallbackPolling()
}
const onJobs = (ev: MessageEvent) => {
stopFallbackPolling() // ✅ sobald Daten kommen, Polling aus
try {
applyList(JSON.parse(ev.data))
} catch {}
}
es.addEventListener('jobs', onJobs as any)
es.onerror = () => startFallbackPolling()
const onVis = () => {
if (!document.hidden) void loadOnce()
}
document.addEventListener('visibilitychange', onVis)
// ❌ das hier empfehle ich rauszuwerfen, siehe Schritt C
// window.addEventListener('hover', onVis)
return () => {
cancelled = true
stopFallbackPolling()
document.removeEventListener('visibilitychange', onVis)
// window.removeEventListener('hover', onVis)
es?.removeEventListener('jobs', onJobs as any)
es?.close()
es = null
}
}, [authed])
}, [loadDoneCount])
useEffect(() => {
if (selectedTab !== 'finished') return
const refreshDoneNow = useCallback(
async (preferPage?: number) => {
// ✅ wenn noch ein done-fetch läuft: abbrechen (sonst stauen sich Requests)
if (doneFetchInFlightRef.current) {
doneFetchAbortRef.current?.abort()
}
let cancelled = false
const inFlightRef = { current: false }
const ac = new AbortController()
const loadDone = async () => {
if (cancelled || inFlightRef.current) return
inFlightRef.current = true
const ac = new AbortController()
doneFetchAbortRef.current = ac
doneFetchInFlightRef.current = true
try {
const wanted = typeof preferPage === 'number' ? preferPage : donePage
const res = await fetch(
`/api/record/done?page=${donePage}&pageSize=${DONE_PAGE_SIZE}` +
`&sort=${encodeURIComponent(doneSort)}` +
`&withCount=1`,
`/api/record/done?page=${wanted}&pageSize=${DONE_PAGE_SIZE}` +
`&sort=${encodeURIComponent(doneSort)}&withCount=1`,
{ cache: 'no-store' as any, signal: ac.signal }
)
@ -1360,69 +1236,12 @@ export default function App() {
const data = await res.json().catch(() => null)
// akzeptiere beide Formen:
// A) { count, items }
// B) doneListResponse { totalCount, items }
// C) Legacy array
const items = Array.isArray(data?.items)
? (data.items as RecordJob[])
: Array.isArray(data)
? (data as RecordJob[])
: []
const countRaw =
typeof data?.count === 'number'
? data.count
: typeof data?.totalCount === 'number'
? data.totalCount
: items.length
if (!cancelled) {
setDoneJobs(items)
setDoneCount(Number.isFinite(countRaw) ? countRaw : items.length)
}
} catch {
if (!cancelled) {
setDoneJobs([])
setDoneCount(0)
}
} finally {
inFlightRef.current = false
}
}
void loadDone()
const baseMs = 20000
const t = window.setInterval(() => {
if (!document.hidden) void loadDone()
}, baseMs)
const onVis = () => {
if (!document.hidden) void loadDone()
}
document.addEventListener('visibilitychange', onVis)
return () => {
cancelled = true
ac.abort()
window.clearInterval(t)
document.removeEventListener('visibilitychange', onVis)
}
}, [selectedTab, donePage, doneSort])
const refreshDoneNow = useCallback(
async (preferPage?: number) => {
try {
const wanted = typeof preferPage === 'number' ? preferPage : donePage
const data = await apiJSON<any>(
`/api/record/done?page=${wanted}&pageSize=${DONE_PAGE_SIZE}` +
`&sort=${encodeURIComponent(doneSort)}&withCount=1`,
{ cache: 'no-store' as any }
)
const items = Array.isArray(data?.items) ? (data.items as RecordJob[]) : []
const countRaw = Number(data?.count ?? data?.totalCount ?? items.length)
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : items.length
@ -1432,53 +1251,190 @@ export default function App() {
const target = Math.min(Math.max(1, wanted), maxPage)
if (target !== donePage) setDonePage(target)
// wenn target anders ist, optional nochmal mit target laden:
if (target === wanted) {
setDoneJobs(items)
} else {
const data2 = await apiJSON<any>(
// Wenn wir auf eine andere Page clampen mussten: die richtige Page nachladen
if (target !== wanted) {
const res2 = await fetch(
`/api/record/done?page=${target}&pageSize=${DONE_PAGE_SIZE}` +
`&sort=${encodeURIComponent(doneSort)}&withCount=1`,
{ cache: 'no-store' as any }
{ cache: 'no-store' as any, signal: ac.signal }
)
if (!res2.ok) throw new Error(`HTTP ${res2.status}`)
const data2 = await res2.json().catch(() => null)
const items2 = Array.isArray(data2?.items) ? (data2.items as RecordJob[]) : []
setDoneJobs(items2)
} else {
setDoneJobs(items)
}
setLastHeaderUpdateAtMs(Date.now())
} catch (e: any) {
// Abort ist ok
if (String(e?.name) !== 'AbortError') {
// optional: console.debug('[DONE] refresh failed', e)
}
} finally {
// ✅ Nur der "aktuelle" Request darf den InFlight-Status zurücksetzen.
// Sonst kann ein älterer (abgebrochener) Request einen neueren überschreiben.
const isCurrent = doneFetchAbortRef.current === ac
if (isCurrent) {
doneFetchAbortRef.current = null
doneFetchInFlightRef.current = false
}
} catch {
// ignore
}
},
[donePage, doneSort]
)
useEffect(() => {
let es: EventSource | null = null
try {
es = new EventSource('/api/record/done/stream')
} catch {
return
}
const onDone = () => {
// wenn finished tab offen: liste aktualisieren
if (selectedTabRef.current === 'finished') {
void refreshDoneNow()
} else {
// sonst nur count aktualisieren (leicht)
// optional: void loadDoneCount() wenn du es aus dem Scope verfügbar machst
useEffect(() => {
if (selectedTab !== 'finished') return
// ✅ Badge/Count updaten + FinishedDownloads (ALL) reloaden
void loadDoneCount()
requestFinishedReload()
const onVis = () => {
if (!document.hidden) {
void loadDoneCount()
requestFinishedReload()
}
}
document.addEventListener('visibilitychange', onVis)
return () => {
document.removeEventListener('visibilitychange', onVis)
}
}, [selectedTab, loadDoneCount, requestFinishedReload])
useEffect(() => {
const maxPage = Math.max(1, Math.ceil(doneCount / DONE_PAGE_SIZE))
if (donePage > maxPage) setDonePage(maxPage)
}, [doneCount, donePage])
// jobs SSE / polling (mit "Job gestartet" Toast für Backend-Autostarts)
useEffect(() => {
if (!authed) return
let es: EventSource | null = null
let timer: number | null = null
const stopPoll = () => {
if (timer != null) {
window.clearInterval(timer)
timer = null
}
}
es.addEventListener('doneChanged', onDone as any)
es.onerror = () => {
// fallback: dein bestehendes polling bleibt als sicherheit
const startPoll = () => {
if (timer != null) return
timer = window.setInterval(() => {
if (document.hidden) return
if (selectedTabRef.current === 'finished') {
void loadDoneCount()
requestFinishedReload()
} else {
void loadDoneCount()
}
}, document.hidden ? 60000 : 15000)
}
const lastFireRef = { t: 0 }
let coalesceTimer: number | null = null
const requestRefresh = () => {
const now = Date.now()
const since = now - lastFireRef.t
// coalesce bursts
if (since < 800) {
if (coalesceTimer != null) return
coalesceTimer = window.setTimeout(() => {
coalesceTimer = null
lastFireRef.t = Date.now()
if (selectedTabRef.current === 'finished') {
void loadDoneCount()
requestFinishedReload()
} else {
void loadDoneCount()
}
}, 900)
return
}
lastFireRef.t = now
if (selectedTabRef.current === 'finished') {
void loadDoneCount()
requestFinishedReload()
} else {
void loadDoneCount()
}
}
// initial
void loadDoneCount()
es = new EventSource('/api/record/done/stream')
es.onopen = () => {
// ✅ sobald SSE stabil da ist: Poll aus
stopPoll()
}
es.onerror = () => {
// ✅ SSE kaputt -> Poll an
startPoll()
}
const onDone = () => requestRefresh()
es.addEventListener('doneChanged', onDone as any)
const onVis = () => {
if (!document.hidden) requestRefresh()
}
document.addEventListener('visibilitychange', onVis)
return () => {
document.removeEventListener('visibilitychange', onVis)
if (coalesceTimer != null) window.clearTimeout(coalesceTimer)
stopPoll()
es?.removeEventListener('doneChanged', onDone as any)
es?.close()
es = null
}
}, [refreshDoneNow])
}, [authed, loadDoneCount, requestFinishedReload])
useEffect(() => {
if (!authed) return
// initial
void loadJobs()
// polling: schneller wenn running-tab offen oder jobs laufen
const t = window.setInterval(() => {
if (document.hidden) return
const hasRunning = jobsRef.current.some((j) => {
const s = String((j as any)?.status ?? '').toLowerCase()
return s === 'running' || s === 'postwork'
})
// wenn Tab "running" offen ODER irgendwas läuft -> häufiger pollen
if (selectedTabRef.current === 'running' || hasRunning) {
void loadJobs()
}
}, document.hidden ? 60000 : 3000) // 3s fühlt sich "live" an
const onVis = () => {
if (!document.hidden) void loadJobs()
}
document.addEventListener('visibilitychange', onVis)
return () => {
window.clearInterval(t)
document.removeEventListener('visibilitychange', onVis)
}
}, [authed, loadJobs])
function isChaturbate(raw: string): boolean {
const norm = normalizeHttpUrl(raw)
@ -1517,21 +1473,24 @@ export default function App() {
const onHint = (ev: Event) => {
const e = ev as CustomEvent<{ delta?: number }>
const delta = Number(e.detail?.delta ?? 0)
if (!Number.isFinite(delta) || delta === 0) {
void refreshDoneNow()
void loadDoneCount()
requestFinishedReload()
return
}
// ✅ Tabs sofort updaten (optimistisch)
setDoneCount((c) => Math.max(0, c + delta))
// ✅ danach einmal server-truth holen (Pagination + count 100% korrekt)
void refreshDoneNow()
// ✅ danach server-truth holen + ALL reload
void loadDoneCount()
requestFinishedReload()
}
window.addEventListener('finished-downloads:count-hint', onHint as EventListener)
return () => window.removeEventListener('finished-downloads:count-hint', onHint as EventListener)
}, [refreshDoneNow])
}, [loadDoneCount, requestFinishedReload])
useEffect(() => {
const onNav = (ev: Event) => {
@ -1608,9 +1567,46 @@ export default function App() {
)
window.setTimeout(() => {
setDoneJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
setDoneJobs((prev) => {
const filtered = prev.filter((j) => baseName(j.output || '') !== file)
// ✅ sofort auffüllen, wenn wir Platz haben
const need = DONE_PAGE_SIZE - filtered.length
if (need <= 0) return filtered
const prefetchKey = makePrefetchKey(donePage + 1, doneSort)
const buf = donePrefetchRef.current
if (!buf || buf.key !== prefetchKey || !Array.isArray(buf.items) || buf.items.length === 0) {
return filtered
}
const next: RecordJob[] = [...filtered]
const used = new Set(next.map((x) => String(x.id || baseName(x.output || '')).trim()))
while (next.length < DONE_PAGE_SIZE && buf.items.length > 0) {
const cand = buf.items.shift()!
const id = String(cand.id || baseName(cand.output || '')).trim()
if (!id || used.has(id)) continue
used.add(id)
next.push(cand)
}
// buffer zurückschreiben (mit verkürzter items-Liste)
donePrefetchRef.current = { ...buf, items: buf.items, ts: buf.ts }
return next
})
// ✅ Count sofort optimistisch runter
setDoneCount((c) => Math.max(0, c - 1))
// ✅ Player / jobs cleanup wie bei dir
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
// ✅ Buffer direkt wieder nachfüllen (background)
void prefetchDonePage(donePage + 1)
}, 320)
const undoToken = typeof data?.undoToken === 'string' ? data.undoToken : ''
@ -1619,7 +1615,7 @@ export default function App() {
window.dispatchEvent(
new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'error' as const } })
)
notify.error('Löschen fehlgeschlagen', e?.message ?? String(e))
notify.error('Löschen fehlgeschlagen: ', file)
return // ✅ void statt null
}
},
@ -1652,7 +1648,7 @@ export default function App() {
} catch (e: any) {
window.dispatchEvent(new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'error' as const } }))
notify.error('Keep fehlgeschlagen', e?.message ?? String(e))
notify.error('Keep fehlgeschlagen', file)
return
}
},
@ -1665,6 +1661,11 @@ export default function App() {
if (!file) return
try {
// ✅ Player/Preview soll den alten File-Key loslassen, bevor umbenannt wird
window.dispatchEvent(new CustomEvent('player:release', { detail: { file } }))
// kurze Pause hilft in der Praxis, wenn Video.js/Browser noch “dran” hängt
await new Promise((r) => window.setTimeout(r, 60))
const res = await apiJSON<{ ok: boolean; oldFile: string; newFile: string }>(
`/api/record/toggle-hot?file=${encodeURIComponent(file)}`,
{ method: 'POST' }
@ -1697,8 +1698,11 @@ export default function App() {
return match ? { ...j, output: apply(j.output || '') } : j
})
)
return res
} catch (e: any) {
notify.error('Umbenennen fehlgeschlagen', e?.message ?? String(e))
return
}
},
[notify]
@ -2384,7 +2388,7 @@ export default function App() {
getShow: () => ['public', 'private', 'hidden', 'away'],
intervalMs: 12000,
intervalMs: 8000,
onData: (data: ChaturbateOnlineResponse) => {
void (async () => {
@ -2830,6 +2834,7 @@ export default function App() {
setDoneSort(m)
setDonePage(1)
}}
loadMode="all"
/>
) : null}
@ -2871,6 +2876,12 @@ export default function App() {
{playerJob ? (
<Player
key={[
String((playerJob as any)?.id ?? ''),
baseName(playerJob.output || ''),
// optional: assetNonce, wenn du auch Asset-Rebuilds “erzwingen” willst
String(assetNonce),
].join('::')}
job={playerJob}
modelKey={playerModelKey ?? undefined}
modelsByKey={modelsByKey}

View File

@ -26,9 +26,9 @@ function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ')
}
const sizeMap: Record<Size, { btn: string; icon: string }> = {
sm: { btn: 'px-2.5 py-1.5 text-sm', icon: 'size-5' },
md: { btn: 'px-3 py-2 text-sm', icon: 'size-5' },
const sizeMap: Record<Size, { btn: string; icon: string; iconOnly: string }> = {
sm: { btn: 'px-2.5 py-1.5 text-sm', icon: 'size-5', iconOnly: 'h-9 w-9' },
md: { btn: 'px-3 py-2 text-sm', icon: 'size-5', iconOnly: 'h-10 w-10' },
}
export default function ButtonGroup({
@ -57,7 +57,7 @@ export default function ButtonGroup({
onClick={() => onChange(it.id)}
aria-pressed={active}
className={cn(
'relative inline-flex items-center justify-center font-semibold focus:z-10 transition-colors',
'relative inline-flex items-center justify-center font-semibold leading-none focus:z-10 transition-colors',
!isFirst && '-ml-px',
isFirst && 'rounded-l-md',
isLast && 'rounded-r-md',
@ -73,7 +73,7 @@ export default function ButtonGroup({
'disabled:opacity-50 disabled:cursor-not-allowed',
// Padding / Größe
iconOnly ? 'px-2 py-2' : s.btn
iconOnly ? `p-0 ${s.iconOnly}` : s.btn
)}
title={typeof it.label === 'string' ? it.label : it.srLabel}
>

View File

@ -62,11 +62,11 @@ function modelKeyFromFilename(fileOrPath: string): string | null {
return stem ? stem.trim() : null
}
// ✅ passt zu Backend: generated/meta/<id>/thumbs.jpg
// ✅ passt zu Backend: generated/meta/<id>/thumbs.webp
function thumbUrlFromOutput(output: string): string | null {
const id = assetIdFromOutput(output)
if (!id) return null
return `/generated/meta/${encodeURIComponent(id)}/thumbs.jpg`
return `/generated/meta/${encodeURIComponent(id)}/thumbs.webp`
}
async function ensureCover(category: string, thumbPath: string, modelName: string | null, refresh: boolean) {
@ -77,7 +77,11 @@ async function ensureCover(category: string, thumbPath: string, modelName: strin
(m ? `&model=${encodeURIComponent(m)}` : ``) +
(refresh ? `&refresh=1` : ``)
await fetch(url, { method: 'GET', cache: 'no-store' as any })
const res = await fetch(url, { method: 'GET', cache: 'no-store' as any })
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `ensureCover failed: HTTP ${res.status}`)
}
}
type TagRow = {
@ -101,6 +105,7 @@ export default function CategoriesTab() {
const [err, setErr] = React.useState<string | null>(null)
const [coverBust, setCoverBust] = React.useState<number>(() => Date.now())
const [coverState, setCoverState] = React.useState<Record<string, 'ok' | 'error'>>({})
const [hasCoverByTag, setHasCoverByTag] = React.useState<Record<string, boolean>>({})
const [renewing, setRenewing] = React.useState(false)
const [renewProgress, setRenewProgress] = React.useState<{ done: number; total: number } | null>(null)
@ -248,23 +253,31 @@ export default function CategoriesTab() {
}))
.sort((a, b) => a.tag.localeCompare(b.tag, undefined, { sensitivity: 'base' }))
let coverInfoByTag = new Map<string, CoverInfoListItem>()
// coverinfo/list holen (optional)
const coverInfoByTag = new Map<string, CoverInfoListItem>()
try {
const infos = await apiJSON<CoverInfoListItem[]>('/api/generated/coverinfo/list', {
cache: 'no-store' as any,
signal: ac.signal as any,
})
for (const i of Array.isArray(infos) ? infos : []) {
const k = String(i?.category ?? '').trim().toLowerCase()
const k = String((i as any)?.category ?? '').trim().toLowerCase()
if (k) coverInfoByTag.set(k, i)
}
} catch {
// optional: still weiterlaufen ohne Fallback
// weiter ohne coverinfo
}
// stabiles Model pro Tag (für &model= in <img>)
const coverModelByTag: Record<string, string> = {}
// ✅ 1) hasCoverByTag befüllen (Default: false)
const has: Record<string, boolean> = {}
for (const r of outRows) {
const info = coverInfoByTag.get(r.tag)
has[r.tag] = Boolean(info?.hasCover)
}
setHasCoverByTag(has)
// ✅ 2) stabiles Model pro Tag (für Overlay)
const nextCoverModelByTag: Record<string, string> = {}
for (const r of outRows) {
// 1) bevorzugt aus candidates (doneJobs)
const list = candMap[r.tag] || []
@ -279,15 +292,29 @@ export default function CategoriesTab() {
}
}
if (model) coverModelByTag[r.tag] = model
if (model) nextCoverModelByTag[r.tag] = model
}
setCoverModelByTag(nextCoverModelByTag)
setCoverModelByTag(coverModelByTag)
// ✅ 3) coverState aufräumen:
// - Tags die nicht mehr existieren rauswerfen
// - Tags ohne Cover => state entfernen (damit nix "ok" bleibt)
setCoverState((prev) => {
const next: Record<string, 'ok' | 'error'> = {}
for (const r of outRows) {
const st = prev[r.tag]
if (!st) continue
if (has[r.tag] !== true) continue
next[r.tag] = st
}
return next
})
setRows(outRows)
// nur cache-bust der IMG URLs (wenn du willst)
setCoverBust(Date.now())
// ❗ Optional: NICHT immer busten (sonst unnötige Reloads).
// Wenn du trotzdem jedes Mal neu laden willst, uncomment:
// setCoverBust(Date.now())
} catch (e: any) {
if (e?.name === 'AbortError') return
@ -295,6 +322,8 @@ export default function CategoriesTab() {
setRows([])
candidatesRef.current = {}
setCoverModelByTag({})
setHasCoverByTag({})
setCoverState({})
} finally {
// nur "aus" schalten, wenn dieser refresh noch der aktuelle ist
if (refreshAbortRef.current === ac) {
@ -324,34 +353,44 @@ export default function CategoriesTab() {
rows.map(async (r) => {
try {
const list = candMap[r.tag] || []
const pick = list.length ? list[Math.floor(Math.random() * list.length)] : ''
const thumb = pick ? thumbUrlFromOutput(pick) : null
if (thumb) {
const model = pick ? modelKeyFromFilename(pick) : null
await ensureCover(r.tag, thumb, model, true)
// ✅ Overlay-Model = wirklich genutztes Model
setCoverModelByTag((prev) => {
const next = { ...prev }
if (model?.trim()) next[r.tag] = model.trim()
else delete next[r.tag]
return next
})
return { tag: r.tag, ok: true, status: 200, text: '' }
// ✅ wenn es keine Kandidaten gibt -> NICHT versuchen, /cover zu fetchen (vermeidet 404 komplett)
if (list.length === 0) {
// optional: wenn du willst, kannst du hier hasCoverByTag NICHT anfassen.
// Wir sagen nur: "konnte nicht erneuern, weil keine Quelle da"
return { tag: r.tag, ok: true, status: 0, text: 'skipped (no candidates)' }
}
const model = coverModelByTag[r.tag] ?? ''
// ✅ random Pick -> Thumb -> ensureCover (generiert Cover garantiert ohne 404, sofern Thumb existiert)
const pick = list[Math.floor(Math.random() * list.length)]
const thumb = thumbUrlFromOutput(pick)
const res = await fetch(coverSrc(r.tag, Date.now(), true, model), {
method: 'GET',
cache: 'no-store',
if (!thumb) {
return { tag: r.tag, ok: true, status: 0, text: 'skipped (no thumb url)' }
}
const model = modelKeyFromFilename(pick)
await ensureCover(r.tag, thumb, model, true)
// ✅ WICHTIG: nach Erfolg UI-Flags setzen, sonst "sieht" die UI das neue Cover nicht
setHasCoverByTag((p) => ({ ...p, [r.tag]: true }))
setCoverModelByTag((prev) => {
const next = { ...prev }
if (model?.trim()) next[r.tag] = model.trim()
else delete next[r.tag]
return next
})
const text = !res.ok ? await res.text().catch(() => '') : ''
const ok = res.ok || res.status === 404
return { tag: r.tag, ok, status: res.status, text }
// ✅ CoverState resetten, damit <img> neu lädt und onLoad wieder "ok" setzen kann
setCoverState((s) => {
const n = { ...s }
delete n[r.tag]
return n
})
return { tag: r.tag, ok: true, status: 200, text: '' }
} catch (e: any) {
return { tag: r.tag, ok: false, status: 0, text: e?.message ?? String(e) }
} finally {
@ -360,18 +399,18 @@ export default function CategoriesTab() {
})
)
const failedNo404 = results.filter((x) => !x.ok && x.status !== 404)
if (failedNo404.length) {
console.warn('Cover renew failed:', failedNo404.slice(0, 20))
const sample = failedNo404.slice(0, 8).map((f) => `${f.tag} (${f.status || 'ERR'})`).join(', ')
setErr(`Covers fehlgeschlagen: ${failedNo404.length}/${results.length} — z.B.: ${sample}`)
const failed = results.filter((x) => !x.ok)
if (failed.length) {
console.warn('Cover renew failed:', failed.slice(0, 20))
const sample = failed.slice(0, 8).map((f) => `${f.tag} (ERR)`).join(', ')
setErr(`Covers fehlgeschlagen: ${failed.length}/${results.length} — z.B.: ${sample}`)
} else {
setErr(null)
}
} finally {
// ✅ nach Batch einmal busten reicht
setCoverBust(Date.now())
setRenewing(false)
// optional: kurz stehen lassen, dann ausblenden
setTimeout(() => setRenewProgress(null), 400)
}
}, [rows, renewing])
@ -425,7 +464,14 @@ export default function CategoriesTab() {
<div className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{rows.map((r) => {
const model = coverModelByTag[r.tag] ?? null
const img = coverSrc(r.tag, coverBust, false)
// ✅ wichtig: nur laden, wenn backend sagt "Cover existiert"
const hasCover = hasCoverByTag[r.tag] === true
const hasCoverKnown = Object.prototype.hasOwnProperty.call(hasCoverByTag, r.tag)
// ✅ img URL nur bauen, wenn wir sie wirklich benutzen
const img = hasCover ? coverSrc(r.tag, coverBust, false, model) : ''
const isOk = coverState[r.tag] === 'ok'
const isErr = coverState[r.tag] === 'error'
@ -451,10 +497,27 @@ export default function CategoriesTab() {
title="In FinishedDownloads öffnen (Tag-Filter setzen)"
>
<div className="relative w-full overflow-hidden aspect-[16/9] bg-gray-100/70 dark:bg-white/5">
{/* Wenn Fehler: hübscher Placeholder statt broken image */}
{isErr ? (
{/* ✅ 0) Noch nicht bekannt ob Cover existiert -> KEIN <img>, nur Skeleton */}
{!hasCoverKnown ? (
<div className="absolute inset-0">
<div
className="absolute inset-0 opacity-70"
style={{
background:
'radial-gradient(circle at 30% 20%, rgba(99,102,241,0.20), transparent 55%),' +
'radial-gradient(circle at 70% 80%, rgba(14,165,233,0.14), transparent 55%)',
}}
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent" />
<div className="absolute left-3 bottom-3 text-[11px] font-semibold text-white/80 bg-black/30 px-2.5 py-1 rounded-full ring-1 ring-white/10">
Cover wird geprüft
</div>
</div>
) : !hasCover ? (
/* ✅ 1) Definitiv KEIN Cover -> niemals <img> rendern -> keine 404 */
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2">
<div className="absolute inset-0 opacity-70"
<div
className="absolute inset-0 opacity-70"
style={{
background:
'radial-gradient(circle at 30% 20%, rgba(99,102,241,0.25), transparent 55%),' +
@ -463,13 +526,34 @@ export default function CategoriesTab() {
/>
<div className="relative z-10 rounded-xl bg-white/80 dark:bg-black/30 px-3 py-2.5 shadow-sm ring-1 ring-black/5 dark:ring-white/10 backdrop-blur-md">
<div className="text-xs font-semibold text-gray-900 dark:text-white">
Cover nicht verfügbar
Kein Cover vorhanden
</div>
{model ? (
<div className="mt-0.5 text-[11px] text-gray-700 dark:text-gray-300">
Model: <span className="font-semibold">{model}</span>
</div>
) : null}
<div className="mt-1 text-[11px] text-gray-600 dark:text-gray-400 text-center">
(Kein Request / keine 404)
</div>
</div>
</div>
) : isErr ? (
/* ✅ 2) Cover sollte existieren, aber Laden schlug fehl -> Retry erlaubt */
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2">
<div
className="absolute inset-0 opacity-70"
style={{
background:
'radial-gradient(circle at 30% 20%, rgba(99,102,241,0.25), transparent 55%),' +
'radial-gradient(circle at 70% 80%, rgba(14,165,233,0.18), transparent 55%)',
}}
/>
<div className="relative z-10 rounded-xl bg-white/80 dark:bg-black/30 px-3 py-2.5 shadow-sm ring-1 ring-black/5 dark:ring-white/10 backdrop-blur-md">
<div className="text-xs font-semibold text-gray-900 dark:text-white">
Cover konnte nicht geladen werden
</div>
<div className="mt-1 flex justify-center">
<span
className="text-[11px] inline-flex items-center rounded-full bg-indigo-50 px-2 py-1 font-semibold text-indigo-700 ring-1 ring-indigo-100 hover:bg-indigo-100
@ -477,7 +561,7 @@ export default function CategoriesTab() {
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
// retry: alle IMG-URLs neu laden (einfach)
// retry: state reset + bust
setCoverState((s) => {
const n = { ...s }
delete n[r.tag]
@ -492,8 +576,8 @@ export default function CategoriesTab() {
</div>
</div>
) : (
/* ✅ 3) Cover existiert -> <img> rendern */
<>
{/* subtle top sheen + bottom gradient for readability */}
<div
aria-hidden="true"
className="absolute inset-0 z-[1] pointer-events-none"
@ -503,6 +587,7 @@ export default function CategoriesTab() {
'linear-gradient(to top, rgba(0,0,0,0.35), rgba(0,0,0,0) 45%)',
}}
/>
{/* blurred fill */}
<img
src={img}
@ -519,17 +604,23 @@ export default function CategoriesTab() {
className="absolute inset-0 z-0 h-full w-full object-contain"
loading="lazy"
onLoad={() => setCoverState((s) => ({ ...s, [r.tag]: 'ok' }))}
onError={() => setCoverState((s) => ({ ...s, [r.tag]: 'error' }))}
onError={() => {
// ✅ wenn es hier 404 wäre, stimmt hasCoverByTag nicht -> auf false schalten
setCoverState((s) => ({ ...s, [r.tag]: 'error' }))
setHasCoverByTag((p) => ({ ...p, [r.tag]: false }))
}}
/>
{/* Model-Overlay: nur wenn Bild wirklich OK */}
{isOk && model ? (
<div className="absolute left-3 bottom-3 z-10 max-w-[calc(100%-24px)]">
<div className={clsx(
'truncate rounded-full px-2.5 py-1 text-[11px] font-semibold',
'bg-black/40 text-white backdrop-blur-md',
'ring-1 ring-white/15'
)}>
<div
className={clsx(
'truncate rounded-full px-2.5 py-1 text-[11px] font-semibold',
'bg-black/40 text-white backdrop-blur-md',
'ring-1 ring-white/15'
)}
>
{model}
</div>
</div>

View File

@ -11,6 +11,7 @@ import ProgressBar from './ProgressBar'
import RecordJobActions from './RecordJobActions'
import { PauseIcon, PlayIcon } from '@heroicons/react/24/solid'
import { subscribeSSE } from '../../lib/sseSingleton'
import { useRecordJobsSSE } from '../../lib/useRecordJobsSSE'
type PendingWatchedRoom = WaitingModelRow & {
currentShow: string // public / private / hidden / away / unknown
@ -601,10 +602,21 @@ const formatDuration = (ms: number): string => {
}
const runtimeOf = (j: RecordJob, nowMs: number) => {
const start = Date.parse(String(j.startedAt || ''))
if (!Number.isFinite(start)) return '—'
const end = j.endedAt ? Date.parse(String(j.endedAt)) : nowMs
if (!Number.isFinite(end)) return '—'
const anyJ = j as any
const start =
toMs(anyJ.startedAtMs) || // ✅ NEU bevorzugt
toMs(j.startedAt)
if (!Number.isFinite(start) || start <= 0) return '—'
const end =
j.endedAt
? (toMs(anyJ.endedAtMs) || toMs(j.endedAt))
: nowMs
if (!Number.isFinite(end) || end <= 0) return '—'
return formatDuration(end - start)
}
@ -648,6 +660,20 @@ const isPostworkJob = (job: RecordJob): boolean => {
return false
}
const isTerminalStatus = (status?: unknown) => {
const s = String(status ?? '').trim().toLowerCase()
return (
s === 'stopped' ||
s === 'finished' ||
s === 'failed' ||
s === 'done' ||
s === 'completed' ||
s === 'canceled' ||
s === 'cancelled'
)
}
export default function Downloads({
jobs,
pending = [],
@ -660,6 +686,8 @@ export default function Downloads({
modelsByKey = {},
blurPreviews
}: Props) {
const jobsLive = useRecordJobsSSE(jobs)
const [stopAllBusy, setStopAllBusy] = useState(false)
@ -761,7 +789,7 @@ export default function Downloads({
const next: Record<string, true> = {}
for (const id of keys) {
const j = jobs.find((x) => x.id === id)
const j = jobsLive.find((x) => x.id === id)
if (!j) continue
const phaseLower = String((j as any).phase ?? '').trim().toLowerCase()
const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording'
@ -771,15 +799,15 @@ export default function Downloads({
}
return next
})
}, [jobs])
}, [jobsLive])
const [nowMs, setNowMs] = useState(() => Date.now())
const hasActive = useMemo(() => {
// tickt solange mind. ein Job noch nicht beendet ist
return jobs.some((j) => !j.endedAt && j.status === 'running')
}, [jobs])
return jobsLive.some((j) => !j.endedAt && j.status === 'running')
}, [jobsLive])
const postworkQueueInfoById = useMemo(() => {
const infoById = new Map<string, { pos: number; total: number }>()
@ -803,7 +831,7 @@ export default function Downloads({
const running: RecordJob[] = []
const queued: RecordJob[] = []
for (const j of jobs) {
for (const j of jobsLive) {
const pw = (j as any)?.postWork
if (!pw) continue
@ -834,7 +862,7 @@ export default function Downloads({
// }
return infoById
}, [jobs])
}, [jobsLive])
const postworkInfoOf = useCallback(
(job: RecordJob) => {
@ -846,12 +874,12 @@ export default function Downloads({
useEffect(() => {
if (!hasActive) return
const t = window.setInterval(() => setNowMs(Date.now()), 15000)
const t = window.setInterval(() => setNowMs(Date.now()), 1000) // ✅ 1s statt 15s
return () => window.clearInterval(t)
}, [hasActive])
const stoppableIds = useMemo(() => {
return jobs
return jobsLive
.filter((j) => {
if (isPostworkJob(j)) return false
if ((j as any).endedAt) return false
@ -864,7 +892,7 @@ export default function Downloads({
return !isStopping
})
.map((j) => j.id)
}, [jobs, stopRequestedIds])
}, [jobsLive, stopRequestedIds])
const columns = useMemo<Column<DownloadRow>[]>(() => {
return [
@ -1157,22 +1185,36 @@ export default function Downloads({
}, [blurPreviews, markStopRequested, modelsByKey, nowMs, onStopJob, onToggleFavorite, onToggleLike, onToggleWatch, stopRequestedIds, stopInitiatedIds, postworkInfoOf])
const downloadJobRows = useMemo<DownloadRow[]>(() => {
const list = jobs
.filter((j) => !isPostworkJob(j))
const list = jobsLive
.filter((j) => {
if (isPostworkJob(j)) return false
// ✅ Terminale Jobs sollen nicht in "Downloads" bleiben
if (isTerminalStatus((j as any)?.status)) return false
// ✅ Jobs, die ein endedAt haben, sind nicht mehr "Downloads"
if (Boolean((j as any)?.endedAt)) return false
return true
})
.map((job) => ({ kind: 'job', job }) as const)
list.sort((a, b) => addedAtMsOf(b) - addedAtMsOf(a))
return list
}, [jobs])
}, [jobsLive])
const postworkRows = useMemo<DownloadRow[]>(() => {
const list = jobs
.filter((j) => isPostworkJob(j))
const list = jobsLive
.filter((j) => {
if (!isPostworkJob(j)) return false
if (isTerminalStatus((j as any)?.status)) return false
return true
})
.map((job) => ({ kind: 'job', job }) as const)
list.sort((a, b) => addedAtMsOf(b) - addedAtMsOf(a))
return list
}, [jobs])
}, [jobsLive])
const pendingRows = useMemo<DownloadRow[]>(() => {
const list = pending.map((p) => ({ kind: 'pending', pending: p }) as const)

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,4 @@
// frontend\src\components\ui\FinishedDownloadsCardsView.tsx
'use client'
import * as React from 'react'
@ -12,18 +11,16 @@ import {
HeartIcon as HeartSolidIcon,
EyeIcon as EyeSolidIcon,
} from '@heroicons/react/24/solid'
import TagBadge from './TagBadge'
import RecordJobActions from './RecordJobActions'
import TagOverflowRow from './TagOverflowRow'
import { isHotName, stripHotPrefix } from './hotName'
function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ')
}
import { formatResolution } from './formatters'
type InlinePlayState = { key: string; nonce: number } | null
type Props = {
rows: RecordJob[]
isLoading?: boolean
isSmall: boolean
teaserPlayback: 'still' | 'hover' | 'all'
teaserAudio?: boolean
@ -34,7 +31,6 @@ type Props = {
teaserKey: string | null
inlinePlay: InlinePlayState
setInlinePlay: React.Dispatch<React.SetStateAction<InlinePlayState>>
deletingKeys: Set<string>
keepingKeys: Set<string>
@ -97,10 +93,10 @@ const parseTags = (raw?: string): string[] => {
return out
}
export default function FinishedDownloadsCardsView({
rows,
isSmall,
isLoading,
teaserPlayback,
teaserAudio,
hoverTeaserKey,
@ -134,8 +130,6 @@ export default function FinishedDownloadsCardsView({
deleteVideo,
keepVideo,
releasePlayingFile,
modelsByKey,
activeTagSet,
onToggleTagFilter,
@ -143,392 +137,260 @@ export default function FinishedDownloadsCardsView({
onToggleHot,
onToggleFavorite,
onToggleLike,
onToggleWatch
onToggleWatch,
}: Props) {
const [openTagsKey, setOpenTagsKey] = React.useState<string | null>(null)
const tagsPopoverRef = React.useRef<HTMLDivElement | null>(null)
// ✅ Auflösung als {w,h} aus meta.json bevorzugen
const resolutionObjOf = React.useCallback((j: RecordJob): { w: number; h: number } | null => {
const w =
(typeof j.meta?.videoWidth === 'number' && Number.isFinite(j.meta.videoWidth) ? j.meta.videoWidth : 0) ||
(typeof j.videoWidth === 'number' && Number.isFinite(j.videoWidth) ? j.videoWidth : 0)
const h =
(typeof j.meta?.videoHeight === 'number' && Number.isFinite(j.meta.videoHeight) ? j.meta.videoHeight : 0) ||
(typeof j.videoHeight === 'number' && Number.isFinite(j.videoHeight) ? j.videoHeight : 0)
React.useEffect(() => {
if (!openTagsKey) return
if (w > 0 && h > 0) return { w, h }
return null
}, [])
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpenTagsKey(null)
}
const onPointerDown = (e: PointerEvent) => {
const el = tagsPopoverRef.current
if (!el) return
if (el.contains(e.target as Node)) return
setOpenTagsKey(null)
}
document.addEventListener('keydown', onKeyDown)
document.addEventListener('pointerdown', onPointerDown)
return () => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('pointerdown', onPointerDown)
}
}, [openTagsKey])
React.useEffect(() => {
if (!openTagsKey) return
// Falls Job aus der Liste verschwindet → Popover schließen
const exists = rows.some((j) => keyFor(j) === openTagsKey)
if (!exists) setOpenTagsKey(null)
}, [rows, keyFor, openTagsKey])
const metaChipCls = 'rounded bg-black/40 px-1.5 py-0.5 text-xs font-medium backdrop-blur-[2px]'
return (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{rows.map((j) => {
const k = keyFor(j)
const inlineActive = inlinePlay?.key === k
// Sound nur, wenn Setting aktiv UND (Inline aktiv ODER Hover auf diesem Teaser)
const allowSound = Boolean(teaserAudio) && (inlineActive || hoverTeaserKey === k)
const previewMuted = !allowSound
const inlineNonce = inlineActive ? inlinePlay?.nonce ?? 0 : 0
<div className="relative">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{rows.map((j) => {
const k = keyFor(j)
const inlineActive = inlinePlay?.key === k
const allowSound = Boolean(teaserAudio) && (inlineActive || hoverTeaserKey === k)
const previewMuted = !allowSound
const inlineNonce = inlineActive ? inlinePlay?.nonce ?? 0 : 0
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
const model = modelNameFromOutput(j.output)
const fileRaw = baseName(j.output || '')
const isHot = isHotName(fileRaw)
const flags = modelsByKey[lower(model)]
const isFav = Boolean(flags?.favorite)
const isLiked = flags?.liked === true
const isWatching = Boolean(flags?.watching)
const tags = parseTags(flags?.tags)
const showTags = tags.slice(0, 6)
const restTags = tags.length - showTags.length
const fullTags = tags.join(', ')
const model = modelNameFromOutput(j.output)
const fileRaw = baseName(j.output || '')
const isHot = isHotName(fileRaw)
const statusCls =
j.status === 'failed'
? 'bg-red-500/35'
: j.status === 'finished'
? 'bg-emerald-500/35'
: j.status === 'stopped'
? 'bg-amber-500/35'
: 'bg-black/40'
const flags = modelsByKey[lower(model)]
const isFav = Boolean(flags?.favorite)
const isLiked = flags?.liked === true
const isWatching = Boolean(flags?.watching)
const dur = runtimeOf(j)
const size = formatBytes(sizeBytesOf(j))
const tags = parseTags(flags?.tags)
const inlineDomId = `inline-prev-${encodeURIComponent(k)}`
const motionCls = isSmall ? '' : 'transition-all duration-300 ease-in-out hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none'
const dur = runtimeOf(j)
const size = formatBytes(sizeBytesOf(j))
const cardInner = (
<div
role="button"
tabIndex={0}
className={[
'group',
'content-visibility-auto',
'[contain-intrinsic-size:180px_120px]',
motionCls,
'rounded-xl',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500',
busy && 'pointer-events-none',
deletingKeys.has(k) &&
'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30 animate-pulse',
keepingKeys.has(k) &&
'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30 animate-pulse',
removingKeys.has(k) && 'opacity-0 translate-y-2 scale-[0.98]',
].filter(Boolean).join(' ')}
onClick={isSmall ? undefined : () => openPlayer(j)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
}}
>
<Card noBodyPadding className="overflow-hidden">
{/* Preview */}
<div
id={inlineDomId}
ref={registerTeaserHost(k)}
className="relative aspect-video bg-black/5 dark:bg-white/5"
onMouseEnter={isSmall ? undefined : () => onHoverPreviewKeyChange?.(k)}
onMouseLeave={isSmall ? undefined : () => onHoverPreviewKeyChange?.(null)}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (isSmall) return
startInline(k)
}}
>
<div className="absolute inset-0">
<FinishedVideoPreview
job={j}
getFileName={baseName}
className="w-full h-full"
showPopover={false}
blur={isSmall ? false : (inlineActive ? false : blurPreviews)}
animated={teaserPlayback === 'all' ? true : teaserPlayback === 'hover' ? teaserKey === k : false}
animatedMode="teaser"
animatedTrigger="always"
inlineVideo={inlineActive ? 'always' : false}
inlineNonce={inlineNonce}
inlineControls={inlineActive}
inlineLoop={false}
muted={previewMuted}
popoverMuted={previewMuted}
assetNonce={assetNonce ?? 0}
/>
</div>
const resObj = resolutionObjOf(j)
const resLabel = formatResolution(resObj)
{/* Gradient overlay bottom */}
const inlineDomId = `inline-prev-${encodeURIComponent(k)}`
// ✅ Shell an Gallery angelehnt
const shellCls = [
'group relative rounded-lg overflow-hidden outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10',
'bg-white dark:bg-gray-900/40',
'transition-all duration-200',
!isSmall && 'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500',
busy && 'pointer-events-none opacity-70',
deletingKeys.has(k) && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
keepingKeys.has(k) && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
removingKeys.has(k) && 'opacity-0 translate-y-2 scale-[0.98]',
]
.filter(Boolean)
.join(' ')
const cardInner = (
<div
role="button"
tabIndex={0}
className={shellCls}
onClick={isSmall ? undefined : () => openPlayer(j)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
}}
>
{/* Card shell keeps backgrounds consistent */}
<Card noBodyPadding className="overflow-hidden bg-transparent">
{/* Preview */}
<div
className={[
'pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-black/70 to-transparent',
'transition-opacity duration-150',
inlineActive ? 'opacity-0' : 'opacity-100',
].join(' ')}
/>
{/* Bottom overlay meta (Status links, Dauer+Größe rechts) */}
<div
className={[
'pointer-events-none absolute inset-x-0 bottom-0 p-2 text-white',
'transition-opacity duration-150',
inlineActive ? 'opacity-0' : 'opacity-100',
].join(' ')}
id={inlineDomId}
ref={registerTeaserHost(k)}
className="relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
onMouseEnter={isSmall ? undefined : () => onHoverPreviewKeyChange?.(k)}
onMouseLeave={isSmall ? undefined : () => onHoverPreviewKeyChange?.(null)}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (isSmall) return
startInline(k)
}}
>
<div className="flex items-center justify-between gap-2 text-[11px] opacity-90">
<span className={cn('rounded px-1.5 py-0.5 font-semibold', statusCls)}>
{j.status}
</span>
<div className="flex items-center gap-1.5">
<span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">{dur}</span>
<span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">{size}</span>
</div>
</div>
</div>
{!isSmall && inlinePlay?.key === k && (
<button
type="button"
className="absolute left-2 top-2 z-10 rounded-md bg-black/40 px-2 py-1 text-xs font-semibold text-white backdrop-blur hover:bg-black/60"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setInlinePlay((prev) => ({ key: k, nonce: prev?.key === k ? prev.nonce + 1 : 1 }))
}}
title="Von vorne starten"
aria-label="Von vorne starten"
>
</button>
)}
{/* Actions top-right */}
<div
className="absolute right-2 top-2 flex items-center gap-2"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<RecordJobActions
job={j}
variant="overlay"
busy={busy}
isHot={isHot}
isFavorite={isFav}
isLiked={isLiked}
isWatching={isWatching}
onToggleWatch={onToggleWatch}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleHot={
onToggleHot
? async (job) => {
const file = baseName(job.output || '')
if (file) {
// wichtig gegen File-Lock beim Rename:
await releasePlayingFile(file, { close: true })
await new Promise((r) => setTimeout(r, 150))
}
await onToggleHot(job)
}
: undefined
}
onKeep={keepVideo}
onDelete={deleteVideo}
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'details', 'add']}
className="flex items-center gap-2"
/>
</div>
</div>
{/* Footer / Meta */}
<div
className={[
'px-4 py-3 rounded-b-lg border-t border-gray-200/60 dark:border-white/10',
isSmall ? 'bg-white/90 dark:bg-gray-950/80' : 'bg-white/60 backdrop-blur dark:bg-white/5',
].join(' ')}
>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white">
{model}
</div>
<div className="shrink-0 flex items-center gap-1.5">
{isWatching ? <EyeSolidIcon className="size-4 text-sky-600 dark:text-sky-300" /> : null}
{isLiked ? <HeartSolidIcon className="size-4 text-rose-600 dark:text-rose-300" /> : null}
{isFav ? <StarSolidIcon className="size-4 text-amber-600 dark:text-amber-300" /> : null}
</div>
</div>
<div className="mt-0.5 flex items-center gap-2 min-w-0 text-xs text-gray-500 dark:text-gray-400">
<span className="truncate">{stripHotPrefix(fileRaw) || '—'}</span>
{isHot ? (
<span className="shrink-0 rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] font-semibold text-amber-800 dark:text-amber-300">
HOT
</span>
) : null}
</div>
{/* Tags: 1 Zeile, +N öffnet Popover */}
<div
className="mt-2 h-6 relative flex items-center gap-1.5"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
{/* links: Tags (nowrap, werden ggf. geclippt) */}
<div className="min-w-0 flex-1 overflow-hidden">
<div className="flex flex-nowrap items-center gap-1.5">
{showTags.length > 0 ? (
showTags.map((t) => (
<TagBadge
key={t}
tag={t}
active={activeTagSet.has(lower(t))}
onClick={onToggleTagFilter}
/>
))
) : (
<span className="text-xs text-gray-400 dark:text-gray-500"></span>
)}
</div>
{/* media */}
<div className="absolute inset-0">
<FinishedVideoPreview
job={j}
getFileName={baseName}
className="h-full w-full"
showPopover={false}
blur={isSmall ? false : inlineActive ? false : blurPreviews}
animated={teaserPlayback === 'all' ? true : teaserPlayback === 'hover' ? teaserKey === k : false}
animatedMode="teaser"
animatedTrigger="always"
inlineVideo={inlineActive ? 'always' : false}
inlineNonce={inlineNonce}
inlineControls={inlineActive}
inlineLoop={false}
muted={previewMuted}
popoverMuted={previewMuted}
assetNonce={assetNonce ?? 0}
/>
</div>
{/* rechts: Rest-Count immer sichtbar + klickbar */}
{restTags > 0 ? (
{/* Actions top-right (wie Gallery: je nach Größe ausblenden) */}
<div className="absolute inset-x-2 top-2 z-10 flex justify-end" onClick={(e) => e.stopPropagation()}>
<RecordJobActions
job={j}
variant="overlay"
busy={busy}
collapseToMenu
isHot={isHot}
isFavorite={isFav}
isLiked={isLiked}
isWatching={isWatching}
onToggleWatch={onToggleWatch}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleHot={onToggleHot}
onKeep={keepVideo}
onDelete={deleteVideo}
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'details', 'add']}
className="w-full justify-end gap-1"
/>
</div>
{/* Restart (wenn inline läuft) */}
{!isSmall && inlinePlay?.key === k ? (
<button
type="button"
className="shrink-0 inline-flex items-center rounded-md bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-200 hover:bg-gray-200/70 dark:bg-white/5 dark:text-gray-200 dark:ring-white/10 dark:hover:bg-white/10"
title={fullTags}
aria-haspopup="dialog"
aria-expanded={openTagsKey === k}
onPointerDown={(e) => e.stopPropagation()}
className="absolute left-2 top-10 z-10 rounded-md bg-black/45 px-2 py-1 text-xs font-semibold text-white hover:bg-black/60"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setOpenTagsKey((prev) => (prev === k ? null : k))
setInlinePlay((prev) => ({ key: k, nonce: prev?.key === k ? prev.nonce + 1 : 1 }))
}}
title="Von vorne starten"
aria-label="Von vorne starten"
>
+{restTags}
</button>
) : null}
{/* Popover */}
{openTagsKey === k ? (
<div
ref={tagsPopoverRef}
className={[
'absolute right-0 bottom-8 z-30 w-72 max-w-[calc(100vw-2rem)] overflow-hidden rounded-lg border border-gray-200/70 bg-white/95 shadow-lg ring-1 ring-black/5',
isSmall ? '' : 'backdrop-blur',
'dark:border-white/10 dark:bg-gray-950/90 dark:ring-white/10',
].join(' ')}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between gap-2 border-b border-gray-200/60 px-3 py-2 dark:border-white/10">
<div className="text-xs font-semibold text-gray-900 dark:text-white">Tags</div>
<button
type="button"
className="rounded px-2 py-1 text-xs font-medium text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/10"
onClick={() => setOpenTagsKey(null)}
aria-label="Schließen"
title="Schließen"
>
</button>
</div>
<div className="max-h-48 overflow-auto p-2">
<div className="flex flex-wrap gap-1.5">
{tags.map((t) => (
<TagBadge
key={t}
tag={t}
active={activeTagSet.has(lower(t))}
onClick={onToggleTagFilter}
/>
))}
</div>
{/* Bottom overlay (ohne Gradient) */}
<div
className="
pointer-events-none absolute inset-x-0 bottom-0
px-2 pb-2 pt-8 text-white
"
>
<div className="flex items-center justify-end gap-2">
<div className="shrink-0 flex items-center gap-1.5">
<span className={metaChipCls}>{dur}</span>
{resLabel ? (
<span className={metaChipCls} title={resObj ? `${resObj.w}×${resObj.h}` : 'Auflösung'}>
{resLabel}
</span>
) : null}
<span className={metaChipCls}>{size}</span>
</div>
</div>
) : null}
</div>
</div>
</div>
</Card>
{/* Footer / Meta (wie Gallery strukturiert) */}
<div className="relative min-h-[92px] overflow-hidden px-4 py-3 border-t border-black/5 dark:border-white/10 bg-white dark:bg-gray-900">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">{model}</div>
<div className="mt-0.5 flex items-center gap-2 min-w-0">
{isHot ? (
<span className="shrink-0 rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] font-semibold text-amber-800 dark:text-amber-300">
HOT
</span>
) : null}
<span className="min-w-0 truncate text-xs text-gray-500 dark:text-gray-400">
{stripHotPrefix(fileRaw) || '—'}
</span>
</div>
</div>
<div className="shrink-0 flex items-center gap-1.5 pt-0.5">
{isWatching ? <EyeSolidIcon className="size-4 text-sky-600 dark:text-sky-300" /> : null}
{isLiked ? <HeartSolidIcon className="size-4 text-rose-600 dark:text-rose-300" /> : null}
{isFav ? <StarSolidIcon className="size-4 text-amber-600 dark:text-amber-300" /> : null}
</div>
</div>
{/* Tags */}
<div className="mt-2" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
<TagOverflowRow
rowKey={k}
tags={tags}
activeTagSet={activeTagSet}
lower={lower}
onToggleTagFilter={onToggleTagFilter}
/>
</div>
</div>
</Card>
</div>
)
return isSmall ? (
<SwipeCard
ref={(h) => {
if (h) swipeRefs.current.set(k, h)
else swipeRefs.current.delete(k)
}}
key={k}
enabled
disabled={busy}
ignoreFromBottomPx={110}
doubleTapMs={360}
doubleTapMaxMovePx={48}
onDoubleTap={async () => {
if (isHot) return
await onToggleHot?.(j)
}}
onTap={() => {
const domId = `inline-prev-${encodeURIComponent(k)}`
startInline(k)
requestAnimationFrame(() => {
if (!tryAutoplayInline(domId)) requestAnimationFrame(() => tryAutoplayInline(domId))
})
}}
onSwipeLeft={() => deleteVideo(j)}
onSwipeRight={() => keepVideo(j)}
>
{cardInner}
</SwipeCard>
) : (
<React.Fragment key={k}>{cardInner}</React.Fragment>
)
})}
</div>
{isLoading && rows.length === 0 ? (
<div className="absolute inset-0 z-20 grid place-items-center rounded-lg bg-white/50 backdrop-blur-[2px] dark:bg-gray-950/40">
{/* Spinner (zentriert) */}
<div className="flex items-center gap-3 rounded-lg border border-gray-200 bg-white/80 px-3 py-2 shadow-sm dark:border-white/10 dark:bg-gray-900/70">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-gray-300 border-t-transparent dark:border-white/20 dark:border-t-transparent" />
<div className="text-sm font-medium text-gray-800 dark:text-gray-100">Lade</div>
</div>
)
// ✅ Mobile: SwipeCard, Desktop: normale Card
return isSmall ? (
<SwipeCard
ref={(h) => {
if (h) swipeRefs.current.set(k, h)
else swipeRefs.current.delete(k)
}}
key={k}
enabled
disabled={busy}
ignoreFromBottomPx={110}
doubleTapMs={360}
doubleTapMaxMovePx={48}
onTap={() => {
const domId = `inline-prev-${encodeURIComponent(k)}`
startInline(k)
requestAnimationFrame(() => {
if (!tryAutoplayInline(domId)) {
requestAnimationFrame(() => tryAutoplayInline(domId))
}
})
}}
onDoubleTap={
onToggleHot
? async () => {
if (isHot) return false
try {
const file = baseName(j.output || '')
if (file) {
// ✅ NICHT schließen, wenn dieser Teaser gerade inline spielt
const isThisInline = inlinePlay?.key === k
if (!isThisInline) {
await releasePlayingFile(file, { close: true })
await new Promise((r) => setTimeout(r, 150))
}
}
await onToggleHot(j)
return true
} catch {
return false
}
}
: undefined
}
onSwipeLeft={() => deleteVideo(j)}
onSwipeRight={() => keepVideo(j)}
>
{cardInner}
</SwipeCard>
) : (
<React.Fragment key={k}>{cardInner}</React.Fragment>
)
})}
</div>
) : null}
</div>
)
}

View File

@ -1,5 +1,4 @@
// frontend\src\components\ui\FinishedDownloadsGalleryView.tsx
'use client'
import * as React from 'react'
@ -10,13 +9,14 @@ import {
HeartIcon as HeartSolidIcon,
EyeIcon as EyeSolidIcon,
} from '@heroicons/react/24/solid'
import TagBadge from './TagBadge'
import RecordJobActions from './RecordJobActions'
import TagOverflowRow from './TagOverflowRow'
import { isHotName, stripHotPrefix } from './hotName'
import { formatResolution } from './formatters'
type Props = {
rows: RecordJob[]
isLoading?: boolean
blurPreviews?: boolean
durations: Record<string, number>
teaserPlayback: 'still' | 'hover' | 'all'
@ -24,7 +24,6 @@ type Props = {
hoverTeaserKey?: string | null
teaserKey: string | null
handleDuration: (job: RecordJob, seconds: number) => void
keyFor: (j: RecordJob) => string
@ -59,6 +58,7 @@ type Props = {
export default function FinishedDownloadsGalleryView({
rows,
isLoading,
blurPreviews,
durations,
teaserPlayback,
@ -95,17 +95,13 @@ export default function FinishedDownloadsGalleryView({
onToggleWatch,
}: Props) {
const [openTagsKey, setOpenTagsKey] = React.useState<string | null>(null)
const tagsPopoverRef = React.useRef<HTMLDivElement | null>(null)
// ✅ Teaser-Observer nur aktiv, wenn Preview überhaupt "laufen" soll
// ✅ Teaser-Observer nur aktiv, wenn Preview überhaupt "laufen" soll
const shouldObserveTeasers = teaserPlayback === 'hover' || teaserPlayback === 'all'
// ✅ Wrapper: bei still unregistrieren / nicht registrieren
const registerTeaserHostIfNeeded = React.useCallback(
(key: string) => (el: HTMLDivElement | null) => {
if (!shouldObserveTeasers) {
// wichtig: sauber unhooken, falls vorher beobachtet wurde
registerTeaserHost(key)(null)
return
}
@ -114,36 +110,6 @@ export default function FinishedDownloadsGalleryView({
[registerTeaserHost, shouldObserveTeasers]
)
React.useEffect(() => {
if (!openTagsKey) return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpenTagsKey(null)
}
const onPointerDown = (e: PointerEvent) => {
const el = tagsPopoverRef.current
if (!el) return
if (el.contains(e.target as Node)) return
setOpenTagsKey(null)
}
document.addEventListener('keydown', onKeyDown)
document.addEventListener('pointerdown', onPointerDown)
return () => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('pointerdown', onPointerDown)
}
}, [openTagsKey])
React.useEffect(() => {
if (!openTagsKey) return
// Falls Job aus der Liste verschwindet → Popover schließen
const exists = rows.some((j) => keyFor(j) === openTagsKey)
if (!exists) setOpenTagsKey(null)
}, [rows, keyFor, openTagsKey])
const parseTags = (raw?: string): string[] => {
const s = String(raw ?? '').trim()
if (!s) return []
@ -163,265 +129,192 @@ export default function FinishedDownloadsGalleryView({
return out
}
// ✅ Auflösung als {w,h} aus meta.json bevorzugen
const resolutionObjOf = React.useCallback((j: RecordJob): { w: number; h: number } | null => {
const w =
(typeof j.meta?.videoWidth === 'number' && Number.isFinite(j.meta.videoWidth) ? j.meta.videoWidth : 0) ||
(typeof j.videoWidth === 'number' && Number.isFinite(j.videoWidth) ? j.videoWidth : 0)
const h =
(typeof j.meta?.videoHeight === 'number' && Number.isFinite(j.meta.videoHeight) ? j.meta.videoHeight : 0) ||
(typeof j.videoHeight === 'number' && Number.isFinite(j.videoHeight) ? j.videoHeight : 0)
if (w > 0 && h > 0) return { w, h }
return null
}, [])
return (
<>
<div
className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4"
>
{rows.map((j) => {
const k = keyFor(j)
// Sound nur bei Hover auf genau diesem Teaser
const allowSound = Boolean(teaserAudio) && hoverTeaserKey === k
const previewMuted = !allowSound
<div className="relative">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{rows.map((j) => {
const k = keyFor(j)
const model = modelNameFromOutput(j.output)
const modelKey = lower(model)
const flags = modelsByKey[modelKey]
const isFav = Boolean(flags?.favorite)
const isLiked = flags?.liked === true
const isWatching = Boolean(flags?.watching)
const tags = parseTags(flags?.tags)
const showTags = tags.slice(0, 6)
const restTags = tags.length - showTags.length
const fullTags = tags.join(', ')
// Sound nur bei Hover auf genau diesem Teaser
const allowSound = Boolean(teaserAudio) && hoverTeaserKey === k
const previewMuted = !allowSound
const fileRaw = baseName(j.output || '')
const isHot = isHotName(fileRaw)
const file = stripHotPrefix(fileRaw)
const dur = runtimeOf(j)
const size = formatBytes(sizeBytesOf(j))
const model = modelNameFromOutput(j.output)
const modelKey = lower(model)
const flags = modelsByKey[modelKey]
const isFav = Boolean(flags?.favorite)
const isLiked = flags?.liked === true
const isWatching = Boolean(flags?.watching)
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
const deleted = deletedKeys.has(k)
const tags = parseTags(flags?.tags)
return (
<div
key={k}
role="button"
tabIndex={0}
className={[
'group relative rounded-lg overflow-hidden outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10',
'bg-white dark:bg-gray-900/40',
'transition-all duration-200',
'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500',
busy && 'pointer-events-none opacity-70',
deletingKeys.has(k) && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
removingKeys.has(k) && 'opacity-0 translate-y-2 scale-[0.98]',
deleted && 'hidden',
]
.filter(Boolean)
.join(' ')}
onClick={() => onOpenPlayer(j)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
}}
>
{/* Thumb */}
const fileRaw = baseName(j.output || '')
const isHot = isHotName(fileRaw)
const file = stripHotPrefix(fileRaw)
const dur = runtimeOf(j)
const size = formatBytes(sizeBytesOf(j))
const resObj = resolutionObjOf(j)
const resLabel = formatResolution(resObj)
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
const deleted = deletedKeys.has(k)
return (
<div
className="group relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
ref={registerTeaserHostIfNeeded(k)}
onMouseEnter={() => onHoverPreviewKeyChange?.(k)}
onMouseLeave={() => onHoverPreviewKeyChange?.(null)}
key={k}
role="button"
tabIndex={0}
className={[
'group relative rounded-lg overflow-hidden outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10',
'bg-white dark:bg-gray-900/40',
'transition-all duration-200',
'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500',
busy && 'pointer-events-none opacity-70',
deletingKeys.has(k) && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
removingKeys.has(k) && 'opacity-0 translate-y-2 scale-[0.98]',
deleted && 'hidden',
]
.filter(Boolean)
.join(' ')}
onClick={() => onOpenPlayer(j)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
}}
>
{/* ✅ Clip nur Media + Bottom-Overlays (nicht das Menü) */}
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
<div className="absolute inset-0">
<FinishedVideoPreview
{/* Thumb */}
<div
className="group relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
ref={registerTeaserHostIfNeeded(k)}
onMouseEnter={() => onHoverPreviewKeyChange?.(k)}
onMouseLeave={() => onHoverPreviewKeyChange?.(null)}
>
{/* ✅ Clip nur Media + Bottom-Overlays (nicht das Menü) */}
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
<div className="absolute inset-0">
<FinishedVideoPreview
job={j}
getFileName={(p) => stripHotPrefix(baseName(p))}
durationSeconds={durations[k] ?? (j as any)?.durationSeconds}
onDuration={handleDuration}
variant="fill"
showPopover={false}
blur={blurPreviews}
animated={teaserPlayback === 'all' ? true : teaserPlayback === 'hover' ? teaserKey === k : false}
animatedMode="teaser"
animatedTrigger="always"
clipSeconds={1}
thumbSamples={18}
muted={previewMuted}
popoverMuted={previewMuted}
/>
</div>
{/* Bottom overlay meta */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 p-2 text-white">
<div className="flex items-end justify-end gap-2">
{/* Right bottom: Duration + Resolution(label) + Size */}
<div className="shrink-0 flex items-center gap-1.5">
<span className="rounded bg-black/40 px-1.5 py-0.5 text-xs font-medium">{dur}</span>
{resLabel ? (
<span
className="rounded bg-black/40 px-1.5 py-0.5 text-xs font-medium"
title={resObj ? `${resObj.w}×${resObj.h}` : 'Auflösung'}
>
{resLabel}
</span>
) : null}
<span className="rounded bg-black/40 px-1.5 py-0.5 text-xs font-medium">{size}</span>
</div>
</div>
</div>
</div>
{/* Actions (top-right) */}
<div className="absolute inset-x-2 top-2 z-10 flex justify-end" onClick={(e) => e.stopPropagation()}>
<RecordJobActions
job={j}
getFileName={(p) => stripHotPrefix(baseName(p))}
durationSeconds={durations[k] ?? (j as any)?.durationSeconds}
onDuration={handleDuration}
variant="fill"
showPopover={false}
blur={blurPreviews}
animated={teaserPlayback === 'all' ? true : teaserPlayback === 'hover' ? teaserKey === k : false}
animatedMode="teaser"
animatedTrigger="always"
clipSeconds={1}
thumbSamples={18}
muted={previewMuted}
popoverMuted={previewMuted}
variant="overlay"
busy={busy}
collapseToMenu
isHot={isHot}
isFavorite={isFav}
isLiked={isLiked}
isWatching={isWatching}
onToggleWatch={onToggleWatch}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleHot={onToggleHot}
onKeep={keepVideo}
onDelete={deleteVideo}
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'details', 'add']}
className="w-full justify-end gap-1"
/>
</div>
{/* Gradient overlay bottom */}
<div
className="
pointer-events-none absolute inset-x-0 bottom-0 h-16
bg-gradient-to-t from-black/65 to-transparent
transition-opacity duration-150
group-hover:opacity-0 group-focus-within:opacity-0
"
/>
{/* Bottom overlay meta */}
<div
className="
pointer-events-none absolute inset-x-0 bottom-0 p-2 text-white
"
>
<div className="flex items-end justify-between gap-2">
{/* Left: File + Status unten links */}
<div className="min-w-0">
<div>
<span
className={[
'inline-block rounded px-1.5 py-0.5 text-[11px] font-semibold',
j.status === 'finished'
? 'bg-emerald-600/70'
: j.status === 'stopped'
? 'bg-amber-600/70'
: j.status === 'failed'
? 'bg-red-600/70'
: 'bg-black/50',
].join(' ')}
>
{j.status}
</span>
</div>
</div>
{/* Right bottom: Duration + Size */}
<div className="shrink-0 flex items-center gap-1.5">
<span className="rounded bg-black/40 px-1.5 py-0.5 text-xs font-medium">{dur}</span>
<span className="rounded bg-black/40 px-1.5 py-0.5 text-xs font-medium">{size}</span>
</div>
</div>
</div>
</div>
{/* Actions (top-right) */}
<div
className="absolute inset-x-2 top-2 z-10 flex justify-end"
onClick={(e) => e.stopPropagation()}
>
<RecordJobActions
job={j}
variant="overlay"
busy={busy}
collapseToMenu
isHot={isHot}
isFavorite={isFav}
isLiked={isLiked}
isWatching={isWatching}
onToggleWatch={onToggleWatch}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleHot={onToggleHot}
onKeep={keepVideo}
onDelete={deleteVideo}
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'details', 'add']}
className="w-full justify-end gap-1"
/>
{/* Footer / Meta */}
<div className="relative min-h-[92px] overflow-hidden px-4 py-3 rounded-b-lg border-t border-gray-200/60 dark:border-white/10 bg-white dark:bg-gray-900">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white">{model}</div>
<div className="shrink-0 flex items-center gap-1.5">
{isWatching ? <EyeSolidIcon className="size-4 text-sky-600 dark:text-sky-300" /> : null}
{isLiked ? <HeartSolidIcon className="size-4 text-rose-600 dark:text-rose-300" /> : null}
{isFav ? <StarSolidIcon className="size-4 text-amber-600 dark:text-amber-300" /> : null}
</div>
</div>
<div className="mt-0.5 flex items-center gap-2 min-w-0 text-xs text-gray-500 dark:text-gray-400">
<span className="truncate">{stripHotPrefix(file) || '—'}</span>
{isHot ? (
<span className="shrink-0 rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] font-semibold text-amber-800 dark:text-amber-300">
HOT
</span>
) : null}
</div>
{/* Tags */}
<div className="mt-2" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
<TagOverflowRow
rowKey={k}
tags={tags}
activeTagSet={activeTagSet}
lower={lower}
onToggleTagFilter={onToggleTagFilter}
/>
</div>
</div>
</div>
{/* Footer / Meta */}
<div className="px-4 py-3 rounded-b-lg border-t border-gray-200/60 bg-white/60 backdrop-blur dark:border-white/10 dark:bg-white/5">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white">{model}</div>
<div className="shrink-0 flex items-center gap-1.5">
{isWatching ? <EyeSolidIcon className="size-4 text-sky-600 dark:text-sky-300" /> : null}
{isLiked ? <HeartSolidIcon className="size-4 text-rose-600 dark:text-rose-300" /> : null}
{isFav ? <StarSolidIcon className="size-4 text-amber-600 dark:text-amber-300" /> : null}
</div>
</div>
<div className="mt-0.5 flex items-center gap-2 min-w-0 text-xs text-gray-500 dark:text-gray-400">
<span className="truncate">{stripHotPrefix(file) || '—'}</span>
{isHot ? (
<span className="shrink-0 rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] font-semibold text-amber-800 dark:text-amber-300">
HOT
</span>
) : null}
</div>
{/* Tags: 1 Zeile, +N öffnet Popover */}
<div
className="mt-2 h-6 relative flex items-center gap-1.5"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
{/* links: Tags (nowrap, werden ggf. geclippt) */}
<div className="min-w-0 flex-1 overflow-hidden">
<div className="flex flex-nowrap items-center gap-1.5">
{showTags.length > 0 ? (
showTags.map((t) => (
<TagBadge
key={t}
tag={t}
active={activeTagSet.has(lower(t))}
onClick={onToggleTagFilter}
/>
))
) : (
<span className="text-xs text-gray-400 dark:text-gray-500"></span>
)}
</div>
</div>
{/* rechts: Rest-Count immer sichtbar + klickbar */}
{restTags > 0 ? (
<button
type="button"
className="shrink-0 inline-flex items-center rounded-md bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-200 hover:bg-gray-200/70 dark:bg-white/5 dark:text-gray-200 dark:ring-white/10 dark:hover:bg-white/10"
title={fullTags}
aria-haspopup="dialog"
aria-expanded={openTagsKey === k}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setOpenTagsKey((prev) => (prev === k ? null : k))
}}
>
+{restTags}
</button>
) : null}
{/* Popover */}
{openTagsKey === k ? (
<div
ref={tagsPopoverRef}
className="absolute right-0 bottom-8 z-30 w-72 max-w-[calc(100vw-2rem)] overflow-hidden rounded-lg border border-gray-200/70 bg-white/95 shadow-lg ring-1 ring-black/5 backdrop-blur dark:border-white/10 dark:bg-gray-950/90 dark:ring-white/10"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between gap-2 border-b border-gray-200/60 px-3 py-2 dark:border-white/10">
<div className="text-xs font-semibold text-gray-900 dark:text-white">Tags</div>
<button
type="button"
className="rounded px-2 py-1 text-xs font-medium text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/10"
onClick={() => setOpenTagsKey(null)}
aria-label="Schließen"
title="Schließen"
>
</button>
</div>
<div className="max-h-48 overflow-auto p-2">
<div className="flex flex-wrap gap-1.5">
{tags.map((t) => (
<TagBadge
key={t}
tag={t}
active={activeTagSet.has(lower(t))}
onClick={onToggleTagFilter}
/>
))}
</div>
</div>
</div>
) : null}
</div>
</div>
</div>
)
})}
)
})}
</div>
</>
{isLoading && rows.length === 0 ? (
<div className="absolute inset-0 z-20 grid place-items-center rounded-lg bg-white/50 backdrop-blur-[2px] dark:bg-gray-950/40">
<div className="flex items-center gap-3 rounded-lg border border-gray-200 bg-white/80 px-3 py-2 shadow-sm dark:border-white/10 dark:bg-gray-900/70">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-gray-300 border-t-transparent dark:border-white/20 dark:border-t-transparent" />
<div className="text-sm font-medium text-gray-800 dark:text-gray-100">Lade</div>
</div>
</div>
) : null}
</div>
)
}

View File

@ -2,42 +2,468 @@
'use client'
import * as React from 'react'
import Table, { type Column, type SortState } from './Table'
import type { RecordJob } from '../../types'
import FinishedVideoPreview from './FinishedVideoPreview'
import RecordJobActions from './RecordJobActions'
import TagOverflowRow from './TagOverflowRow'
import { isHotName, stripHotPrefix } from './hotName'
import { formatResolution } from './formatters'
type SortMode =
| 'completed_desc'
| 'completed_asc'
| 'file_asc'
| 'file_desc'
| 'duration_desc'
| 'duration_asc'
| 'size_desc'
| 'size_asc'
type Props = {
rows: RecordJob[]
columns: Column<RecordJob>[]
getRowKey: (j: RecordJob) => string
sort: SortState
onSortChange: (s: SortState) => void
onRowClick: (job: RecordJob) => void
rowClassName?: (job: RecordJob) => string
isLoading?: boolean
// helpers
keyFor: (j: RecordJob) => string
baseName: (p: string) => string
lower: (s: string) => string
modelNameFromOutput: (output?: string) => string
runtimeOf: (job: RecordJob) => string
sizeBytesOf: (job: RecordJob) => number | null
formatBytes: (bytes?: number | null) => string
resolutions: Record<string, { w: number; h: number }>
durations: Record<string, number>
// teaser/preview
canHover: boolean
teaserAudio?: boolean
hoverTeaserKey: string | null
setHoverTeaserKey: React.Dispatch<React.SetStateAction<string | null>>
teaserPlayback?: 'still' | 'hover' | 'all'
teaserKey: string | null
registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void
handleDuration: (job: RecordJob, seconds: number) => void
handleResolution: (job: RecordJob, w: number, h: number) => void
blurPreviews?: boolean
assetNonce?: number
// state/flags for actions row + rowClassName
deletingKeys: Set<string>
keepingKeys: Set<string>
removingKeys: Set<string>
modelsByKey: Record<string, { favorite?: boolean; liked?: boolean | null; watching?: boolean | null; tags?: string }>
activeTagSet: Set<string>
onToggleTagFilter: (tag: string) => void
// actions
onOpenPlayer: (job: RecordJob) => void
onSortModeChange: (m: SortMode) => void
page: number
onPageChange: (page: number) => void
onToggleHot?: (job: RecordJob) => void | Promise<void>
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
onToggleLike?: (job: RecordJob) => void | Promise<void>
onToggleWatch?: (job: RecordJob) => void | Promise<void>
deleteVideo: (job: RecordJob) => Promise<boolean>
keepVideo: (job: RecordJob) => Promise<boolean>
}
export default function FinishedDownloadsTableView({
rows,
columns,
getRowKey,
sort,
onSortChange,
onRowClick,
rowClassName,
isLoading,
keyFor,
baseName,
lower,
modelNameFromOutput,
runtimeOf,
sizeBytesOf,
formatBytes,
resolutions,
durations,
canHover,
teaserAudio,
hoverTeaserKey,
setHoverTeaserKey,
teaserPlayback = 'hover',
teaserKey,
registerTeaserHost,
handleDuration,
handleResolution,
blurPreviews,
assetNonce,
deletingKeys,
keepingKeys,
removingKeys,
modelsByKey,
activeTagSet,
onToggleTagFilter,
onOpenPlayer,
onSortModeChange,
page,
onPageChange,
onToggleHot,
onToggleFavorite,
onToggleLike,
onToggleWatch,
deleteVideo,
keepVideo,
}: Props) {
const [sort, setSort] = React.useState<SortState>(null)
const runtimeSecondsForSort = React.useCallback((job: RecordJob) => {
// 1) Prefer real video duration (ffprobe / backend)
const sec = (job as any)?.durationSeconds
if (typeof sec === 'number' && Number.isFinite(sec) && sec > 0) return sec
// 2) Fallback: endedAt-startedAt (only if plausible)
const start = Date.parse(String((job as any)?.startedAt || ''))
const end = Date.parse(String((job as any)?.endedAt || ''))
if (Number.isFinite(start) && Number.isFinite(end) && end > start) {
const diffSec = (end - start) / 1000
if (diffSec >= 1 && diffSec <= 24 * 60 * 60) return diffSec
}
return Number.POSITIVE_INFINITY
}, [])
const parseTags = React.useCallback((raw?: string): string[] => {
const s = String(raw ?? '').trim()
if (!s) return []
const parts = s
.split(/[\n,;|]+/g)
.map((p) => p.trim())
.filter(Boolean)
const seen = new Set<string>()
const out: string[] = []
for (const p of parts) {
const k = p.toLowerCase()
if (seen.has(k)) continue
seen.add(k)
out.push(p)
}
out.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
return out
}, [])
const columns = React.useMemo<Column<RecordJob>[]>(() => {
return [
{
key: 'preview',
header: 'Vorschau',
widthClassName: 'w-[140px]',
cell: (j) => {
const k = keyFor(j)
const allowSound = Boolean(teaserAudio) && hoverTeaserKey === k
const previewMuted = !allowSound
return (
<div
ref={registerTeaserHost(k)}
className="py-1"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onMouseEnter={() => {
if (canHover) setHoverTeaserKey(k)
}}
onMouseLeave={() => {
if (canHover) setHoverTeaserKey(null)
}}
>
<FinishedVideoPreview
job={j}
getFileName={baseName}
durationSeconds={durations[k]}
muted={previewMuted}
popoverMuted={previewMuted}
onDuration={handleDuration}
onResolution={handleResolution}
className="w-28 h-16 rounded-md ring-1 ring-black/5 dark:ring-white/10"
showPopover={false}
blur={blurPreviews}
animated={teaserPlayback === 'all' ? true : teaserPlayback === 'hover' ? teaserKey === k : false}
animatedMode="teaser"
animatedTrigger="always"
assetNonce={assetNonce}
/>
</div>
)
},
},
{
key: 'Model',
header: 'Model',
sortable: true,
sortValue: (j) => {
const fileRaw = baseName(j.output || '')
const isHot = isHotName(fileRaw)
const model = modelNameFromOutput(j.output)
const file = stripHotPrefix(fileRaw)
return `${model} ${isHot ? 'HOT' : ''} ${file}`.trim()
},
cell: (j) => {
const fileRaw = baseName(j.output || '')
const isHot = isHotName(fileRaw)
const file = stripHotPrefix(fileRaw)
const model = modelNameFromOutput(j.output)
const modelKey = lower(modelNameFromOutput(j.output))
const tags = parseTags(modelsByKey[modelKey]?.tags)
return (
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">{model}</div>
<div className="mt-0.5 min-w-0 text-xs text-gray-500 dark:text-gray-400">
<div className="flex items-center gap-2 min-w-0">
<span className="truncate" title={file}>
{file || '—'}
</span>
{(() => {
const k = keyFor(j)
const res = resolutions[k] ?? null
const label = formatResolution(res)
if (!label) return null
return (
<span
className="shrink-0 rounded-md bg-gray-100 px-1.5 py-0.5 text-[11px] font-semibold text-gray-700 ring-1 ring-inset ring-gray-200
dark:bg-white/5 dark:text-gray-200 dark:ring-white/10"
title={res ? `${res.w}×${res.h}` : 'Auflösung'}
>
{label}
</span>
)
})()}
{isHot ? (
<span className="shrink-0 rounded-md bg-amber-500/15 px-1.5 py-0.5 text-[11px] font-semibold text-amber-800 dark:text-amber-300">
HOT
</span>
) : null}
</div>
{tags.length > 0 ? (
<div className="mt-1" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
<TagOverflowRow
rowKey={keyFor(j)}
tags={tags}
activeTagSet={activeTagSet}
lower={lower}
onToggleTagFilter={onToggleTagFilter}
maxWidthClassName="max-w-[11rem]"
/>
</div>
) : null}
</div>
</div>
)
},
},
{
key: 'completedAt',
header: 'Fertiggestellt am',
sortable: true,
widthClassName: 'w-[150px]',
sortValue: (j) => {
const t = Date.parse(String(j.endedAt || ''))
return Number.isFinite(t) ? t : Number.NEGATIVE_INFINITY
},
cell: (j) => {
const t = Date.parse(String(j.endedAt || ''))
if (!Number.isFinite(t)) return <span className="text-xs text-gray-400"></span>
const d = new Date(t)
const date = d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
const time = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
return (
<time
dateTime={d.toISOString()}
title={`${date} ${time}`}
className="tabular-nums whitespace-nowrap text-sm text-gray-900 dark:text-white"
>
<span className="font-medium">{date}</span>
<span className="mx-1 text-gray-400 dark:text-gray-600">·</span>
<span className="text-gray-600 dark:text-gray-300">{time}</span>
</time>
)
},
},
{
key: 'runtime',
header: 'Dauer',
align: 'right',
sortable: true,
sortValue: (j) => runtimeSecondsForSort(j),
cell: (j) => <span className="font-medium text-gray-900 dark:text-white">{runtimeOf(j)}</span>,
},
{
key: 'size',
header: 'Größe',
align: 'right',
sortable: true,
sortValue: (j) => {
const s = sizeBytesOf(j)
return typeof s === 'number' ? s : Number.NEGATIVE_INFINITY
},
cell: (j) => <span className="font-medium text-gray-900 dark:text-white">{formatBytes(sizeBytesOf(j))}</span>,
},
{
key: 'actions',
header: 'Aktionen',
align: 'right',
srOnlyHeader: true,
cell: (j) => {
const k = keyFor(j)
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
const fileRaw = baseName(j.output || '')
const isHot = isHotName(fileRaw)
const modelKey = lower(modelNameFromOutput(j.output))
const flags = modelsByKey[modelKey]
const isFav = Boolean(flags?.favorite)
const isLiked = flags?.liked === true
const isWatching = Boolean(flags?.watching)
return (
<RecordJobActions
job={j}
variant="table"
busy={busy}
isHot={isHot}
isFavorite={isFav}
isLiked={isLiked}
isWatching={isWatching}
onToggleWatch={onToggleWatch}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleHot={onToggleHot}
onKeep={keepVideo}
onDelete={deleteVideo}
order={['watch', 'favorite', 'like', 'hot', 'details', 'add', 'keep', 'delete']}
className="flex items-center justify-end gap-1"
/>
)
},
},
]
}, [
keyFor,
baseName,
durations,
teaserAudio,
hoverTeaserKey,
registerTeaserHost,
canHover,
setHoverTeaserKey,
handleDuration,
handleResolution,
blurPreviews,
teaserPlayback,
teaserKey,
assetNonce,
lower,
modelNameFromOutput,
modelsByKey,
activeTagSet,
onToggleTagFilter,
resolutions,
deletingKeys,
keepingKeys,
removingKeys,
onToggleWatch,
onToggleFavorite,
onToggleLike,
onToggleHot,
keepVideo,
deleteVideo,
sizeBytesOf,
formatBytes,
runtimeOf,
parseTags,
runtimeSecondsForSort,
])
const sortStateToMode = React.useCallback((s: SortState): SortMode => {
if (!s) return 'completed_desc'
const anyS = s as any
const key = String(anyS.key ?? anyS.columnKey ?? anyS.id ?? '')
const dirRaw = String(anyS.dir ?? anyS.direction ?? anyS.order ?? '').toLowerCase()
const asc = dirRaw === 'asc' || dirRaw === '1' || dirRaw === 'true'
if (key === 'completedAt') return asc ? 'completed_asc' : 'completed_desc'
if (key === 'runtime') return asc ? 'duration_asc' : 'duration_desc'
if (key === 'size') return asc ? 'size_asc' : 'size_desc'
if (key === 'Model') return asc ? 'file_asc' : 'file_desc'
if (key === 'video') return asc ? 'file_asc' : 'file_desc'
return asc ? 'completed_asc' : 'completed_desc'
}, [])
const handleSortChange = React.useCallback(
(s: SortState) => {
setSort(s) // Table Pfeil
onSortModeChange(sortStateToMode(s))
if (page !== 1) onPageChange(1)
},
[onSortModeChange, sortStateToMode, page, onPageChange]
)
const rowClassName = React.useCallback(
(j: RecordJob) => {
const k = keyFor(j)
return [
'transition-all duration-300',
(deletingKeys.has(k) || removingKeys.has(k)) && 'bg-red-50/60 dark:bg-red-500/10 pointer-events-none',
deletingKeys.has(k) && 'animate-pulse',
(keepingKeys.has(k) || removingKeys.has(k)) && 'pointer-events-none',
keepingKeys.has(k) && 'bg-emerald-50/60 dark:bg-emerald-500/10 animate-pulse',
removingKeys.has(k) && 'opacity-0',
]
.filter(Boolean)
.join(' ')
},
[keyFor, deletingKeys, keepingKeys, removingKeys]
)
return (
<Table
rows={rows}
columns={columns}
getRowKey={getRowKey}
striped
fullWidth
stickyHeader
compact={false}
card
sort={sort}
onSortChange={onSortChange}
onRowClick={onRowClick}
rowClassName={rowClassName}
/>
<div className="relative">
<Table
rows={rows}
columns={columns}
getRowKey={(j) => keyFor(j)}
striped
fullWidth
stickyHeader
compact={false}
card
sort={sort}
onSortChange={handleSortChange}
onRowClick={onOpenPlayer}
rowClassName={rowClassName}
/>
{isLoading && rows.length === 0 ? (
<div className="absolute inset-0 z-20 grid place-items-center rounded-lg bg-white/50 backdrop-blur-[2px] dark:bg-gray-950/40">
<div className="flex items-center gap-3 rounded-lg border border-gray-200 bg-white/80 px-3 py-2 shadow-sm dark:border-white/10 dark:bg-gray-900/70">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-gray-300 border-t-transparent dark:border-white/20 dark:border-t-transparent" />
<div className="text-sm font-medium text-gray-800 dark:text-gray-100">Lade</div>
</div>
</div>
) : null}
</div>
)
}

View File

@ -1,3 +1,4 @@
// frontend\src\components\ui\FinishedVideoPreview.tsx
'use client'
import { useEffect, useMemo, useRef, useState, type SyntheticEvent } from 'react'
@ -9,12 +10,18 @@ type Variant = 'thumb' | 'fill'
type InlineVideoMode = false | true | 'always' | 'hover'
type AnimatedMode = 'frames' | 'clips' | 'teaser'
type AnimatedTrigger = 'always' | 'hover'
type ProgressKind = 'inline' | 'teaser' | 'clips'
export type FinishedVideoPreviewProps = {
job: RecordJob
getFileName: (path: string) => string
/** optional legacy override (z.B. aus Cache im Parent) */
durationSeconds?: number
/** Callbacks für Parent-State */
onDuration?: (job: RecordJob, seconds: number) => void
onResolution?: (job: RecordJob, w: number, h: number) => void
/** animated="true": frames = wechselnde Bilder, clips = 1s-Teaser-Clips, teaser = vorgerendertes MP4 */
animated?: boolean
@ -34,7 +41,6 @@ export type FinishedVideoPreviewProps = {
variant?: Variant
className?: string
showPopover?: boolean
blur?: boolean
@ -51,16 +57,15 @@ export type FinishedVideoPreviewProps = {
inlineControls?: boolean
/** Inline-Playback: loopen? */
inlineLoop?: boolean
assetNonce?: number
/** alle Inline/Teaser/Clips muted? (Default: true) */
/** alle Inline/Teaser/Clips muted? (Default: true) */
muted?: boolean
/** Popover-Video muted? (Default: true) */
popoverMuted?: boolean
noGenerateTeaser?: boolean
}
export default function FinishedVideoPreview({
@ -68,10 +73,12 @@ export default function FinishedVideoPreview({
getFileName,
durationSeconds,
onDuration,
onResolution,
animated = false,
animatedMode = 'frames',
animatedTrigger = 'always',
autoTickMs = 15000,
thumbStepSec,
thumbSpread,
@ -90,7 +97,7 @@ export default function FinishedVideoPreview({
inlineLoop = true,
assetNonce = 0,
muted = DEFAULT_INLINE_MUTED,
popoverMuted = DEFAULT_INLINE_MUTED,
noGenerateTeaser,
@ -98,6 +105,160 @@ export default function FinishedVideoPreview({
const file = getFileName(job.output || '')
const blurCls = blur ? 'blur-md' : ''
// ✅ meta robust normalisieren (job.meta kann string sein)
const meta = useMemo(() => {
const m: any = (job as any)?.meta
if (!m) return null
if (typeof m === 'string') {
try {
return JSON.parse(m)
} catch {
return null
}
}
return m
}, [job])
// ✅ falls job.meta keine previewClips enthält: meta.json nachladen
const [fetchedMeta, setFetchedMeta] = useState<any | null>(null)
const metaForPreview = meta ?? fetchedMeta
const [progressMountTick, setProgressMountTick] = useState(0)
// previewClips mapping: preview.mp4 ist Concatenation von Segmenten
type PreviewClip = { startSeconds: number; durationSeconds: number }
type PreviewClipMap = { start: number; dur: number; cumStart: number; cumEnd: number }
const previewClipMap = useMemo<PreviewClipMap[] | null>(() => {
let pcsAny: any = (metaForPreview as any)?.previewClips
// ✅ falls previewClips als JSON-string gespeichert ist
if (typeof pcsAny === 'string') {
try {
pcsAny = JSON.parse(pcsAny)
} catch {
pcsAny = null
}
}
// ✅ falls es verschachtelt ist (falls du sowas irgendwo hast)
if (!Array.isArray(pcsAny) && Array.isArray((metaForPreview as any)?.preview?.clips)) {
pcsAny = (metaForPreview as any).preview.clips
}
const pcs = pcsAny as PreviewClip[] | null
if (!Array.isArray(pcs) || pcs.length === 0) return null
let cum = 0
const out: PreviewClipMap[] = []
for (const c of pcs) {
const start = Number((c as any)?.startSeconds)
const dur = Number((c as any)?.durationSeconds)
if (!Number.isFinite(start) || start < 0) continue
if (!Number.isFinite(dur) || dur <= 0) continue
const cumStart = cum
const cumEnd = cum + dur
out.push({ start, dur, cumStart, cumEnd })
cum = cumEnd
}
return out.length ? out : null
}, [metaForPreview])
const previewClipMapKey = useMemo(() => {
if (!previewClipMap) return ''
// stabiler key für effect-deps
return previewClipMap.map((c) => `${c.start.toFixed(3)}:${c.dur.toFixed(3)}`).join('|')
}, [previewClipMap])
// (aktuell ungenutzt, aber bewusst drin gelassen)
const mapPreviewTimeToGlobalTime = (tPreview: number, totalSeconds?: number) => {
const m = previewClipMap
if (!m || !m.length) return tPreview
// clamp
if (tPreview <= 0) return m[0].start
const last = m[m.length - 1]
if (tPreview >= last.cumEnd) {
// ✅ Wenn wir die Vollvideo-Dauer kennen: Teaser-Ende = 100% (Vollvideo-Ende)
if (typeof totalSeconds === 'number' && Number.isFinite(totalSeconds) && totalSeconds > 0) return totalSeconds
// Fallback
return last.start + last.dur
}
// binary search nach cumStart/cumEnd
let lo = 0
let hi = m.length - 1
while (lo <= hi) {
const mid = (lo + hi) >> 1
const c = m[mid]
if (tPreview < c.cumStart) hi = mid - 1
else if (tPreview >= c.cumEnd) lo = mid + 1
else {
// innerhalb des Segments
return c.start + (tPreview - c.cumStart)
}
}
// Fallback: nächster Clip
const idx = Math.max(0, Math.min(m.length - 1, lo))
const c = m[idx]
return c.start
}
void mapPreviewTimeToGlobalTime
const effectiveW =
(typeof meta?.videoWidth === 'number' && Number.isFinite(meta.videoWidth) && meta.videoWidth > 0 ? meta.videoWidth : undefined) ??
(typeof job.videoWidth === 'number' && Number.isFinite(job.videoWidth) && job.videoWidth > 0 ? job.videoWidth : undefined)
const effectiveH =
(typeof meta?.videoHeight === 'number' &&
Number.isFinite(meta.videoHeight) &&
meta.videoHeight > 0
? meta.videoHeight
: undefined) ??
(typeof job.videoHeight === 'number' && Number.isFinite(job.videoHeight) && job.videoHeight > 0 ? job.videoHeight : undefined)
const effectiveSizeBytes =
(typeof meta?.fileSize === 'number' && Number.isFinite(meta.fileSize) && meta.fileSize > 0 ? meta.fileSize : undefined) ??
(typeof job.sizeBytes === 'number' && Number.isFinite(job.sizeBytes) && job.sizeBytes > 0 ? job.sizeBytes : undefined)
const effectiveFPS =
(typeof meta?.fps === 'number' && Number.isFinite(meta.fps) && meta.fps > 0 ? meta.fps : undefined) ??
(typeof job.fps === 'number' && Number.isFinite(job.fps) && job.fps > 0 ? job.fps : undefined)
// --- Duration normalisieren: manche Quellen liefern ms statt s
const rawDuration =
(typeof (metaForPreview as any)?.durationSeconds === 'number' &&
Number.isFinite((metaForPreview as any).durationSeconds) &&
(metaForPreview as any).durationSeconds > 0
? (metaForPreview as any).durationSeconds
: undefined) ??
(typeof job.durationSeconds === 'number' && Number.isFinite(job.durationSeconds) && job.durationSeconds > 0
? job.durationSeconds
: undefined) ??
(typeof durationSeconds === 'number' && Number.isFinite(durationSeconds) && durationSeconds > 0 ? durationSeconds : undefined)
// Heuristik: > 24h in Sekunden ist unplausibel für Clips; außerdem sieht ms typischerweise wie 600000 aus.
const effectiveDurationSec =
typeof rawDuration === 'number' && Number.isFinite(rawDuration) && rawDuration > 0
? rawDuration > 24 * 60 * 60
? rawDuration / 1000
: rawDuration
: undefined
const hasDuration = typeof effectiveDurationSec === 'number' && Number.isFinite(effectiveDurationSec) && effectiveDurationSec > 0
const hasResolution =
typeof effectiveW === 'number' &&
typeof effectiveH === 'number' &&
Number.isFinite(effectiveW) &&
Number.isFinite(effectiveH) &&
effectiveW > 0 &&
effectiveH > 0
const commonVideoProps = {
muted,
playsInline: true,
@ -115,8 +276,8 @@ export default function FinishedVideoPreview({
const rootRef = useRef<HTMLDivElement | null>(null)
const [inView, setInView] = useState(false)
// ✅ NEU: sobald einmal im Viewport gewesen -> true (damit wir danach nicht wieder entladen)
const [everInView, setEverInView] = useState(false)
// ✅ sobald einmal im Viewport gewesen -> true (damit wir danach nicht wieder entladen)
const [, setEverInView] = useState(false)
// Tick nur für frames-Mode
const [localTick, setLocalTick] = useState(0)
@ -125,29 +286,65 @@ export default function FinishedVideoPreview({
const [hovered, setHovered] = useState(false)
const inlineMode: 'never' | 'always' | 'hover' =
inlineVideo === true || inlineVideo === 'always'
? 'always'
: inlineVideo === 'hover'
? 'hover'
: 'never'
inlineVideo === true || inlineVideo === 'always' ? 'always' : inlineVideo === 'hover' ? 'hover' : 'never'
// --- Hover-Events brauchen wir, wenn inline hover ODER teaser hover
const wantsHover =
inlineMode === 'hover' || (animated && (animatedMode === 'clips' || animatedMode === 'teaser') && animatedTrigger === 'hover')
const stripHot = (s: string) => (s.startsWith('HOT ') ? s.slice(4) : s)
const previewId = useMemo(() => {
const file = getFileName(job.output || '')
if (!file) return ''
const base = file.replace(/\.[^.]+$/, '') // ext weg
const f = getFileName(job.output || '')
if (!f) return ''
const base = f.replace(/\.[^.]+$/, '') // ext weg
return stripHot(base).trim()
}, [job.output, getFileName])
// Vollvideo (für Inline-Playback + Duration-Metadaten)
const videoSrc = useMemo(
() => (file ? `/api/record/video?file=${encodeURIComponent(file)}` : ''),
[file]
)
// ✅ meta fallback fetch: nur wenn previewClips fehlen (für teaser-sprünge)
useEffect(() => {
if (!previewId) return
if (!animated || animatedMode !== 'teaser') return
if (!(inView || (wantsHover && hovered))) return
const hasDuration =
typeof durationSeconds === 'number' && Number.isFinite(durationSeconds) && durationSeconds > 0
const pcs = (meta as any)?.previewClips
const hasPcs =
Array.isArray(pcs) || (typeof pcs === 'string' && pcs.length > 0) || Array.isArray((meta as any)?.preview?.clips)
if (hasPcs) return
let aborted = false
const ctrl = new AbortController()
const tryFetch = async (url: string) => {
try {
const res = await fetch(url, {
signal: ctrl.signal,
cache: 'no-store',
credentials: 'include',
})
if (!res.ok) return null
return await res.json()
} catch {
return null
}
}
;(async () => {
const byId = await tryFetch(`/api/record/done/meta?id=${encodeURIComponent(previewId)}`)
const byFile = file ? await tryFetch(`/api/record/done/meta?file=${encodeURIComponent(file)}`) : null
const j = byId ?? byFile
if (!aborted && j) setFetchedMeta(j)
})()
return () => {
aborted = true
ctrl.abort()
}
}, [previewId, file, animated, animatedMode, meta, inView, wantsHover, hovered])
// Vollvideo (für Inline-Playback + Fallback-Metadaten via loadedmetadata)
const videoSrc = useMemo(() => (file ? `/api/record/video?file=${encodeURIComponent(file)}` : ''), [file])
const sizeClass = variant === 'fill' ? 'w-full h-full' : 'w-20 h-16'
@ -155,9 +352,95 @@ export default function FinishedVideoPreview({
const teaserMp4Ref = useRef<HTMLVideoElement | null>(null)
const clipsRef = useRef<HTMLVideoElement | null>(null)
const lastMountedRef = useRef<{
inline: HTMLVideoElement | null
teaser: HTMLVideoElement | null
clips: HTMLVideoElement | null
}>({
inline: null,
teaser: null,
clips: null,
})
const bumpMountTickIfNew = (slot: 'inline' | 'teaser' | 'clips', el: HTMLVideoElement | null) => {
if (!el) return
if (lastMountedRef.current[slot] === el) return // ✅ gleicher DOM-Node -> NICHT nochmal tickern
lastMountedRef.current[slot] = el // ✅ erst merken, dann state setzen
setProgressMountTick((x) => x + 1)
}
// ▶️ Progressbar für abgespieltes Preview/Inline-Video
const [playRatio, setPlayRatio] = useState(0)
const [, setPlayGlobalSec] = useState(0)
const clamp01 = (x: number) => (x < 0 ? 0 : x > 1 ? 1 : x)
// ✅ FIX: Teaser-Mapping darf NICHT von currentSrc abhängen.
// Ratio basiert auf vvDur (z.B. 2/18) — unabhängig von totalSeconds.
const readProgressStepped = (
vv: HTMLVideoElement | null,
totalSeconds: number | undefined, // bleibt drin (nur für clamp/teaser-end)
stepSec = 1,
forceTeaserMap = false
): { ratio: number; globalSec: number; vvDur: number } => {
if (!vv) return { ratio: 0, globalSec: 0, vvDur: 0 }
const vvDur = Number(vv.duration)
const vvDurOk = Number.isFinite(vvDur) && vvDur > 0
if (!vvDurOk) return { ratio: 0, globalSec: 0, vvDur: 0 }
const tPreview = Number(vv.currentTime)
if (!Number.isFinite(tPreview) || tPreview < 0) return { ratio: 0, globalSec: 0, vvDur }
let globalSec = 0
const m = previewClipMap
if (forceTeaserMap && Array.isArray(m) && m.length > 0) {
const last = m[m.length - 1]
// Ende -> global = totalSeconds (falls bekannt), sonst Segment-Ende
if (tPreview >= last.cumEnd) {
globalSec =
typeof totalSeconds === 'number' && Number.isFinite(totalSeconds) && totalSeconds > 0
? totalSeconds
: last.start + last.dur
} else {
// Segment finden
let lo = 0
let hi = m.length - 1
let seg = m[0]
while (lo <= hi) {
const mid = (lo + hi) >> 1
const c = m[mid]
if (tPreview < c.cumStart) hi = mid - 1
else if (tPreview >= c.cumEnd) lo = mid + 1
else {
seg = c
break
}
}
const within = Math.max(0, tPreview - seg.cumStart)
const snapped = Math.floor(within / stepSec) * stepSec
globalSec = seg.start + Math.min(snapped, seg.dur)
}
} else {
// inline/clips: global = currentTime (gesnappt)
globalSec = Math.floor(tPreview / stepSec) * stepSec
}
// ✅ Balken-Ratio basiert auf vvDur
const g = Math.max(0, Math.min(globalSec, vvDur))
const ratio = clamp01(g / vvDur)
return { ratio, globalSec: g, vvDur }
}
const hardStop = (v: HTMLVideoElement | null) => {
if (!v) return
try { v.pause() } catch {}
try {
v.pause()
} catch {}
try {
v.removeAttribute('src')
// @ts-ignore
@ -196,11 +479,11 @@ export default function FinishedVideoPreview({
(entries) => {
const hit = Boolean(entries[0]?.isIntersecting)
setInView(hit)
if (hit) setEverInView(true) // ✅ NEU
if (hit) setEverInView(true)
},
{
threshold: 0.01,
rootMargin: '350px 0px', // ✅ lädt erst "bei Bedarf", aber schon etwas vor dem Viewport
rootMargin: '120px 0px', // oder '0px' wenn dus hart willst
}
)
@ -224,7 +507,7 @@ export default function FinishedVideoPreview({
if (animatedMode !== 'frames') return null
if (!hasDuration) return null
const dur = durationSeconds!
const dur = effectiveDurationSec!
const step = Math.max(0.25, thumbStepSec ?? 3)
if (thumbSpread) {
@ -239,15 +522,15 @@ export default function FinishedVideoPreview({
const total = Math.max(dur - 0.1, step)
const t = (localTick * step) % total
return Math.min(dur - 0.05, Math.max(0.05, t))
}, [animated, animatedMode, hasDuration, durationSeconds, localTick, thumbStepSec, thumbSpread, thumbSamples])
}, [animated, animatedMode, hasDuration, effectiveDurationSec, localTick, thumbStepSec, thumbSpread, thumbSamples])
const v = assetNonce ?? 0
const thumbSrc = useMemo(() => {
if (!previewId) return ''
if (thumbTimeSec == null) return `/api/record/preview?id=${encodeURIComponent(previewId)}&v=${v}`
return `/api/record/preview?id=${encodeURIComponent(previewId)}&t=${thumbTimeSec}&v=${v}-${localTick}`
}, [previewId, thumbTimeSec, localTick, v])
return `/api/record/preview?id=${encodeURIComponent(previewId)}&t=${thumbTimeSec}&v=${v}`
}, [previewId, thumbTimeSec, v])
const teaserSrc = useMemo(() => {
if (!previewId) return ''
@ -255,17 +538,51 @@ export default function FinishedVideoPreview({
return `/api/generated/teaser?id=${encodeURIComponent(previewId)}${noGen}&v=${v}`
}, [previewId, v, noGenerateTeaser])
// ✅ Nur Vollvideo darf onDuration liefern (nicht Teaser!)
// ✅ Wenn meta.json schon alles hat: sofort Callbacks auslösen (kein hidden <video> nötig)
useEffect(() => {
let did = false
if (onDuration && hasDuration) {
onDuration(job, Number(effectiveDurationSec))
did = true
}
if (onResolution && hasResolution) {
onResolution(job, Number(effectiveW), Number(effectiveH))
did = true
}
if (did) setMetaLoaded(true)
}, [job, onDuration, onResolution, hasDuration, hasResolution, effectiveDurationSec, effectiveW, effectiveH])
// ✅ Fallback: nur wenn wir wirklich noch nichts haben, über loadedmetadata nachladen
const handleLoadedMetadata = (e: SyntheticEvent<HTMLVideoElement>) => {
setMetaLoaded(true)
if (!onDuration) return
const secs = e.currentTarget.duration
if (Number.isFinite(secs) && secs > 0) onDuration(job, secs)
const vv = e.currentTarget
if (onDuration && !hasDuration) {
const secs = Number(vv.duration)
if (Number.isFinite(secs) && secs > 0) onDuration(job, secs)
}
if (onResolution && !hasResolution) {
const w = Number(vv.videoWidth)
const h = Number(vv.videoHeight)
if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) {
onResolution(job, w, h)
}
}
}
useEffect(() => {
setThumbOk(true)
setVideoOk(true)
// ✅ Mount-Guards zurücksetzen
lastMountedRef.current.inline = null
lastMountedRef.current.teaser = null
lastMountedRef.current.clips = null
}, [previewId, assetNonce])
if (!videoSrc) {
@ -274,10 +591,7 @@ export default function FinishedVideoPreview({
// --- Inline Video sichtbar?
const showingInlineVideo =
inlineMode !== 'never' &&
inView &&
videoOk &&
(inlineMode === 'always' || (inlineMode === 'hover' && hovered))
inlineMode !== 'never' && inView && videoOk && (inlineMode === 'always' || (inlineMode === 'hover' && hovered))
// --- Teaser/Clips aktiv? (inView, nicht inline, optional nur hover)
const teaserActive =
@ -287,26 +601,87 @@ export default function FinishedVideoPreview({
videoOk &&
!showingInlineVideo &&
(animatedTrigger === 'always' || hovered) &&
(
(animatedMode === 'teaser' && teaserOk && Boolean(teaserSrc)) ||
(animatedMode === 'clips' && hasDuration)
)
((animatedMode === 'teaser' && teaserOk && Boolean(teaserSrc)) || (animatedMode === 'clips' && hasDuration))
// --- Hover-Events brauchen wir, wenn inline hover ODER teaser hover
const wantsHover =
inlineMode === 'hover' ||
(animated && (animatedMode === 'clips' || animatedMode === 'teaser') && animatedTrigger === 'hover')
const progressTotalSeconds = hasDuration ? effectiveDurationSec : undefined
// ✅ Nur dann echte Asset-Requests auslösen, wenn wir sie brauchen
const shouldLoadAssets = everInView || (wantsHover && hovered)
const shouldLoadAssets = inView || (wantsHover && hovered)
// --- Legacy "clips" Logik (wenn du es noch nutzt)
// ✅ Progress-Quelle: NUR das Element, das wirklich spielt (für "Sprünge" wichtig)
const progressVideoRef =
showingInlineVideo
? inlineRef
: !showingInlineVideo && teaserActive && animatedMode === 'teaser'
? teaserMp4Ref
: !showingInlineVideo && teaserActive && animatedMode === 'clips'
? clipsRef
: null
const showProgressBar =
Boolean(progressVideoRef) &&
inView &&
typeof progressTotalSeconds === 'number' &&
Number.isFinite(progressTotalSeconds) &&
progressTotalSeconds > 0
const progressKind: ProgressKind =
showingInlineVideo ? 'inline' : teaserActive && animatedMode === 'teaser' ? 'teaser' : 'clips'
// ✅ Frames-Progress: zeigt Position des aktuellen Thumbnails relativ zur Gesamtdauer
const showFrameProgress =
animated &&
animatedMode === 'frames' &&
hasDuration &&
typeof thumbTimeSec === 'number' &&
Number.isFinite(thumbTimeSec) &&
thumbTimeSec >= 0
const frameRatio = showFrameProgress ? clamp01(thumbTimeSec! / effectiveDurationSec!) : 0
// finaler Balken: Video-Progress hat Priorität, sonst Frames-Progress
const progressRatio = showProgressBar ? playRatio : showFrameProgress ? frameRatio : 0
const showAnyProgress = showProgressBar || showFrameProgress
const clipOverlay = useMemo(() => {
if (!hasDuration) return null
const total = effectiveDurationSec!
if (!(total > 0)) return null
const pcsAny: any = (metaForPreview as any)?.previewClips
let pcs: any = pcsAny
if (typeof pcs === 'string') {
try {
pcs = JSON.parse(pcs)
} catch {
pcs = null
}
}
if (!Array.isArray(pcs) || pcs.length === 0) return null
const clips = pcs
.map((c: any) => ({
start: Number(c?.startSeconds),
dur: Number(c?.durationSeconds),
}))
.filter((c: any) => Number.isFinite(c.start) && c.start >= 0 && Number.isFinite(c.dur) && c.dur > 0)
if (!clips.length) return null
return clips.map((c: any) => {
const left = clamp01(c.start / total)
const width = clamp01(c.dur / total)
return { left, width, start: c.start, dur: c.dur }
})
}, [metaForPreview, hasDuration, effectiveDurationSec])
// --- Legacy "clips" Logik
const clipTimes = useMemo(() => {
if (!animated) return []
if (animatedMode !== 'clips') return []
if (!hasDuration) return []
const dur = durationSeconds!
const dur = effectiveDurationSec!
const clipLen = Math.max(0.25, clipSeconds)
const count = Math.max(8, Math.min(thumbSamples ?? 18, Math.floor(dur)))
@ -319,7 +694,7 @@ export default function FinishedVideoPreview({
times.push(Math.min(dur - 0.05, Math.max(0.05, t)))
}
return times
}, [animated, animatedMode, hasDuration, durationSeconds, thumbSamples, clipSeconds])
}, [animated, animatedMode, hasDuration, effectiveDurationSec, thumbSamples, clipSeconds])
const clipTimesKey = useMemo(() => clipTimes.map((t) => t.toFixed(2)).join(','), [clipTimes])
@ -327,21 +702,75 @@ export default function FinishedVideoPreview({
const clipStartRef = useRef(0)
useEffect(() => {
const v = teaserMp4Ref.current
if (!v) return
const vv = teaserMp4Ref.current
if (!vv) return
const active = teaserActive && animatedMode === 'teaser'
if (!active) {
try { v.pause() } catch {}
try {
vv.pause()
} catch {}
return
}
applyInlineVideoPolicy(v, { muted })
applyInlineVideoPolicy(vv, { muted })
const p = v.play?.()
const p = vv.play?.()
if (p && typeof (p as any).catch === 'function') (p as Promise<void>).catch(() => {})
}, [teaserActive, animatedMode, teaserSrc, muted])
// ▶️ Progressbar: global relativ zur Vollvideo-Dauer (mit mapping => Sprünge)
useEffect(() => {
if (!showProgressBar) {
setPlayRatio(0)
setPlayGlobalSec(0)
return
}
const vv = progressVideoRef?.current ?? null
if (!vv) {
setPlayRatio(0)
setPlayGlobalSec(0)
return
}
let stopped = false
let timer: number | null = null
const sync = () => {
if (stopped) return
if (!vv.isConnected) return
const forceMap = progressKind === 'teaser' && Array.isArray(previewClipMap) && previewClipMap.length > 0
const p = readProgressStepped(vv, progressTotalSeconds, 1, forceMap)
setPlayRatio(p.ratio)
setPlayGlobalSec(p.globalSec)
}
// initial sofort
sync()
// ✅ Sekundentakt (robust, unabhängig von raf/play-events)
timer = window.setInterval(sync, 1000)
// optional: bei metadata/timeupdate sofort einmal syncen
const onLoaded = () => sync()
const onTime = () => sync()
vv.addEventListener('loadedmetadata', onLoaded)
vv.addEventListener('durationchange', onLoaded)
vv.addEventListener('timeupdate', onTime)
return () => {
stopped = true
if (timer != null) window.clearInterval(timer)
vv.removeEventListener('loadedmetadata', onLoaded)
vv.removeEventListener('durationchange', onLoaded)
vv.removeEventListener('timeupdate', onTime)
}
}, [showProgressBar, progressVideoRef, progressTotalSeconds, previewClipMapKey, progressKind, previewClipMap, progressMountTick])
useEffect(() => {
if (!showingInlineVideo) return
applyInlineVideoPolicy(inlineRef.current, { muted })
@ -349,12 +778,11 @@ export default function FinishedVideoPreview({
// Legacy: "clips" spielt 1s Segmente aus dem Vollvideo per seek
useEffect(() => {
const v = clipsRef.current
if (!v) return
const vv = clipsRef.current
if (!vv) return
if (!(teaserActive && animatedMode === 'clips')) {
// bei teaser-mode übernimmt autoplay/loop, hier nur pausieren wenn nicht aktiv
if (!teaserActive) v.pause()
if (!teaserActive) vv.pause()
return
}
@ -365,9 +793,9 @@ export default function FinishedVideoPreview({
const start = () => {
try {
v.currentTime = clipStartRef.current
vv.currentTime = clipStartRef.current
} catch {}
const p = v.play()
const p = vv.play()
if (p && typeof (p as any).catch === 'function') (p as Promise<void>).catch(() => {})
}
@ -375,41 +803,48 @@ export default function FinishedVideoPreview({
const onTimeUpdate = () => {
if (!clipTimes.length) return
if (v.currentTime - clipStartRef.current >= clipSeconds) {
if (vv.currentTime - clipStartRef.current >= clipSeconds) {
clipIdxRef.current = (clipIdxRef.current + 1) % clipTimes.length
clipStartRef.current = clipTimes[clipIdxRef.current]
try {
v.currentTime = clipStartRef.current + 0.01
vv.currentTime = clipStartRef.current + 0.01
} catch {}
}
}
v.addEventListener('loadedmetadata', onLoaded)
v.addEventListener('timeupdate', onTimeUpdate)
vv.addEventListener('loadedmetadata', onLoaded)
vv.addEventListener('timeupdate', onTimeUpdate)
if (v.readyState >= 1) start()
if (vv.readyState >= 1) start()
return () => {
v.removeEventListener('loadedmetadata', onLoaded)
v.removeEventListener('timeupdate', onTimeUpdate)
v.pause()
vv.removeEventListener('loadedmetadata', onLoaded)
vv.removeEventListener('timeupdate', onTimeUpdate)
vv.pause()
}
}, [teaserActive, animatedMode, clipTimesKey, clipSeconds, clipTimes])
// ✅ brauchen wir noch hidden-metadata-load?
const needHiddenMeta =
inView &&
(onDuration || onResolution) &&
!metaLoaded &&
!showingInlineVideo &&
((onDuration && !hasDuration) || (onResolution && !hasResolution))
const previewNode = (
<div
ref={rootRef}
className={[
'rounded bg-gray-100 dark:bg-white/5 overflow-hidden relative',
sizeClass,
className ?? '',
].join(' ')}
className={['group rounded bg-gray-100 dark:bg-white/5 overflow-hidden relative isolate', sizeClass, className ?? ''].join(' ')}
onMouseEnter={wantsHover ? () => setHovered(true) : undefined}
onMouseLeave={wantsHover ? () => setHovered(false) : undefined}
onFocus={wantsHover ? () => setHovered(true) : undefined}
onBlur={wantsHover ? () => setHovered(false) : undefined}
data-duration={hasDuration ? String(effectiveDurationSec) : undefined}
data-res={hasResolution ? `${effectiveW}x${effectiveH}` : undefined}
data-size={typeof effectiveSizeBytes === 'number' ? String(effectiveSizeBytes) : undefined}
data-fps={typeof effectiveFPS === 'number' ? String(effectiveFPS) : undefined}
>
{/* 1) Inline Full Video (mit Controls) */}
{/* ✅ Thumb IMMER als Fallback/Background */}
{shouldLoadAssets && thumbSrc && thumbOk ? (
<img
@ -428,14 +863,19 @@ export default function FinishedVideoPreview({
{showingInlineVideo ? (
<video
{...commonVideoProps}
ref={inlineRef}
ref={(el) => {
inlineRef.current = el
bumpMountTickIfNew('inline', el)
}}
key={`inline-${previewId}-${inlineNonce}`}
src={videoSrc}
className={[
'absolute inset-0 w-full h-full object-cover',
blurCls,
inlineControls ? 'pointer-events-auto' : 'pointer-events-none',
].filter(Boolean).join(' ')}
]
.filter(Boolean)
.join(' ')}
autoPlay
muted={muted}
controls={inlineControls}
@ -446,10 +886,13 @@ export default function FinishedVideoPreview({
/>
) : null}
{/* ✅ Teaser MP4: nur im Viewport (teaserActive) Thumb bleibt drunter sichtbar */}
{/* ✅ Teaser MP4 */}
{!showingInlineVideo && teaserActive && animatedMode === 'teaser' ? (
<video
ref={teaserMp4Ref}
ref={(el) => {
teaserMp4Ref.current = el
bumpMountTickIfNew('teaser', el)
}}
key={`teaser-mp4-${previewId}`}
src={teaserSrc}
className={[
@ -457,7 +900,9 @@ export default function FinishedVideoPreview({
blurCls,
teaserReady ? 'opacity-100' : 'opacity-0',
'transition-opacity duration-150',
].filter(Boolean).join(' ')}
]
.filter(Boolean)
.join(' ')}
muted={muted}
playsInline
autoPlay
@ -467,22 +912,22 @@ export default function FinishedVideoPreview({
onLoadedData={() => setTeaserReady(true)}
onPlaying={() => setTeaserReady(true)}
onError={() => {
setTeaserOk(false) // ✅ nur teaser abschalten
setTeaserReady(false) // ✅ overlay wieder weg
setTeaserOk(false)
setTeaserReady(false)
}}
/>
) : null}
{/* ✅ Legacy clips (falls noch genutzt) */}
{/* ✅ Legacy clips */}
{!showingInlineVideo && teaserActive && animatedMode === 'clips' ? (
<video
ref={clipsRef}
ref={(el) => {
clipsRef.current = el
bumpMountTickIfNew('clips', el)
}}
key={`clips-${previewId}-${clipTimesKey}`}
src={videoSrc}
className={[
'absolute inset-0 w-full h-full object-cover pointer-events-none',
blurCls,
].filter(Boolean).join(' ')}
className={['absolute inset-0 w-full h-full object-cover pointer-events-none', blurCls].filter(Boolean).join(' ')}
muted={muted}
playsInline
preload="metadata"
@ -491,21 +936,66 @@ export default function FinishedVideoPreview({
/>
) : null}
{/* Metadaten nur laden wenn nötig (und nicht inline) */}
{inView && onDuration && !hasDuration && !metaLoaded && !showingInlineVideo && (
<video
src={videoSrc}
preload="metadata"
muted={muted}
playsInline
className="hidden"
onLoadedMetadata={handleLoadedMetadata}
/>
)}
{/* ▶️ Progressbar: kräftiger + mehr Kontrast */}
{showAnyProgress ? (
<div
aria-hidden="true"
className={[
'absolute left-0 right-0 bottom-0 z-10 pointer-events-none',
// etwas höher + bei hover deutlich
'h-0.5 group-hover:h-1.5',
'transition-[height] duration-150 ease-out',
// Track: heller + border/inset für Kontrast
'rounded-none group-hover:rounded-full',
'bg-black/35 dark:bg-white/10',
// darf beim Hover raus “glowen”
'overflow-hidden group-hover:overflow-visible',
].join(' ')}
>
{/* 1) Segmente (previewClips) als Markierungen */}
{progressKind === 'teaser' && clipOverlay ? (
<div className="absolute inset-0">
{clipOverlay.map((c, i) => (
<div
key={`seg-${i}-${c.left.toFixed(6)}-${c.width.toFixed(6)}`}
className="absolute top-0 bottom-0 bg-white/15 dark:bg-white/20"
style={{
left: `${c.left * 100}%`,
width: `${c.width * 100}%`,
}}
/>
))}
</div>
) : null}
{/* 2) Kontinuierlicher Fortschritt (SOLID, kein Gradient) */}
<div
className="absolute inset-0 origin-left transition-transform duration-150 ease-out"
style={{
transform: `scaleX(${clamp01(progressRatio)})`,
background: 'rgba(99,102,241,0.95)', // indigo-500-ish, kräftig
}}
/>
{/* 3) Knob am Ende (macht Progress sofort klar) */}
<div
className="absolute top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity duration-150"
style={{
left: `calc(${clamp01(progressRatio) * 100}% - 4px)`,
}}
>
<div className="h-1.5 w-1.5 rounded-full bg-white/90 shadow-[0_0_0_2px_rgba(0,0,0,0.25),0_0_10px_rgba(168,85,247,0.55)]" />
</div>
</div>
) : null}
{/* ✅ Metadaten-Fallback nur wenn nötig (und nicht inline) */}
{needHiddenMeta ? (
<video src={videoSrc} preload="metadata" muted={muted} playsInline className="hidden" onLoadedMetadata={handleLoadedMetadata} />
) : null}
</div>
)
// Gallery: kein HoverPopover
if (!showPopover) return previewNode
return (

View File

@ -1,8 +1,10 @@
// frontend\src\components\ui\GenerateAssetsTask.tsx
'use client'
import { useRef, useState, useEffect, useCallback } from 'react'
import { useEffect, useRef, useState } from 'react'
import Button from './Button'
import ProgressBar from './ProgressBar'
import { subscribeSSE } from '../../lib/sseSingleton'
type TaskState = {
running: boolean
@ -16,6 +18,17 @@ type TaskState = {
error?: string
}
type Progress = { done: number; total: number }
type Props = {
onFinished?: () => void
onStart?: (ac: AbortController) => void
onProgress?: (p: Progress) => void
onDone?: () => void
onCancelled?: () => void
onError?: (message: string) => void
}
async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, {
cache: 'no-store' as any,
@ -41,212 +54,173 @@ async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
return data as T
}
type Props = {
onFinished?: () => void
}
export default function GenerateAssetsTask({ onFinished }: Props) {
export default function GenerateAssetsTask({
onFinished,
onStart,
onProgress,
onDone,
onCancelled,
onError,
}: Props) {
const [state, setState] = useState<TaskState | null>(null)
const [error, setError] = useState<string | null>(null)
const [starting, setStarting] = useState(false)
const [stopping, setStopping] = useState(false)
const loadStatus = useCallback(async () => {
try {
const st = await fetchJSON<TaskState>('/api/tasks/generate-assets')
setState(st)
} catch (e: any) {
setError(e?.message ?? String(e))
}
}, [])
const [startError, setStartError] = useState<string | null>(null)
// Plumbing (für Abbrechen über TaskList)
const abortRef = useRef<AbortController | null>(null)
const armedRef = useRef(false) // onStart nur 1× pro Lauf feuern
const cancelledRef = useRef(false)
const stopInFlightRef = useRef(false)
const prevRunningRef = useRef(false)
const lastErrorRef = useRef('')
const onProgressRef = useRef<Props['onProgress']>(onProgress)
const onErrorRef = useRef<Props['onError']>(onError)
useEffect(() => {
onProgressRef.current = onProgress
}, [onProgress])
useEffect(() => {
onErrorRef.current = onError
}, [onError])
async function stopInternal() {
if (stopInFlightRef.current) return
stopInFlightRef.current = true
try {
await fetch('/api/tasks/generate-assets', { method: 'DELETE', cache: 'no-store' as any })
} catch {
// ignore
} finally {
stopInFlightRef.current = false
}
}
function ensureControllerCreated() {
if (abortRef.current) return abortRef.current
const ac = new AbortController()
abortRef.current = ac
const onAbort = () => {
cancelledRef.current = true
void stopInternal()
}
ac.signal.addEventListener('abort', onAbort, { once: true })
return ac
}
function armTaskList(ac: AbortController) {
if (armedRef.current) return
armedRef.current = true
onStart?.(ac)
}
// Detect finish (running -> false)
useEffect(() => {
const prev = prevRunningRef.current
const cur = Boolean(state?.running)
prevRunningRef.current = cur
// Task ist gerade fertig geworden
if (prev && !cur) {
const errText = String(state?.error ?? '').trim()
// Reset pro Run
abortRef.current = null
armedRef.current = false
if (cancelledRef.current || errText === 'abgebrochen') {
cancelledRef.current = false
onCancelled?.()
} else if (errText) {
// Fehlerfälle werden über onError schon gemeldet
} else {
onDone?.()
}
onFinished?.()
}
}, [state?.running, onFinished])
}, [state?.running, state?.error, onFinished, onDone, onCancelled])
// SSE: State + Progress nur nach oben (TaskList), kein UI hier
useEffect(() => {
loadStatus()
}, [loadStatus])
const unsub = subscribeSSE<TaskState>('/api/tasks/assets/stream', 'state', (st) => {
setState(st)
useEffect(() => {
if (!state?.running) return
const t = window.setInterval(loadStatus, 1200)
return () => window.clearInterval(t)
}, [state?.running, loadStatus])
if (st?.running) {
const ac = ensureControllerCreated()
armTaskList(ac)
onProgressRef.current?.({ done: st?.done ?? 0, total: st?.total ?? 0 })
}
const errText = String(st?.error ?? '').trim()
if (errText && errText !== lastErrorRef.current) {
lastErrorRef.current = errText
onErrorRef.current?.(errText)
}
})
return () => unsub()
}, [])
async function start() {
setError(null)
if (state?.running) return
setStartError(null)
setStarting(true)
cancelledRef.current = false
lastErrorRef.current = ''
// Controller vorbereiten, aber TaskList erst *nach* erfolgreichem Start armieren
const ac = ensureControllerCreated()
try {
const st = await fetchJSON<TaskState>('/api/tasks/generate-assets', { method: 'POST' })
setState(st)
// TaskList jetzt aktivieren
armTaskList(ac)
if (st?.running) {
onProgress?.({ done: st?.done ?? 0, total: st?.total ?? 0 })
}
} catch (e: any) {
setError(e?.message ?? String(e))
// Start fehlgeschlagen -> Controller/Flags zurücksetzen
abortRef.current = null
armedRef.current = false
const msg = e?.message ?? String(e)
setStartError(msg)
onError?.(msg)
} finally {
setStarting(false)
}
}
async function stop() {
setError(null)
setStopping(true)
try {
await fetch('/api/tasks/generate-assets', { method: 'DELETE', cache: 'no-store' as any })
} catch (e: any) {
// ignore wir holen danach Status neu
} finally {
await loadStatus()
setStopping(false)
}
}
const running = !!state?.running
const total = state?.total ?? 0
const done = state?.done ?? 0
const pct = total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 0
const fmtTime = (iso?: string) => {
const s = String(iso ?? '').trim()
if (!s) return null
const d = new Date(s)
if (!Number.isFinite(d.getTime())) return null
return d.toLocaleString(undefined, { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
}
const started = fmtTime(state?.startedAt)
const finished = fmtTime(state?.finishedAt)
return (
<div
className="
rounded-2xl border border-gray-200 bg-white/80 p-4 shadow-sm
backdrop-blur supports-[backdrop-filter]:bg-white/60
dark:border-white/10 dark:bg-gray-950/50 dark:supports-[backdrop-filter]:bg-gray-950/35
"
>
{/* Header */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="text-sm font-semibold text-gray-900 dark:text-white">
Assets-Generator
</div>
{/* Status badge */}
{running ? (
<span className="inline-flex items-center rounded-full bg-indigo-500/10 px-2 py-0.5 text-[11px] font-semibold text-indigo-700 ring-1 ring-inset ring-indigo-200 dark:text-indigo-200 dark:ring-indigo-400/30">
läuft
</span>
) : (
<span className="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-semibold text-gray-700 ring-1 ring-inset ring-gray-200 dark:bg-white/5 dark:text-gray-200 dark:ring-white/10">
bereit
</span>
)}
</div>
<div className="mt-1 text-xs text-gray-600 dark:text-white/70">
Erzeugt pro fertiger Datei unter <span className="font-mono">/generated/&lt;id&gt;/</span>{' '}
<span className="font-mono">thumbs.jpg</span>, <span className="font-mono">preview.mp4</span>{' '}
und <span className="font-mono">meta.json</span> für schnelle Listen & zuverlässige Duration.
</div>
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Assets-Generator</div>
<div className="mt-0.5 text-xs text-gray-600 dark:text-gray-300">
Erzeugt fehlende Assets (thumb/preview/meta). Fortschritt & Abbrechen oben in der Taskliste.
</div>
{/* Actions */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
{running ? (
<Button
variant="secondary"
color="red"
onClick={stop}
disabled={stopping}
className="w-full sm:w-auto"
>
{stopping ? 'Stoppe…' : 'Stop'}
</Button>
) : null}
<Button
variant="primary"
onClick={start}
disabled={starting || running}
className="w-full sm:w-auto"
>
{starting ? 'Starte…' : running ? 'Läuft…' : 'Generieren'}
</Button>
</div>
{startError ? (
<div className="mt-2 text-xs text-red-700 dark:text-red-200">
{startError}
</div>
) : null}
</div>
{/* Errors */}
{error ? (
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200">
{error}
</div>
) : null}
{state?.error ? (
<div className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200">
{state.error}
</div>
) : null}
{/* Body */}
{state ? (
<div className="mt-4 space-y-3">
<ProgressBar
value={pct}
showPercent
rightLabel={total ? `${done}/${total} Dateien` : '—'}
/>
{/* Stats */}
<div className="grid grid-cols-3 gap-2">
<div className="rounded-xl border border-gray-200 bg-white px-3 py-2 dark:border-white/10 dark:bg-white/5">
<div className="text-[11px] font-medium text-gray-600 dark:text-white/70">Thumbs</div>
<div className="mt-0.5 text-sm font-semibold tabular-nums text-gray-900 dark:text-white">
{state.generatedThumbs ?? 0}
</div>
</div>
<div className="rounded-xl border border-gray-200 bg-white px-3 py-2 dark:border-white/10 dark:bg-white/5">
<div className="text-[11px] font-medium text-gray-600 dark:text-white/70">Previews</div>
<div className="mt-0.5 text-sm font-semibold tabular-nums text-gray-900 dark:text-white">
{state.generatedPreviews ?? 0}
</div>
</div>
<div className="rounded-xl border border-gray-200 bg-white px-3 py-2 dark:border-white/10 dark:bg-white/5">
<div className="text-[11px] font-medium text-gray-600 dark:text-white/70">Übersprungen</div>
<div className="mt-0.5 text-sm font-semibold tabular-nums text-gray-900 dark:text-white">
{state.skipped ?? 0}
</div>
</div>
</div>
{/* Times */}
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1 text-xs text-gray-600 dark:text-white/70">
<span>
{started ? <>Start: <span className="font-medium text-gray-900 dark:text-white">{started}</span></> : 'Start: —'}
</span>
<span>
{finished ? <>Ende: <span className="font-medium text-gray-900 dark:text-white">{finished}</span></> : 'Ende: —'}
</span>
</div>
</div>
) : (
<div className="mt-4 text-xs text-gray-600 dark:text-white/70">
Status wird geladen
</div>
)}
<div className="shrink-0">
<Button variant="primary" onClick={start} disabled={starting || running}>
{starting ? 'Starte…' : 'Start'}
</Button>
</div>
</div>
)
}

View File

@ -0,0 +1,87 @@
// frontend\src\components\ui\LoadingSpinner.tsx
'use client'
import * as React from 'react'
type SpinnerSize = 'xs' | 'sm' | 'md' | 'lg' | number
export type LoadingSpinnerProps = {
/** Größe als Preset oder px (number) */
size?: SpinnerSize
/** Farbe via Tailwind (z.B. "text-indigo-500") */
className?: string
/** Optionaler Text neben dem Spinner (sichtbar) */
label?: React.ReactNode
/** Screenreader-Text (wenn label nicht gesetzt ist) */
srLabel?: string
/** Zentriert Spinner + Label als Inline-Flex */
center?: boolean
}
function sizeToPx(size: SpinnerSize): number {
if (typeof size === 'number') return size
if (size === 'xs') return 12
if (size === 'sm') return 16
if (size === 'lg') return 28
return 20 // md default
}
function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ')
}
export default function LoadingSpinner({
size = 'md',
className,
label,
srLabel = 'Lädt…',
center = false,
}: LoadingSpinnerProps) {
const px = sizeToPx(size)
return (
<span
className={cn(
'inline-flex items-center gap-2',
center && 'justify-center w-full',
)}
role="status"
aria-live="polite"
>
<svg
width={px}
height={px}
viewBox="0 0 24 24"
className={cn('animate-spin text-gray-500', className)}
aria-hidden="true"
>
{/* Hintergrund-Ring (leicht transparent) */}
<circle
cx="12"
cy="12"
r="9"
fill="none"
stroke="currentColor"
strokeWidth="3"
opacity="0.25"
/>
{/* “Arc” vorne */}
<path
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
d="M21 12a9 9 0 0 0-9-9"
opacity="0.95"
/>
</svg>
{label ? (
<span className="text-sm text-gray-700 dark:text-gray-200">{label}</span>
) : (
<span className="sr-only">{srLabel}</span>
)}
</span>
)
}

View File

@ -1,5 +1,5 @@
// frontend/src/components/ui/LoginPage.tsx
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import Button from './Button'
type Props = {
@ -45,26 +45,219 @@ function getNextFromLocation(): string {
}
}
const LOGIN_STATE_KEY = 'recorder_login_state_v1'
type PersistedLoginState = {
stage: 'login' | 'verify' | 'setup'
username: string
code: string
setupAuthUrl: string | null
setupSecret: string | null
setupInfo: string | null
ts: number
}
function loadLoginState(): PersistedLoginState | null {
try {
const raw = sessionStorage.getItem(LOGIN_STATE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as PersistedLoginState
// optional: nach 15 Minuten verwerfen
if (!parsed?.ts || Date.now() - parsed.ts > 15 * 60 * 1000) return null
return parsed
} catch {
return null
}
}
function saveLoginState(s: PersistedLoginState) {
try {
sessionStorage.setItem(LOGIN_STATE_KEY, JSON.stringify(s))
} catch {
// ignore
}
}
function clearLoginState() {
try {
sessionStorage.removeItem(LOGIN_STATE_KEY)
} catch {
// ignore
}
}
function splitCodeToDigits(code: string): string[] {
const only = (code ?? '').replace(/\D/g, '').slice(0, 6)
const arr = only.split('')
while (arr.length < 6) arr.push('')
return arr
}
function digitsToCode(d: string[]) {
return (d ?? []).join('')
}
export default function LoginPage({ onLoggedIn }: Props) {
const nextPath = useMemo(() => getNextFromLocation(), [])
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [code, setCode] = useState('')
// 6x inputs
const [codeDigits, setCodeDigits] = useState<string[]>(['', '', '', '', '', ''])
const codeInputsRef = useRef<Array<HTMLInputElement | null>>([])
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [stage, setStage] = useState<'login' | 'verify' | 'setup'>('login')
const [setupAuthUrl, setSetupAuthUrl] = useState<string | null>(null)
const [setupSecret, setSetupSecret] = useState<string | null>(null)
const [setupInfo, setSetupInfo] = useState<string | null>(null)
const submittedOnceRef = useRef(false)
const codeStr = useMemo(() => digitsToCode(codeDigits), [codeDigits])
function focusDigit(i: number) {
const el = codeInputsRef.current[i]
if (el) el.focus()
}
function clearAllDigits() {
setCodeDigits(['', '', '', '', '', ''])
submittedOnceRef.current = false
window.setTimeout(() => focusDigit(0), 0)
}
function setDigitAt(i: number, val: string) {
const v = (val ?? '').replace(/\D/g, '').slice(-1) // genau 1 Ziffer
setCodeDigits((prev) => {
const next = [...prev]
next[i] = v
return next
})
}
function handleDigitChange(i: number, raw: string) {
const only = (raw ?? '').replace(/\D/g, '')
if (!only) {
setDigitAt(i, '')
submittedOnceRef.current = false
return
}
// falls mehrere Ziffern reinkommen (z.B. AutoFill), verteilen
if (only.length > 1) {
setCodeDigits((prev) => {
const next = [...prev]
let k = i
for (const ch of only) {
if (k > 5) break
next[k] = ch
k++
}
return next
})
submittedOnceRef.current = false
window.setTimeout(() => focusDigit(Math.min(5, i + only.length)), 0)
return
}
setDigitAt(i, only)
submittedOnceRef.current = false
if (i < 5) window.setTimeout(() => focusDigit(i + 1), 0)
}
function handleDigitKeyDown(i: number, ev: React.KeyboardEvent<HTMLInputElement>) {
if (ev.key === 'Backspace') {
const cur = codeDigits[i]
if (cur) {
ev.preventDefault()
setDigitAt(i, '')
submittedOnceRef.current = false
return
}
if (i > 0) {
ev.preventDefault()
focusDigit(i - 1)
setDigitAt(i - 1, '')
submittedOnceRef.current = false
}
return
}
if (ev.key === 'ArrowLeft') {
ev.preventDefault()
if (i > 0) focusDigit(i - 1)
return
}
if (ev.key === 'ArrowRight') {
ev.preventDefault()
if (i < 5) focusDigit(i + 1)
return
}
}
function handleDigitPaste(i: number, ev: React.ClipboardEvent<HTMLInputElement>) {
const text = ev.clipboardData.getData('text') || ''
const only = text.replace(/\D/g, '').slice(0, 6)
if (!only) return
ev.preventDefault()
setCodeDigits((prev) => {
const next = [...prev]
let k = i
for (const ch of only) {
if (k > 5) break
next[k] = ch
k++
}
return next
})
submittedOnceRef.current = false
window.setTimeout(() => focusDigit(Math.min(5, i + only.length)), 0)
}
// Restore aus sessionStorage
useEffect(() => {
const st = loadLoginState()
if (!st) return
setStage(st.stage ?? 'login')
setUsername(st.username ?? '')
setCodeDigits(splitCodeToDigits(st.code ?? ''))
setSetupAuthUrl(st.setupAuthUrl ?? null)
setSetupSecret(st.setupSecret ?? null)
setSetupInfo(st.setupInfo ?? null)
}, [])
// Persist in sessionStorage (wichtig für Mobile + Bitwarden Wechsel)
useEffect(() => {
saveLoginState({
stage,
username,
code: codeStr,
setupAuthUrl,
setupSecret,
setupInfo,
ts: Date.now(),
})
}, [stage, username, codeStr, setupAuthUrl, setupSecret, setupInfo])
// Bei Stage-Wechsel: Fokus / Submit-Guard reset
useEffect(() => {
submittedOnceRef.current = false
if (stage === 'verify' || stage === 'setup') {
window.setTimeout(() => focusDigit(0), 0)
}
}, [stage])
// Wenn Backend schon eingeloggt ist (z.B. Cookie vorhanden), direkt weiter
useEffect(() => {
useEffect(() => {
let cancelled = false
const check = async () => {
try {
@ -78,6 +271,7 @@ export default function LoginPage({ onLoggedIn }: Props) {
// Setup-Infos laden (QR/otpauth)
void ensure2FASetup()
} else {
clearLoginState()
window.location.assign(nextPath || '/')
}
return
@ -99,7 +293,7 @@ export default function LoginPage({ onLoggedIn }: Props) {
}
}, [nextPath])
const submitLogin = async () => {
const submitLogin = async () => {
setBusy(true)
setError(null)
@ -113,6 +307,8 @@ export default function LoginPage({ onLoggedIn }: Props) {
// 2FA ist aktiv → Code-Abfrage
if (data?.totpRequired) {
setStage('verify')
// Code-Felder leeren (sauberer Start)
clearAllDigits()
return
}
@ -121,12 +317,13 @@ export default function LoginPage({ onLoggedIn }: Props) {
if (me?.authenticated && !me?.totpConfigured) {
setStage('setup')
clearAllDigits()
await ensure2FASetup()
return
}
// normaler Fall: eingeloggt + entweder 2FA schon configured oder bewusst nicht erzwingen
if (onLoggedIn) await onLoggedIn()
clearLoginState()
window.location.assign(nextPath || '/')
} catch (e: any) {
setError(e?.message ?? String(e))
@ -135,20 +332,24 @@ export default function LoginPage({ onLoggedIn }: Props) {
}
}
const submit2FA = async () => {
const c = codeStr.trim()
if (!/^\d{6}$/.test(c)) return
setBusy(true)
setError(null)
try {
await apiJSON<{ ok?: boolean }>('/api/auth/2fa/enable', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
body: JSON.stringify({ code: c }),
})
if (onLoggedIn) await onLoggedIn()
clearLoginState()
window.location.assign(nextPath || '/')
} catch (e: any) {
submittedOnceRef.current = false
setError(e?.message ?? String(e))
} finally {
setBusy(false)
@ -161,9 +362,9 @@ export default function LoginPage({ onLoggedIn }: Props) {
try {
const data = await apiJSON<SetupResp>('/api/auth/2fa/setup', {
method: 'POST', // ✅ dein Backend prüft Method nicht, aber POST ist sauber
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}), // optional leer
body: JSON.stringify({}),
})
const otpauth = (data?.otpauth ?? '').trim()
@ -177,14 +378,50 @@ export default function LoginPage({ onLoggedIn }: Props) {
}
}
const onEnter = (ev: React.KeyboardEvent<HTMLInputElement>) => {
if (ev.key !== 'Enter') return
ev.preventDefault()
// Auto-submit sobald 6 Ziffern befüllt sind (verify + setup)
useEffect(() => {
if (busy) return
if (stage !== 'verify' && stage !== 'setup') return
if (!/^\d{6}$/.test(codeStr)) return
if (submittedOnceRef.current) return
if (stage === 'verify' || stage === 'setup') void submit2FA()
else void submitLogin()
}
submittedOnceRef.current = true
void submit2FA()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [codeStr, stage, busy])
const Code6Inputs = (
<div className="space-y-1">
<label className="text-xs font-medium text-gray-700 dark:text-gray-200">2FA Code</label>
<div className="flex items-center justify-between gap-2">
{codeDigits.map((d, i) => (
<input
key={i}
ref={(el) => {
codeInputsRef.current[i] = el
}}
value={d}
onChange={(e) => handleDigitChange(i, e.target.value)}
onKeyDown={(e) => handleDigitKeyDown(i, e)}
onPaste={(e) => handleDigitPaste(i, e)}
inputMode="numeric"
pattern="[0-9]*"
maxLength={1}
autoComplete={i === 0 ? 'one-time-code' : 'off'}
enterKeyHint={i === 5 ? 'done' : 'next'}
autoCapitalize="none"
autoCorrect="off"
disabled={busy}
className="h-12 w-12 rounded-lg text-center text-lg tabular-nums bg-white text-gray-900 shadow-sm ring-1 ring-gray-200
focus:outline-none focus:ring-2 focus:ring-indigo-500
dark:bg-white/10 dark:text-white dark:ring-white/10"
aria-label={`2FA Ziffer ${i + 1}`}
/>
))}
</div>
</div>
)
return (
<div className="min-h-[100dvh] bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100">
@ -197,20 +434,24 @@ export default function LoginPage({ onLoggedIn }: Props) {
<div className="w-full max-w-md rounded-2xl border border-gray-200/70 bg-white/80 p-6 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5">
<div className="space-y-1">
<h1 className="text-lg font-semibold tracking-tight">Recorder Login</h1>
<p className="text-sm text-gray-600 dark:text-gray-300">
Bitte melde dich an, um fortzufahren.
</p>
<p className="text-sm text-gray-600 dark:text-gray-300">Bitte melde dich an, um fortzufahren.</p>
</div>
<div className="mt-5 space-y-3">
{stage === 'login' ? (
<>
<form
onSubmit={(e) => {
e.preventDefault()
if (busy) return
void submitLogin()
}}
className="space-y-3"
>
<div className="space-y-1">
<label className="text-xs font-medium text-gray-700 dark:text-gray-200">Username</label>
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
onKeyDown={onEnter}
autoComplete="username"
className="block w-full rounded-lg px-3 py-2.5 text-sm bg-white text-gray-900 shadow-sm ring-1 ring-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-white/10 dark:text-white dark:ring-white/10"
placeholder="admin"
@ -224,7 +465,6 @@ export default function LoginPage({ onLoggedIn }: Props) {
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={onEnter}
autoComplete="current-password"
className="block w-full rounded-lg px-3 py-2.5 text-sm bg-white text-gray-900 shadow-sm ring-1 ring-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-white/10 dark:text-white dark:ring-white/10"
placeholder="••••••••••"
@ -232,70 +472,58 @@ export default function LoginPage({ onLoggedIn }: Props) {
/>
</div>
<Button
variant="primary"
className="w-full rounded-lg"
disabled={busy || !username.trim() || !password}
onClick={() => void submitLogin()}
>
<Button type="submit" variant="primary" className="w-full rounded-lg" disabled={busy || !username.trim() || !password}>
{busy ? 'Login…' : 'Login'}
</Button>
</>
</form>
) : stage === 'verify' ? (
<>
<form
onSubmit={(e) => {
e.preventDefault()
if (busy) return
void submit2FA()
}}
className="space-y-3"
>
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200">
2FA ist aktiv bitte gib den Code aus deiner Authenticator-App ein.
</div>
<div className="space-y-1">
<label htmlFor="totp" className="text-xs font-medium text-gray-700 dark:text-gray-200">2FA Code</label>
<input
id="id_code"
name="code"
aria-label="totp"
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
onKeyDown={onEnter}
autoComplete="one-time-code"
required
inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
enterKeyHint="done"
autoCapitalize="none"
autoCorrect="off"
className="block w-full rounded-lg px-3 py-2.5 text-sm bg-white text-gray-900 shadow-sm ring-1 ring-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-white/10 dark:text-white dark:ring-white/10"
placeholder="123456"
disabled={busy}
/>
</div>
{Code6Inputs}
<div className="flex gap-2">
<Button
type="button"
variant="secondary"
className="flex-1 rounded-lg"
disabled={busy}
onClick={() => {
setSetupAuthUrl(null)
setSetupSecret(null)
setSetupInfo(null)
setStage('login')
clearAllDigits()
}}
>
Zurück
</Button>
<Button
type="submit"
variant="primary"
className="flex-1 rounded-lg"
disabled={busy || code.trim().length < 6}
onClick={() => void submit2FA()}
disabled={busy || !/^\d{6}$/.test(codeStr)}
>
{busy ? 'Prüfe…' : 'Bestätigen'}
</Button>
</div>
</>
</form>
) : (
<>
<form
onSubmit={(e) => {
e.preventDefault()
if (busy) return
void submit2FA()
}}
className="space-y-3"
>
<div className="rounded-lg border border-indigo-200 bg-indigo-50 px-3 py-2 text-sm text-indigo-900 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-200">
2FA ist noch nicht eingerichtet bitte richte es jetzt ein (empfohlen).
</div>
@ -329,12 +557,11 @@ export default function LoginPage({ onLoggedIn }: Props) {
</div>
) : null}
{setupInfo ? (
<div className="mt-3 text-xs text-gray-600 dark:text-gray-300">{setupInfo}</div>
) : null}
{setupInfo ? <div className="mt-3 text-xs text-gray-600 dark:text-gray-300">{setupInfo}</div> : null}
<div className="mt-3">
<Button
type="button"
variant="secondary"
className="w-full rounded-lg"
disabled={busy}
@ -346,38 +573,19 @@ export default function LoginPage({ onLoggedIn }: Props) {
</div>
</div>
<div className="space-y-1">
<label htmlFor="totp" className="text-xs font-medium text-gray-700 dark:text-gray-200">2FA Code (zum Aktivieren)</label>
<input
id="totp-setup"
name="code"
aria-label="totp"
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
onKeyDown={onEnter}
autoComplete="one-time-code"
required
inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
enterKeyHint="done"
autoCapitalize="none"
autoCorrect="off"
className="block w-full rounded-lg px-3 py-2.5 text-sm bg-white text-gray-900 shadow-sm ring-1 ring-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-white/10 dark:text-white dark:ring-white/10"
placeholder="123456"
disabled={busy}
/>
</div>
{/* gleiche 6-fach Eingabe auch hier */}
{Code6Inputs}
<div className="flex gap-2">
<Button
type="button"
variant="secondary"
className="flex-1 rounded-lg"
disabled={busy}
onClick={() => {
// optional: Setup überspringen (nicht empfohlen)
if (onLoggedIn) void onLoggedIn()
clearLoginState()
window.location.assign(nextPath || '/')
}}
title="Ohne 2FA fortfahren (nicht empfohlen)"
@ -385,16 +593,11 @@ export default function LoginPage({ onLoggedIn }: Props) {
Später
</Button>
<Button
variant="primary"
className="flex-1 rounded-lg"
disabled={busy || code.trim().length < 6}
onClick={() => void submit2FA()}
>
<Button type="submit" variant="primary" className="flex-1 rounded-lg" disabled={busy || !/^\d{6}$/.test(codeStr)}>
{busy ? 'Aktiviere…' : '2FA aktivieren'}
</Button>
</div>
</>
</form>
)}
{error ? (

View File

@ -1,9 +1,14 @@
// frontend\src\components\ui\Modal.tsx
'use client'
import { Fragment, type ReactNode } from 'react'
import { Fragment, type ReactNode, useEffect, useRef, useState } from 'react'
import { Dialog, Transition } from '@headlessui/react'
import { XMarkIcon } from '@heroicons/react/24/outline'
type ModalLayout = 'single' | 'split'
type ModalScroll = 'body' | 'right' | 'none'
type ModalProps = {
open: boolean
onClose: () => void
@ -17,6 +22,59 @@ type ModalProps = {
* "max-w-lg" (default), "max-w-2xl", "max-w-4xl", "max-w-5xl"
*/
width?: string
/**
* Layout:
* - single: klassisches Modal (ein Content-Bereich)
* - split: 2 Spalten (links fix, rechts content)
*/
layout?: ModalLayout
/**
* Split-Layout: linker Inhalt (fixe Spalte)
*/
left?: ReactNode
/**
* Split-Layout: Breite der linken Spalte (Tailwind).
* Default: "lg:w-80" (320px)
*/
leftWidthClass?: string
/**
* Scroll-Verhalten:
* - body: der Body-Bereich scrollt (bei single default)
* - right: nur rechte Spalte scrollt (bei split default)
* - none: kein Scroll (nur sinnvoll wenn Inhalt garantiert passt)
*/
scroll?: ModalScroll
/**
* Optional: Zusatzklassen
*/
bodyClassName?: string
leftClassName?: string
rightClassName?: string
/**
* Split-Layout: Header über dem scrollbaren rechten Bereich
* (z.B. Tabs/Actions)
*/
rightHeader?: ReactNode
/**
* Optional: Zusatzklassen nur für den scrollbaren RIGHT-BODY
*/
rightBodyClassName?: string
/** Optional: kleines Bild im mobilen collapsed Header */
mobileCollapsedImageSrc?: string
mobileCollapsedImageAlt?: string
}
function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ')
}
export default function Modal({
@ -27,10 +85,85 @@ export default function Modal({
footer,
icon,
width = 'max-w-lg',
layout = 'single',
left,
leftWidthClass = 'lg:w-80',
scroll,
bodyClassName,
leftClassName,
rightClassName,
rightHeader,
rightBodyClassName,
mobileCollapsedImageSrc,
mobileCollapsedImageAlt,
}: ModalProps) {
// sensible defaults
const scrollMode: ModalScroll =
scroll ?? (layout === 'split' ? 'right' : 'body')
// --- mobile collapse-on-scroll (only used in split+mobile stacked) ---
const mobileScrollRef = useRef<HTMLDivElement | null>(null)
const [mobileCollapsed, setMobileCollapsed] = useState(false)
useEffect(() => {
if (!open) return
const html = document.documentElement
const body = document.body
const prevHtmlOverflow = html.style.overflow
const prevBodyOverflow = body.style.overflow
const prevBodyPaddingRight = body.style.paddingRight
// verhindert Layout-Shift wenn Scrollbar verschwindet
const scrollBarWidth = window.innerWidth - html.clientWidth
html.style.overflow = 'hidden'
body.style.overflow = 'hidden'
if (scrollBarWidth > 0) body.style.paddingRight = `${scrollBarWidth}px`
return () => {
html.style.overflow = prevHtmlOverflow
body.style.overflow = prevBodyOverflow
body.style.paddingRight = prevBodyPaddingRight
}
}, [open])
useEffect(() => {
if (!open) return
// reset when opening
setMobileCollapsed(false)
}, [open])
useEffect(() => {
if (!open) return
const el = mobileScrollRef.current
if (!el) return
const THRESHOLD = 72 // px, ab wann "kompakt" wird
const onScroll = () => {
const y = el.scrollTop || 0
// nur updaten wenn sich der boolean ändert (verhindert re-render spam)
setMobileCollapsed((prev) => {
const next = y > THRESHOLD
return prev === next ? prev : next
})
}
// initial
onScroll()
el.addEventListener('scroll', onScroll, { passive: true })
return () => el.removeEventListener('scroll', onScroll as any)
}, [open])
return (
<Transition show={open} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={onClose}>
<Dialog open={open} as="div" className="relative z-50" onClose={onClose}>
{/* Backdrop */}
<Transition.Child
as={Fragment}
@ -45,7 +178,7 @@ export default function Modal({
</Transition.Child>
{/* Modal Panel */}
<div className="fixed inset-0 z-50 overflow-y-auto px-4 py-6 sm:px-6">
<div className="fixed inset-0 z-50 overflow-hidden px-4 py-6 sm:px-6">
<div className="min-h-full flex items-start justify-center sm:items-center">
<Transition.Child
as={Fragment}
@ -57,25 +190,31 @@ export default function Modal({
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel
className={[
'relative w-full transform rounded-lg bg-white text-left shadow-xl transition-all',
className={cn(
'relative w-full rounded-lg bg-white text-left shadow-xl transition-all',
'max-h-[calc(100vh-3rem)] sm:max-h-[calc(100vh-4rem)]',
'flex flex-col',
// panel is a flex column so we can create a real scroll area
'flex flex-col min-h-0',
'dark:bg-gray-800 dark:outline dark:-outline-offset-1 dark:outline-white/10',
width, // <- hier greift deine max-w-… Klasse
].join(' ')}
width
)}
>
{icon && (
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-500/10">
{icon ? (
<div className="mx-auto mb-4 mt-6 flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-500/10">
{icon}
</div>
)}
) : null}
{/* Header */}
<div className="px-6 pt-6 flex items-start justify-between gap-3">
{/* Header (desktop/tablet). On mobile+split we use our own sticky header inside the scroll area */}
<div
className={cn(
'shrink-0 px-4 pt-4 sm:px-6 sm:pt-6 items-start justify-between gap-3',
layout === 'split' ? 'hidden lg:flex' : 'flex'
)}
>
<div className="min-w-0">
{title ? (
<Dialog.Title className="text-base font-semibold text-gray-900 dark:text-white truncate">
<Dialog.Title className="hidden sm:block text-base font-semibold text-gray-900 dark:text-white truncate">
{title}
</Dialog.Title>
) : null}
@ -84,12 +223,12 @@ export default function Modal({
<button
type="button"
onClick={onClose}
className="
inline-flex shrink-0 items-center justify-center rounded-lg p-1.5
text-gray-500 hover:text-gray-900 hover:bg-black/5
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600
dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 dark:focus-visible:outline-indigo-500
"
className={cn(
'inline-flex shrink-0 items-center justify-center rounded-lg p-1.5',
'text-gray-500 hover:text-gray-900 hover:bg-black/5',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600',
'dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 dark:focus-visible:outline-indigo-500'
)}
aria-label="Schließen"
title="Schließen"
>
@ -97,14 +236,157 @@ export default function Modal({
</button>
</div>
{/* Body (scrollable) */}
<div className="px-6 pb-6 pt-4 text-sm text-gray-700 dark:text-gray-300 overflow-y-auto">
{children}
</div>
{/* Body */}
{layout === 'single' ? (
<div
className={cn(
'flex-1 min-h-0 h-full',
scrollMode === 'body'
? 'overflow-y-auto overscroll-contain'
: 'overflow-hidden',
rightClassName
)}
>
{children}
</div>
) : (
// split layout
<div
className={cn(
'px-2 pb-4 pt-3 sm:px-4 sm:pb-6 sm:pt-4',
'flex-1 min-h-0',
'overflow-hidden',
'flex flex-col',
bodyClassName
)}
>
{/* ========================= */}
{/* MOBILE: stacked (no split) */}
{/* ========================= */}
<div
ref={mobileScrollRef}
className={cn(
'lg:hidden flex-1 min-h-0 relative',
// auf Mobile: EIN Scrollcontainer für alles
(scrollMode === 'right' || scrollMode === 'body') ? 'overflow-y-auto overscroll-contain' : 'overflow-hidden'
)}
>
{/* Sticky top area: app bar (shrinks) + left (collapses) + tabs/actions (sticky) */}
<div
className={cn(
'sticky top-0 z-50',
'bg-white/95 backdrop-blur dark:bg-gray-800/95',
'border-b border-gray-200/70 dark:border-white/10'
)}
>
{/* App bar (always visible, shrinks when collapsed) */}
<div
className={cn(
'flex items-center justify-between gap-3 px-3',
mobileCollapsed ? 'py-2' : 'py-3'
)}
>
<div className="min-w-0 flex items-center gap-2">
{mobileCollapsedImageSrc ? (
<img
src={mobileCollapsedImageSrc}
alt={mobileCollapsedImageAlt || title || ''}
className={cn(
'shrink-0 rounded-lg object-cover ring-1 ring-black/5 dark:ring-white/10',
mobileCollapsed ? 'size-8' : 'size-10'
)}
loading="lazy"
decoding="async"
/>
) : null}
<div className="min-w-0">
{title ? (
<div
className={cn(
'truncate font-semibold text-gray-900 dark:text-white',
mobileCollapsed ? 'text-sm' : 'text-base'
)}
>
{title}
</div>
) : null}
</div>
</div>
<button
type="button"
onClick={onClose}
className={cn(
'inline-flex shrink-0 items-center justify-center rounded-lg p-1.5',
'text-gray-500 hover:text-gray-900 hover:bg-black/5',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600',
'dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 dark:focus-visible:outline-indigo-500'
)}
aria-label="Schließen"
title="Schließen"
>
<XMarkIcon className="size-5" />
</button>
</div>
{/* Sticky tabs/actions (always sticky because in this sticky wrapper) */}
{rightHeader ? <div>{rightHeader}</div> : null}
</div>
{/* LEFT content on mobile (scrolls away, not sticky) */}
{left ? (
<div className={cn('lg:hidden px-2 pb-2', leftClassName)}>
{left}
</div>
) : null}
{/* Body (only the right content) */}
<div className={cn('px-2 pt-0 min-h-0', rightClassName)}>
<div className={cn('min-h-0', rightBodyClassName)}>{children}</div>
</div>
</div>
{/* ========================= */}
{/* DESKTOP: real split layout */}
{/* ========================= */}
<div className="hidden lg:flex flex-1 min-h-0 gap-3">
{/* LEFT (fixed) */}
<div
className={cn(
'min-h-0',
leftWidthClass,
'shrink-0',
'overflow-hidden',
leftClassName
)}
>
{left}
</div>
{/* RIGHT */}
<div className={cn('flex-1 min-h-0 flex flex-col', rightClassName)}>
{rightHeader ? <div className="shrink-0">{rightHeader}</div> : null}
<div
className={cn(
'flex-1 min-h-0',
scrollMode === 'right'
? 'overflow-y-auto overscroll-contain'
: 'overflow-hidden',
rightBodyClassName
)}
>
{children}
</div>
</div>
</div>
</div>
)}
{/* Footer */}
{footer ? (
<div className="px-6 py-4 border-t border-gray-200/70 dark:border-white/10 flex justify-end gap-3">
<div className="shrink-0 px-6 py-4 border-t border-gray-200/70 dark:border-white/10 flex justify-end gap-3">
{footer}
</div>
) : null}

File diff suppressed because it is too large Load Diff

View File

@ -14,16 +14,13 @@ type Props = {
className?: string
fit?: 'cover' | 'contain'
// ✅ NEU: aligned refresh (z.B. exakt bei 10s/20s/30s seit startedAt)
alignStartAt?: string | number | Date
alignEndAt?: string | number | Date | null
alignEveryMs?: number
// ✅ NEU: schneller Retry am Anfang (nur bei Running sinnvoll)
fastRetryMs?: number
fastRetryMax?: number
fastRetryWindowMs?: number
}
export default function ModelPreview({
@ -39,24 +36,25 @@ export default function ModelPreview({
fastRetryWindowMs,
className,
}: Props) {
const [pageVisible, setPageVisible] = useState(() => {
if (typeof document === 'undefined') return true
return !document.hidden
})
const blurCls = blur ? 'blur-md' : ''
const rootRef = useRef<HTMLDivElement | null>(null)
// ✅ page visibility als REF (kein Rerender-Fanout bei visibilitychange)
const pageVisibleRef = useRef(true)
// inView als State (brauchen wir für eager/lazy + fetchPriority + UI)
const [inView, setInView] = useState(false)
const inViewRef = useRef(false)
const [localTick, setLocalTick] = useState(0)
const [imgError, setImgError] = useState(false)
const rootRef = useRef<HTMLDivElement | null>(null)
const [inView, setInView] = useState(false)
const retryT = useRef<number | null>(null)
const fastTries = useRef(0)
const hadSuccess = useRef(false)
const enteredViewOnce = useRef(false)
const toMs = (v: any): number => {
if (typeof v === 'number' && Number.isFinite(v)) return v
if (v instanceof Date) return v.getTime()
@ -64,34 +62,61 @@ export default function ModelPreview({
return Number.isFinite(ms) ? ms : NaN
}
// ✅ visibilitychange -> nur REF updaten
useEffect(() => {
const onVis = () => setPageVisible(!document.hidden)
const onVis = () => {
pageVisibleRef.current = !document.hidden
}
pageVisibleRef.current = !document.hidden
document.addEventListener('visibilitychange', onVis)
return () => document.removeEventListener('visibilitychange', onVis)
}, [])
useEffect(() => {
return () => {
if (retryT.current) window.clearTimeout(retryT.current)
}
}, [])
// ✅ IntersectionObserver: setState nur bei tatsächlichem Wechsel
useEffect(() => {
const el = rootRef.current
if (!el) return
const obs = new IntersectionObserver(
(entries) => {
const entry = entries[0]
const next = Boolean(entry && (entry.isIntersecting || entry.intersectionRatio > 0))
if (next === inViewRef.current) return
inViewRef.current = next
setInView(next)
},
{
root: null,
threshold: 0,
rootMargin: '300px 0px',
}
)
obs.observe(el)
return () => obs.disconnect()
}, [])
// ✅ einmaliger Tick beim ersten Sichtbarwerden (nur wenn Parent nicht tickt)
useEffect(() => {
if (typeof thumbTick === 'number') return
if (!inView || !pageVisible) return
if (!inView) return
if (!pageVisibleRef.current) return
if (enteredViewOnce.current) return
enteredViewOnce.current = true
setLocalTick((x) => x + 1)
}, [inView, thumbTick, pageVisible])
}, [inView, thumbTick])
// ✅ lokales Ticken nur wenn nötig (kein Timer wenn Parent tickt / offscreen / tab hidden)
useEffect(() => {
// Wenn Parent tickt, kein lokales Ticken
if (typeof thumbTick === 'number') return
// Nur animieren, wenn im Sichtbereich UND Tab sichtbar
if (!inView || !pageVisible) return
if (!inView) return
if (!pageVisibleRef.current) return
const period = Number(alignEveryMs ?? autoTickMs ?? 10_000)
if (!Number.isFinite(period) || period <= 0) return
@ -99,11 +124,13 @@ export default function ModelPreview({
const startMs = alignStartAt ? toMs(alignStartAt) : NaN
const endMs = alignEndAt ? toMs(alignEndAt) : NaN
// 1) ✅ Aligned: tick genau auf Vielfachen von period seit startMs
// aligned schedule
if (Number.isFinite(startMs)) {
let t: number | undefined
const schedule = () => {
// ✅ wenn tab inzwischen hidden wurde, keine neuen timeouts schedulen
if (!pageVisibleRef.current) return
const now = Date.now()
if (Number.isFinite(endMs) && now >= endMs) return
@ -112,6 +139,9 @@ export default function ModelPreview({
const wait = rem === 0 ? period : period - rem
t = window.setTimeout(() => {
// ✅ nochmal checken, falls inzwischen offscreen/hidden
if (!inViewRef.current) return
if (!pageVisibleRef.current) return
setLocalTick((x) => x + 1)
schedule()
}, wait)
@ -123,62 +153,56 @@ export default function ModelPreview({
}
}
// 2) Fallback: normales Interval (nicht aligned)
// fallback interval
const id = window.setInterval(() => {
if (!inViewRef.current) return
if (!pageVisibleRef.current) return
setLocalTick((x) => x + 1)
}, period)
return () => window.clearInterval(id)
}, [thumbTick, autoTickMs, inView, pageVisible, alignStartAt, alignEndAt, alignEveryMs])
}, [thumbTick, autoTickMs, inView, alignStartAt, alignEndAt, alignEveryMs])
// ✅ tick Quelle
const rawTick = typeof thumbTick === 'number' ? thumbTick : localTick
// ✅ WICHTIG: Offscreen NICHT ständig src ändern (sonst trotzdem Requests!)
// Wir "freezen" den Tick, solange inView=false oder tab hidden
const frozenTickRef = useRef(0)
const [frozenTick, setFrozenTick] = useState(0)
useEffect(() => {
const el = rootRef.current
if (!el) return
if (!inView) return
if (!pageVisibleRef.current) return
frozenTickRef.current = rawTick
setFrozenTick(rawTick)
}, [rawTick, inView])
const obs = new IntersectionObserver(
(entries) => {
const entry = entries[0]
setInView(Boolean(entry && (entry.isIntersecting || entry.intersectionRatio > 0)))
},
{
root: null,
threshold: 0, // wichtiger: nicht 0.1
rootMargin: '300px 0px', // preload: 300px vor/nach Viewport
}
)
obs.observe(el)
return () => obs.disconnect()
}, [])
const tick = typeof thumbTick === 'number' ? thumbTick : localTick
// bei neuem Tick Error-Flag zurücksetzen (damit wir retries erlauben)
// bei neuem *sichtbaren* Tick Error-Flag zurücksetzen
useEffect(() => {
setImgError(false)
}, [tick])
}, [frozenTick])
useEffect(() => {
// bei Job-Wechsel alles sauber neu starten
// bei Job-Wechsel reset
hadSuccess.current = false
fastTries.current = 0
enteredViewOnce.current = false
setImgError(false)
setLocalTick((x) => x + 1) // sofort neuer Request
// ✅ sofort neuer Request, aber nur wenn wir auch wirklich sichtbar sind
if (inViewRef.current && pageVisibleRef.current) {
setLocalTick((x) => x + 1)
}
}, [jobId])
// Thumbnail mit Cache-Buster (?v=...)
const thumb = useMemo(
() => `/api/record/preview?id=${encodeURIComponent(jobId)}&v=${tick}`,
[jobId, tick]
() => `/api/record/preview?id=${encodeURIComponent(jobId)}&v=${frozenTick}`,
[jobId, frozenTick]
)
// HLS nur für große Vorschau im Popover
const hq = useMemo(
() =>
`/api/record/preview?id=${encodeURIComponent(jobId)}&hover=1&file=index_hq.m3u8`,
() => `/api/record/preview?id=${encodeURIComponent(jobId)}&hover=1&file=index_hq.m3u8`,
[jobId]
)
@ -188,15 +212,13 @@ export default function ModelPreview({
open && (
<div className="w-[420px] max-w-[calc(100vw-1.5rem)]">
<div className="relative aspect-video overflow-hidden rounded-lg bg-black">
<LiveHlsVideo src={hq} muted={false} className={['w-full h-full relative z-0'].filter(Boolean).join(' ')} />
<LiveHlsVideo src={hq} muted={false} className="w-full h-full relative z-0" />
{/* LIVE badge */}
<div className="absolute left-2 top-2 inline-flex items-center gap-1.5 rounded-full bg-red-600/90 px-2 py-1 text-[11px] font-semibold text-white shadow-sm">
<span className="inline-block size-1.5 rounded-full bg-white animate-pulse" />
Live
</div>
{/* Close */}
<button
type="button"
className="absolute right-2 top-2 z-20 inline-flex items-center justify-center rounded-md p-1.5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white/70 bg-white/75 text-gray-900 ring-1 ring-black/10 hover:bg-white/90 dark:bg-black/40 dark:text-white dark:ring-white/10 dark:hover:bg-black/55"
@ -226,7 +248,8 @@ export default function ModelPreview({
<img
src={thumb}
loading={inView ? 'eager' : 'lazy'}
fetchPriority={inView ? 'high' : 'auto'}
fetchPriority={inView ? 'high' : 'auto'}
decoding="async"
alt=""
className={['block w-full h-full object-cover object-center', blurCls].filter(Boolean).join(' ')}
onLoad={() => {
@ -238,9 +261,8 @@ export default function ModelPreview({
onError={() => {
setImgError(true)
// ✅ Fast-Retry nur wenn aktiviert & sinnvoll
if (!fastRetryMs) return
if (!inView || !pageVisible) return
if (!inViewRef.current || !pageVisibleRef.current) return
if (hadSuccess.current) return
const startMs = alignStartAt ? toMs(alignStartAt) : NaN
@ -254,7 +276,7 @@ export default function ModelPreview({
if (retryT.current) window.clearTimeout(retryT.current)
retryT.current = window.setTimeout(() => {
fastTries.current += 1
setLocalTick((x) => x + 1) // triggert neuen Request via ?v=
setLocalTick((x) => x + 1)
}, fastRetryMs)
}}
/>

View File

@ -216,7 +216,7 @@ export default function PerformanceMonitor({
{/* DISK */}
<div className="flex items-center gap-2" title={diskTitle}>
<span className="text-[11px] font-medium text-gray-600 dark:text-gray-300">Disk</span>
<div className="h-2 w-14 sm:w-16 rounded-full bg-gray-200/70 dark:bg-white/10 overflow-hidden">
<div className="hidden sm:block h-2 w-14 sm:w-16 rounded-full bg-gray-200/70 dark:bg-white/10 overflow-hidden">
<div
className={`h-full ${barTone(diskTone)}`}
style={{ width: `${Math.round(usedFill * 100)}%` }}
@ -230,7 +230,7 @@ export default function PerformanceMonitor({
{/* PING */}
<div className="flex items-center gap-2">
<span className="text-[11px] font-medium text-gray-600 dark:text-gray-300">Ping</span>
<div className="h-2 w-14 sm:w-16 rounded-full bg-gray-200/70 dark:bg-white/10 overflow-hidden">
<div className="hidden sm:block h-2 w-14 sm:w-16 rounded-full bg-gray-200/70 dark:bg-white/10 overflow-hidden">
<div
className={`h-full ${barTone(pingTone)}`}
style={{ width: `${Math.round(pingFill * 100)}%` }}
@ -244,7 +244,7 @@ export default function PerformanceMonitor({
{/* FPS */}
<div className="flex items-center gap-2">
<span className="text-[11px] font-medium text-gray-600 dark:text-gray-300">FPS</span>
<div className="h-2 w-14 sm:w-16 rounded-full bg-gray-200/70 dark:bg-white/10 overflow-hidden">
<div className="hidden sm:block h-2 w-14 sm:w-16 rounded-full bg-gray-200/70 dark:bg-white/10 overflow-hidden">
<div
className={`h-full ${barTone(fpsTone)}`}
style={{ width: `${Math.round(fpsFill * 100)}%` }}
@ -258,7 +258,7 @@ export default function PerformanceMonitor({
{/* CPU */}
<div className="flex items-center gap-2">
<span className="text-[11px] font-medium text-gray-600 dark:text-gray-300">CPU</span>
<div className="h-2 w-14 sm:w-16 rounded-full bg-gray-200/70 dark:bg-white/10 overflow-hidden">
<div className="hidden sm:block h-2 w-14 sm:w-16 rounded-full bg-gray-200/70 dark:bg-white/10 overflow-hidden">
<div
className={`h-full ${barTone(cpuTone)}`}
style={{ width: `${Math.round(cpuFill * 100)}%` }}

View File

@ -458,7 +458,12 @@ export type PlayerProps = {
// actions
onKeep?: (job: RecordJob) => void | Promise<void>
onDelete?: (job: RecordJob) => void | Promise<void>
onToggleHot?: (job: RecordJob) => void | Promise<void>
onToggleHot?: (
job: RecordJob
) =>
| void
| { ok?: boolean; oldFile?: string; newFile?: string }
| Promise<void | { ok?: boolean; oldFile?: string; newFile?: string }>
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
onToggleLike?: (job: RecordJob) => void | Promise<void>
onToggleWatch?: (job: RecordJob) => void | Promise<void>
@ -499,6 +504,24 @@ export default function Player({
const sizeLabel = React.useMemo(() => formatBytes(sizeBytesOf(job)), [job])
const anyJob = job as any
const [fullDurationSec, setFullDurationSec] = React.useState<number>(() => {
return (
Number((job as any)?.meta?.durationSeconds) ||
Number((job as any)?.durationSeconds) ||
0
)
})
const [metaReady, setMetaReady] = React.useState<boolean>(() => {
// live ist egal, finished: erst mal false (wir holen gleich)
return job.status === 'running'
})
const [metaDims, setMetaDims] = React.useState<{ h: number; fps: number | null }>(() => ({
h: 0,
fps: null,
}))
// ✅ Live nur, wenn es wirklich Preview/HLS-Assets gibt (nicht nur status==="running")
const isRunning = job.status === 'running'
const [hlsReady, setHlsReady] = React.useState(false)
@ -514,6 +537,48 @@ export default function Player({
[isRunning, job.id, finishedStem]
)
React.useEffect(() => {
if (isRunning) return
if (fullDurationSec > 0) return
const fileName = baseName(job.output?.trim() || '')
if (!fileName) return
let alive = true
const ctrl = new AbortController()
;(async () => {
try {
// ✅ Backend-Endpoint existiert bei dir bereits: /api/record/done/meta
// Ich gebe hier file mit, weil du finished oft darüber mapst.
const url = apiUrl(`/api/record/done/meta?file=${encodeURIComponent(fileName)}`)
const res = await apiFetch(url, { signal: ctrl.signal, cache: 'no-store' })
if (!res.ok) return
const j = await res.json()
const dur = Number(j?.durationSeconds || j?.meta?.durationSeconds || 0) || 0
if (!alive || dur <= 0) return
setFullDurationSec(dur)
// ✅ Video.js Duration-Shim nachträglich füttern + UI refreshen
const p: any = playerRef.current
if (p && !p.isDisposed?.()) {
try {
p.__fullDurationSec = dur
p.trigger?.('durationchange')
p.trigger?.('timeupdate')
} catch {}
}
} catch {}
})()
return () => {
alive = false
ctrl.abort()
}
}, [isRunning, fullDurationSec, job.output])
const isHotFile = fileRaw.startsWith('HOT ')
const model = React.useMemo(() => {
const k = (modelKey || '').trim()
@ -522,13 +587,9 @@ export default function Player({
const file = React.useMemo(() => stripHotPrefix(fileRaw), [fileRaw])
const runtimeLabel = React.useMemo(() => {
const sec =
Number((job as any)?.meta?.durationSeconds) ||
Number((job as any)?.durationSeconds) ||
0
const sec = Number(fullDurationSec || 0) || 0
return sec > 0 ? formatDuration(sec * 1000) : '—'
}, [job])
}, [fullDurationSec])
// Datum: bevorzugt aus Dateiname, sonst startedAt/endedAt/createdAt — ✅ inkl. Uhrzeit
const dateLabel = React.useMemo(() => {
@ -562,7 +623,7 @@ export default function Player({
)
const previewB = React.useMemo(
() => apiUrl(`/api/record/preview?id=${encodeURIComponent(previewId)}&file=thumbs.jpg`),
() => apiUrl(`/api/record/preview?id=${encodeURIComponent(previewId)}&file=thumbs.webp`),
[previewId]
)
@ -579,12 +640,13 @@ export default function Player({
}, [previewA])
const videoH = React.useMemo(
() => pickNum(anyJob.videoHeight, anyJob.height, anyJob.meta?.height),
[anyJob.videoHeight, anyJob.height, anyJob.meta?.height]
() => pickNum(metaDims.h, anyJob.videoHeight, anyJob.height, anyJob.meta?.height),
[metaDims.h, anyJob.videoHeight, anyJob.height, anyJob.meta?.height]
)
const fps = React.useMemo(
() => pickNum(anyJob.fps, anyJob.frameRate, anyJob.meta?.fps, anyJob.meta?.frameRate),
[anyJob.fps, anyJob.frameRate, anyJob.meta?.fps, anyJob.meta?.frameRate]
() => pickNum(metaDims.fps, anyJob.fps, anyJob.frameRate, anyJob.meta?.fps, anyJob.meta?.frameRate),
[metaDims.fps, anyJob.fps, anyJob.frameRate, anyJob.meta?.fps, anyJob.meta?.frameRate]
)
const [intrH, setIntrH] = React.useState<number | null>(null)
@ -709,7 +771,11 @@ export default function Player({
// ✅ Live wird NICHT mehr über Video.js gespielt
if (isRunning) return { src: '', type: '' }
// ✅ Warten bis meta.json existiert + Infos geladen
if (!metaReady) return { src: '', type: '' }
const file = baseName(job.output?.trim() || '')
if (file) {
const ext = file.toLowerCase().split('.').pop()
const type =
@ -718,7 +784,7 @@ export default function Player({
}
return { src: buildVideoSrc({ id: job.id, quality: appliedQuality }), type: 'video/mp4' }
}, [isRunning, job.output, job.id, appliedQuality, buildVideoSrc])
}, [isRunning, metaReady, job.output, job.id, appliedQuality, buildVideoSrc])
const containerRef = React.useRef<HTMLDivElement | null>(null)
const playerRef = React.useRef<VideoJsPlayer | null>(null)
@ -769,6 +835,76 @@ export default function Player({
}
}, [playbackKey, defaultQuality])
React.useEffect(() => {
if (isRunning) {
setMetaReady(true)
return
}
const fileName = baseName(job.output?.trim() || '')
if (!fileName) {
// wenn kein file → fail-open
setMetaReady(true)
return
}
let alive = true
const ctrl = new AbortController()
setMetaReady(false)
;(async () => {
// Poll bis metaExists=true (oder fail-open nach N Versuchen)
for (let i = 0; i < 80 && alive && !ctrl.signal.aborted; i++) {
try {
const url = apiUrl(`/api/record/done/meta?file=${encodeURIComponent(fileName)}`)
const res = await apiFetch(url, { signal: ctrl.signal, cache: 'no-store' })
if (res.ok) {
const j = await res.json()
const exists = Boolean(j?.metaExists)
const dur = Number(j?.durationSeconds || 0) || 0
const h = Number(j?.height || 0) || 0
const fps = Number(j?.fps || 0) || 0
// ✅ Infos neu in den Player-State übernehmen
if (dur > 0) {
setFullDurationSec(dur)
const p: any = playerRef.current
if (p && !p.isDisposed?.()) {
try {
p.__fullDurationSec = dur
p.trigger?.('durationchange')
p.trigger?.('timeupdate')
} catch {}
}
}
if (h > 0) {
setMetaDims({ h, fps: fps > 0 ? fps : null })
}
if (exists) {
setMetaReady(true)
return
}
}
} catch {}
await new Promise((r) => setTimeout(r, 250))
}
// fail-open (damit der Player nicht “für immer” blockiert)
if (alive) setMetaReady(true)
})()
return () => {
alive = false
ctrl.abort()
}
}, [isRunning, playbackKey, job.output])
// ✅ iOS Safari: visualViewport changes (address bar / bottom bar / keyboard) need a rerender
const [, setVvTick] = React.useState(0)
@ -874,11 +1010,7 @@ export default function Player({
startSec,
})
const knownFull =
Number((job as any)?.meta?.durationSeconds) ||
Number((anyJob as any)?.meta?.durationSeconds) ||
Number((job as any)?.durationSeconds) ||
0
const knownFull = Number(fullDurationSec || 0) || 0
if (knownFull > 0) p.__fullDurationSec = knownFull
try {
@ -924,7 +1056,7 @@ export default function Player({
p.load?.()
} catch {}
},
[isRunning, buildVideoSrc, updateIntrinsicDims, job, anyJob]
[isRunning, buildVideoSrc, updateIntrinsicDims, fullDurationSec]
)
// ✅ Gear-Auswahl: requestedQuality setzen, bei manual sofort umschalten
@ -1138,7 +1270,7 @@ export default function Player({
if (!fileName) return
// volle Dauer: nimm was du hast (durationSeconds ist bei finished normalerweise da)
const knownFull = Number((job as any).durationSeconds ?? anyJob?.meta?.durationSeconds ?? 0) || 0
const knownFull = Number(fullDurationSec || 0) || 0
if (knownFull > 0) p.__fullDurationSec = knownFull
// absolute server-seek
@ -1265,13 +1397,14 @@ export default function Player({
delete p.__serverSeekAbs
} catch {}
}
}, [playerReadyTick, job.output, isRunning, buildVideoSrc, updateIntrinsicDims, anyJob, job, videoH])
}, [playerReadyTick, job.output, isRunning, buildVideoSrc, updateIntrinsicDims, videoH])
React.useLayoutEffect(() => {
if (!mounted) return
if (!containerRef.current) return
if (playerRef.current) return
if (isRunning) return // ✅ neu: für Live keinen Video.js mounten
if (!metaReady) return
const videoEl = document.createElement('video')
videoEl.className = 'video-js vjs-big-play-centered w-full h-full'
@ -1390,7 +1523,7 @@ export default function Player({
}
}
}
}, [mounted, startMuted, isRunning, videoH, updateIntrinsicDims])
}, [mounted, startMuted, isRunning, metaReady, videoH, updateIntrinsicDims])
React.useEffect(() => {
const p = playerRef.current
@ -1402,8 +1535,27 @@ export default function Player({
el.classList.toggle('is-live-download', Boolean(isLive))
}, [isLive])
const releaseMedia = React.useCallback(() => {
const p = playerRef.current
if (!p || (p as any).isDisposed?.()) return
try {
p.pause()
;(p as any).reset?.()
} catch {}
try {
p.src({ src: '', type: 'video/mp4' } as any)
;(p as any).load?.()
} catch {}
}, [])
React.useEffect(() => {
if (!mounted) return
if (!isRunning && !metaReady) {
releaseMedia()
return
}
const p = playerRef.current
if (!p || (p as any).isDisposed?.()) return
@ -1429,9 +1581,18 @@ export default function Player({
;(p as any).__timeOffsetSec = 0
// volle Dauer kennen wir bei finished meistens schon:
const knownFull = Number((job as any).durationSeconds ?? (job as any).meta?.durationSeconds ?? 0) || 0
const knownFull = Number(fullDurationSec || 0) || 0
;(p as any).__fullDurationSec = knownFull
// ✅ NICHT neu setzen, wenn Source identisch ist (verhindert "cancelled" durch unnötige Reloads)
const curSrc = String((p as any).currentSrc?.() || '')
if (curSrc && curSrc === media.src) {
// trotzdem versuchen zu spielen (z.B. wenn nur muted/state geändert wurde)
const ret = p.play?.()
if (ret && typeof (ret as any).catch === 'function') (ret as Promise<void>).catch(() => {})
return
}
p.src({ src: media.src, type: media.type })
const tryPlay = () => {
@ -1447,7 +1608,7 @@ export default function Player({
// ✅ volle Dauer: aus bekannten Daten (nicht aus p.duration())
try {
const knownFull = Number((job as any).durationSeconds ?? anyJob?.meta?.durationSeconds ?? 0) || 0
const knownFull = Number(fullDurationSec || 0) || 0
if (knownFull > 0) (p as any).__fullDurationSec = knownFull
} catch {}
@ -1474,7 +1635,7 @@ export default function Player({
})
tryPlay()
}, [mounted, media.src, media.type, startMuted, updateIntrinsicDims, job, anyJob])
}, [mounted, isRunning, metaReady, media.src, media.type, startMuted, updateIntrinsicDims, fullDurationSec, releaseMedia])
React.useEffect(() => {
if (!mounted) return
@ -1499,21 +1660,6 @@ export default function Player({
queueMicrotask(() => p.trigger('resize'))
}, [expanded])
const releaseMedia = React.useCallback(() => {
const p = playerRef.current
if (!p || (p as any).isDisposed?.()) return
try {
p.pause()
;(p as any).reset?.()
} catch {}
try {
p.src({ src: '', type: 'video/mp4' } as any)
;(p as any).load?.()
} catch {}
}, [])
React.useEffect(() => {
const onRelease = (ev: Event) => {
const detail = (ev as CustomEvent<{ file?: string }>).detail

View File

@ -1,11 +1,13 @@
// frontend\src\components\ui\RecorderSettings.tsx
'use client'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import Button from './Button'
import Card from './Card'
import LabeledSwitch from './LabeledSwitch'
import GenerateAssetsTask from './GenerateAssetsTask'
import TaskList from './TaskList'
import type { TaskItem } from './TaskList'
type RecorderSettings = {
recordDir: string
@ -62,6 +64,26 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
const [msg, setMsg] = useState<string | null>(null)
const [err, setErr] = useState<string | null>(null)
const [diskStatus, setDiskStatus] = useState<DiskStatus | null>(null)
// ✅ Tasklist (Assets generieren)
const assetsAbortRef = useRef<AbortController | null>(null)
const [assetsTask, setAssetsTask] = useState<TaskItem>({
id: 'generate-assets',
status: 'idle',
title: 'Assets generieren', // oder '' wenn du es komplett weg willst
text: '',
cancellable: true,
fading: false,
})
const [cleanupTask, setCleanupTask] = useState<TaskItem>({
id: 'cleanup',
status: 'idle',
title: 'Aufräumen',
text: '',
cancellable: false,
fading: false,
})
const pauseGB = Number(value.lowDiskPauseBelowGB ?? DEFAULTS.lowDiskPauseBelowGB ?? 5)
const uiPauseGB = diskStatus?.pauseGB ?? pauseGB
@ -225,6 +247,15 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
}
}
function fadeOutTask(setter: React.Dispatch<React.SetStateAction<TaskItem>>, delayMs = 3500, fadeMs = 500) {
window.setTimeout(() => {
setter((t) => ({ ...t, fading: true }))
window.setTimeout(() => {
setter((t) => ({ ...t, status: 'idle', text: '', err: undefined, done: 0, total: 0, fading: false }))
}, fadeMs)
}, delayMs)
}
async function cleanupSmallDone() {
setErr(null)
setMsg(null)
@ -236,7 +267,6 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
setErr('doneDir ist leer.')
return
}
if (!mb || mb <= 0) {
setErr('Mindestgröße ist 0 es würde nichts gelöscht.')
return
@ -244,13 +274,25 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
const ok = window.confirm(
`Aufräumen:\n` +
`• Löscht Dateien in "${doneDir}" < ${mb} MB (Ordner "keep" wird übersprungen)\n` +
`• Entfernt verwaiste Previews/Thumbs/Generated-Assets ohne passende Datei\n\n` +
`Fortfahren?`
`• Löscht Dateien in "${doneDir}" < ${mb} MB (Ordner "keep" wird übersprungen)\n` +
`• Entfernt verwaiste Previews/Thumbs/Generated-Assets ohne passende Datei\n\n` +
`Fortfahren?`
)
if (!ok) return
// ✅ Task starten (als letzter Eintrag in TaskList)
setCleaning(true)
setCleanupTask((t) => ({
...t,
status: 'running',
title: 'Aufräumen', // ✅ HINZUFÜGEN (reset)
text: 'Räume auf…',
err: undefined,
done: 0,
total: 1,
fading: false,
}))
try {
const res = await fetch('/api/settings/cleanup', {
method: 'POST',
@ -264,19 +306,60 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
const data = await res.json()
setMsg(
`🧹 Aufräumen fertig:\n` +
`• Gelöscht: ${data.deletedFiles} Datei(en) (${data.deletedBytesHuman})\n` +
`• Geprüft: ${data.scannedFiles} · Übersprungen: ${data.skippedFiles} · Fehler: ${data.errorCount}\n` +
`• Orphans: ${data.orphanIdsRemoved}/${data.orphanIdsScanned} entfernt (Previews/Thumbs/Generated)`
)
const scannedFiles = Number(data.scannedFiles ?? 0)
const orphanRemoved = Number(data.orphanIdsRemoved ?? 0)
const genRemoved = Number(data.generatedOrphansRemoved ?? 0)
const orphansTotalRemoved = orphanRemoved + genRemoved
setCleanupTask((t) => ({
...t,
status: 'done',
done: 1,
total: 1,
title: 'Aufräumen',
text: `geprüft: ${scannedFiles} · Orphans: ${orphansTotalRemoved}`,
}))
fadeOutTask(setCleanupTask) // ✅ nach und nach ausfaden
} catch (e: any) {
setErr(e?.message ?? String(e))
const msg = e?.message ?? String(e)
setErr(msg)
setCleanupTask((t) => ({
...t,
status: 'error',
text: 'Fehler beim Aufräumen.',
err: msg,
}))
fadeOutTask(setCleanupTask)
} finally {
setCleaning(false)
}
}
async function cancelAssetsTask() {
const ac = assetsAbortRef.current
assetsAbortRef.current = null
// UI sofort
setAssetsTask((t: TaskItem) => ({ ...t, status: 'cancelled', text: 'Abgebrochen.' }))
if (ac) {
ac.abort()
return
}
// Fallback (z.B. nach Reload, wenn kein Controller existiert):
try {
await fetch('/api/tasks/generate-assets', { method: 'DELETE', cache: 'no-store' as any })
} catch {
// ignore
}
}
return (
<Card
header={
@ -295,6 +378,13 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
grayBody
>
<div className="space-y-4">
<TaskList
tasks={[assetsTask, cleanupTask]} // ✅ cleanupTask ist “am Ende”
onCancel={(id: string) => {
if (id === 'generate-assets') cancelAssetsTask()
}}
/>
{/* Alerts */}
{err && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200">
@ -307,25 +397,78 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
</div>
)}
{/* ✅ Tasks (als erstes) */}
{/* Aufgaben */}
<div className="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-950/40">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Tasks</div>
<div className="text-sm font-semibold text-gray-900 dark:text-white">Aufgaben</div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
Generiere fehlende Vorschauen/Metadaten für schnelle Listenansichten.
Hintergrundaufgaben wie z.B. Asset/Preview-Generierung.
</div>
</div>
<div className="shrink-0">
<span className="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-[11px] font-medium text-gray-700 dark:bg-white/10 dark:text-gray-200">
Utilities
</span>
</div>
</div>
<div className="mt-3">
<GenerateAssetsTask onFinished={onAssetsGenerated} />
<div className="mt-3 space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<GenerateAssetsTask
onFinished={onAssetsGenerated}
onStart={(ac) => {
assetsAbortRef.current = ac
setAssetsTask((t: TaskItem) => ({
...t,
status: 'running',
text: '',
done: 0,
total: 0,
err: undefined,
fading: false,
}))
}}
onProgress={(p) => {
setAssetsTask((t: TaskItem) => ({
...t,
status: 'running',
text: '',
done: p.done,
total: p.total,
}))
}}
onDone={() => {
assetsAbortRef.current = null
setAssetsTask((t: TaskItem) => ({ ...t, status: 'done' }))
fadeOutTask(setAssetsTask)
}}
onCancelled={() => {
assetsAbortRef.current = null
setAssetsTask((t: TaskItem) => ({ ...t, status: 'cancelled', text: 'Abgebrochen.' }))
fadeOutTask(setAssetsTask)
}}
onError={(message) => {
assetsAbortRef.current = null
setAssetsTask((t: TaskItem) => ({
...t,
status: 'error',
text: 'Fehler beim Generieren.',
err: message,
}))
fadeOutTask(setAssetsTask)
}}
/>
</div>
<div className="shrink-0 flex items-center gap-2">
<Button
variant="secondary"
onClick={cleanupSmallDone}
disabled={saving || cleaning || !value.autoDeleteSmallDownloads}
className="h-9 px-3"
title="Löscht Dateien im doneDir kleiner als die Mindestgröße (keep wird übersprungen)"
>
{cleaning ? '…' : 'Aufräumen'}
</Button>
</div>
</div>
</div>
</div>
@ -474,7 +617,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
</div>
<div className="sm:col-span-8">
<div className="flex items-center gap-2">
<div className="flex items-center justify-end gap-2">
<input
type="number"
min={0}
@ -486,21 +629,11 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
autoDeleteSmallDownloadsBelowMB: Number(e.target.value || 0),
}))
}
className="h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 shadow-sm
className="h-9 w-32 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 shadow-sm
dark:border-white/10 dark:bg-gray-900 dark:text-gray-100"
/>
<span className="shrink-0 text-xs text-gray-600 dark:text-gray-300">MB</span>
<Button
variant="secondary"
onClick={cleanupSmallDone}
disabled={saving || cleaning || !value.autoDeleteSmallDownloads}
className="h-9 shrink-0 px-3"
title="Löscht Dateien im doneDir kleiner als die Mindestgröße (keep wird übersprungen)"
>
{cleaning ? '…' : 'Aufräumen'}
</Button>
</div>
</div>
</div>

View File

@ -4,11 +4,35 @@
import * as React from 'react'
import { TrashIcon, BookmarkSquareIcon } from '@heroicons/react/24/outline'
import { FireIcon as FireSolidIcon } from '@heroicons/react/24/solid'
import { createRoot } from 'react-dom/client'
function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ')
}
function getGlobalFxLayer(): HTMLDivElement | null {
if (typeof document === 'undefined') return null
const ID = '__swipecard_hot_fx_layer__'
let el = document.getElementById(ID) as HTMLDivElement | null
if (!el) {
el = document.createElement('div')
el.id = ID
el.style.position = 'fixed'
el.style.inset = '0'
el.style.pointerEvents = 'none'
el.style.zIndex = '2147483647'
// optional, aber nice:
;(el.style as any).contain = 'layout style paint'
document.body.appendChild(el)
}
return el
}
export type SwipeAction = {
label: React.ReactNode
className?: string
@ -96,9 +120,9 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
className,
leftAction = {
label: (
<span className="inline-flex flex-col items-center gap-1 font-semibold leading-tight">
<BookmarkSquareIcon className="h-6 w-6" aria-hidden="true" />
<span>Behalten</span>
<span className="inline-flex items-center gap-2 font-semibold">
<BookmarkSquareIcon className="h-6 w-6" />
<span className="hidden sm:inline">Behalten</span>
</span>
),
className: 'bg-emerald-500/20 text-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-300',
@ -112,10 +136,8 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
),
className: 'bg-red-500/20 text-red-800 dark:bg-red-500/15 dark:text-red-300',
},
//thresholdPx = 120,
thresholdPx = 180,
//thresholdRatio = 0.35,
thresholdRatio = 0.1,
thresholdPx = 140,
thresholdRatio = 0.28,
ignoreFromBottomPx = 72,
ignoreSelector = '[data-swipe-ignore]',
snapMs = 180,
@ -144,8 +166,6 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
const tapTimerRef = React.useRef<number | null>(null)
const lastTapRef = React.useRef<{ t: number; x: number; y: number } | null>(null)
const fxLayerRef = React.useRef<HTMLDivElement | null>(null)
const pointer = React.useRef<{
id: number | null
x: number
@ -153,7 +173,8 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
dragging: boolean
captured: boolean
tapIgnored: boolean
}>({ id: null, x: 0, y: 0, dragging: false, captured: false, tapIgnored: false })
noSwipe: boolean
}>({ id: null, x: 0, y: 0, dragging: false, captured: false, tapIgnored: false, noSwipe: false })
const [dx, setDx] = React.useState(0)
const [armedDir, setArmedDir] = React.useState<null | 'left' | 'right'>(null)
@ -246,34 +267,25 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
const card = cardRef.current
if (!outer || !card) return
const layer = fxLayerRef.current
const layer = getGlobalFxLayer()
if (!layer) return
const outerRect = outer.getBoundingClientRect()
// ✅ Start im Viewport (da wo getippt wurde, fallback: Mitte)
let startX = typeof clientX === 'number' ? clientX : window.innerWidth / 2
let startY = typeof clientY === 'number' ? clientY : window.innerHeight / 2
// ✅ Start: da wo getippt wurde (fallback: Mitte)
let startX = outerRect.width / 2
let startY = outerRect.height / 2
if (typeof clientX === 'number' && typeof clientY === 'number') {
startX = clientX - outerRect.left
startY = clientY - outerRect.top
// optional: innerhalb der Card halten
startX = Math.max(0, Math.min(outerRect.width, startX))
startY = Math.max(0, Math.min(outerRect.height, startY))
}
// Ziel: HOT Button (falls gefunden)
// Ziel: HOT Button (falls gefunden) ebenfalls im Viewport
const targetEl = hotTargetSelector
? (card.querySelector(hotTargetSelector) as HTMLElement | null)
? ((outerRef.current?.querySelector(hotTargetSelector) as HTMLElement | null) ??
(card.querySelector(hotTargetSelector) as HTMLElement | null))
: null
let endX = startX
let endY = startY
if (targetEl) {
const tr = targetEl.getBoundingClientRect()
endX = tr.left - outerRect.left + tr.width / 2
endY = tr.top - outerRect.top + tr.height / 2
endX = tr.left + tr.width / 2
endY = tr.top + tr.height / 2
}
const dx = endX - startX
@ -281,17 +293,33 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
// Flame node
const flame = document.createElement('div')
flame.textContent = '🔥'
flame.style.position = 'absolute'
flame.style.left = `${startX}px`
flame.style.top = `${startY}px`
flame.style.transform = 'translate(-50%, -50%)'
flame.style.fontSize = '30px'
flame.style.filter = 'drop-shadow(0 10px 16px rgba(0,0,0,0.22))'
flame.style.pointerEvents = 'none'
flame.style.willChange = 'transform, opacity'
flame.style.zIndex = '2147483647'
flame.style.lineHeight = '1'
flame.style.userSelect = 'none'
flame.style.filter = 'drop-shadow(0 10px 16px rgba(0,0,0,0.22))'
// ✅ Heroicon per React-Root rendern (Client-sicher)
const inner = document.createElement('div')
inner.style.width = '30px'
inner.style.height = '30px'
inner.style.color = '#f59e0b' // amber
// optional: damit SVG nicht “inline” komisch sitzt
inner.style.display = 'block'
flame.appendChild(inner)
layer.appendChild(flame)
const root = createRoot(inner)
root.render(<FireSolidIcon className="w-full h-full" aria-hidden="true" />)
void flame.getBoundingClientRect()
// ✅ Timing: Pop (200ms) + Hold (500ms) + Fly (400ms) = 1100ms
const popMs = 200
const holdMs = 500
@ -346,7 +374,12 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
}, popMs + holdMs + Math.round(flyMs * 0.75))
}
anim.onfinish = () => flame.remove()
anim.onfinish = () => {
try {
root.unmount()
} catch {}
flame.remove()
}
},
[hotTargetSelector]
)
@ -382,25 +415,27 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
)}
/>
<div className={cn('absolute inset-0 flex items-center transition-all duration-200 ease-out')}
<div
className="absolute inset-0 flex items-center"
style={{
transform: `translateX(${Math.max(-24, Math.min(24, dx / 8))}px)`,
opacity: dx === 0 ? 0 : 1,
justifyContent: dx > 0 ? 'flex-start' : 'flex-end',
paddingLeft: dx > 0 ? 16 : 0,
paddingRight: dx > 0 ? 0 : 16,
}}
>
{dx > 0 ? leftAction.label : rightAction.label}
<div
style={{
transform: armedDir ? 'scale(1.05)' : 'scale(1)',
transition: 'transform 180ms ease, opacity 180ms ease',
opacity: armedDir ? 1 : 0.9,
}}
>
{dx > 0 ? leftAction.label : rightAction.label}
</div>
</div>
</div>
{/* FX Layer (Flame) */}
<div
ref={fxLayerRef}
className="pointer-events-none absolute inset-0 z-50"
/>
{/* Foreground (moves) */}
<div
ref={cardRef}
@ -411,16 +446,23 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
transition: animMs ? `transform ${animMs}ms ease` : undefined,
touchAction: 'pan-y',
willChange: dx !== 0 ? 'transform' : undefined,
boxShadow: dx !== 0 ? '0 10px 24px rgba(0,0,0,0.18)' : undefined,
borderRadius: dx !== 0 ? '12px' : undefined,
}}
onPointerDown={(e) => {
if (!enabled || disabled) return
// ✅ 1) Ignoriere Start auf "No-swipe"-Elementen
const target = e.target as HTMLElement | null
const tapIgnored = Boolean(tapIgnoreSelector && target?.closest?.(tapIgnoreSelector))
// Tap ignorieren (SingleTap nicht auslösen), DoubleTap soll aber weiter gehen
let tapIgnored = Boolean(tapIgnoreSelector && target?.closest?.(tapIgnoreSelector))
// Harte Ignore-Zone: da wollen wir wirklich gar nichts (wie vorher)
if (ignoreSelector && target?.closest?.(ignoreSelector)) return
// NEW: wenn true -> wir lassen Tap/DoubleTap zu, aber starten niemals Swipe/Drag
let noSwipe = false
const root = e.currentTarget as HTMLElement
const videos = Array.from(root.querySelectorAll('video')) as HTMLVideoElement[]
const ctlVideo = videos.find((v) => v.controls)
@ -435,25 +477,34 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
e.clientY <= vr.bottom
if (inVideo) {
// unten frei für Timeline/Scrub (iPhone braucht meist etwas mehr)
// unten frei für Timeline/Scrub
const fromBottomVideo = vr.bottom - e.clientY
const scrubZonePx = 72
if (fromBottomVideo <= scrubZonePx) return
if (fromBottomVideo <= scrubZonePx) {
noSwipe = true
tapIgnored = true // SingleTap nicht in unsere Logik -> Video controls sollen gewinnen
} else {
// Swipe nur aus den Seitenrändern
const edgeZonePx = 64
const xFromLeft = e.clientX - vr.left
const xFromRight = vr.right - e.clientX
const inEdge = xFromLeft <= edgeZonePx || xFromRight <= edgeZonePx
// Swipe nur aus den Seitenrändern
const edgeZonePx = 64
const xFromLeft = e.clientX - vr.left
const xFromRight = vr.right - e.clientX
const inEdge = xFromLeft <= edgeZonePx || xFromRight <= edgeZonePx
if (!inEdge) return
if (!inEdge) {
noSwipe = true
tapIgnored = true // Video-Interaktion nicht stören
}
}
}
}
// ✅ 3) Optional: generelle Card-Bottom-Sperre (bei dir in CardsView auf 0 lassen)
// Generelle Card-Bottom-Sperre: Swipe verhindern, aber Tap darf bleiben
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const fromBottom = rect.bottom - e.clientY
if (ignoreFromBottomPx && fromBottom <= ignoreFromBottomPx) return
if (ignoreFromBottomPx && fromBottom <= ignoreFromBottomPx) {
noSwipe = true
// tapIgnored NICHT automatisch setzen Footer-Taps sollen funktionieren
}
pointer.current = {
id: e.pointerId,
@ -461,20 +512,20 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
y: e.clientY,
dragging: false,
captured: false,
tapIgnored, // ✅ WICHTIG: nicht "false"
tapIgnored,
noSwipe,
}
// ✅ Perf: pro Gesture einmal Threshold berechnen
const el = cardRef.current
const w = el?.offsetWidth || 360
thresholdRef.current = Math.min(thresholdPx, w * thresholdRatio)
// ✅ dxRef reset (neue Gesture)
dxRef.current = 0
}}
onPointerMove={(e) => {
if (!enabled || disabled) return
if (pointer.current.id !== e.pointerId) return
if (pointer.current.noSwipe) return
const ddx = e.clientX - pointer.current.x
const ddy = e.clientY - pointer.current.y
@ -508,8 +559,17 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
}
}
// ✅ dx nur pro Frame in React-State schreiben
dxRef.current = ddx
const applyResistance = (x: number, w: number) => {
const limit = w * 0.9
const ax = Math.abs(x)
if (ax <= limit) return x
const extra = ax - limit
const resisted = limit + extra * 0.25
return Math.sign(x) * resisted
}
const w = cardRef.current?.offsetWidth || 360
dxRef.current = applyResistance(ddx, w)
if (rafRef.current == null) {
rafRef.current = requestAnimationFrame(() => {
@ -521,7 +581,14 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
// ✅ armedDir nur updaten wenn geändert
const threshold = thresholdRef.current
const nextDir = ddx > threshold ? 'right' : ddx < -threshold ? 'left' : null
setArmedDir((prev) => (prev === nextDir ? prev : nextDir))
setArmedDir((prev) => {
if (prev === nextDir) return prev
// mini feedback beim “arming”
if (nextDir) {
try { navigator.vibrate?.(10) } catch {}
}
return nextDir
})
}}
onPointerUp={(e) => {
if (!enabled || disabled) return
@ -547,16 +614,8 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
}
;(e.currentTarget as HTMLElement).style.touchAction = 'pan-y'
if (!wasDragging) {
// Tap auf Video/Controls => NICHT anfassen
if (wasTapIgnored) {
setAnimMs(0)
setDx(0)
setArmedDir(null)
return
}
if (!wasDragging) {
const now = Date.now()
const last = lastTapRef.current
@ -578,23 +637,37 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
if (doubleTapBusyRef.current) return
doubleTapBusyRef.current = true
// ✅ FX sofort starten (ohne irgendwas am Video zu resetten)
// ✅ FX sofort anlegen
try {
runHotFx(e.clientX, e.clientY)
} catch (err) {
// optional zum Debuggen:
// console.error('runHotFx failed', err)
}
// ✅ Toggle erst NACH dem nächsten Paint-Frame starten
requestAnimationFrame(() => {
try {
runHotFx(e.clientX, e.clientY)
} catch {}
;(async () => {
try {
await onDoubleTap?.()
} finally {
doubleTapBusyRef.current = false
}
})()
})
;(async () => {
try {
await onDoubleTap?.()
} catch {
// optional: error feedback
} finally {
doubleTapBusyRef.current = false
}
})()
return
}
// ✅ nur SingleTap "blocken" wenn tapIgnored DoubleTap bleibt möglich
if (wasTapIgnored) {
// wichtig: lastTapRef trotzdem setzen, damit DoubleTap beim 2. Tap klappt
lastTapRef.current = { t: now, x: e.clientX, y: e.clientY }
clearTapTimer()
tapTimerRef.current = window.setTimeout(() => {
tapTimerRef.current = null
lastTapRef.current = null
}, onDoubleTap ? doubleTapMs : 0)
return
}
@ -612,18 +685,6 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
}, onDoubleTap ? doubleTapMs : 0)
return
// kein Double: SingleTap erst nach Delay auslösen
lastTapRef.current = { t: now, x: e.clientX, y: e.clientY }
clearTapTimer()
tapTimerRef.current = window.setTimeout(() => {
tapTimerRef.current = null
lastTapRef.current = null
onTap?.()
}, onDoubleTap ? doubleTapMs : 0)
return
}
const finalDx = dxRef.current
@ -651,7 +712,7 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
;(e.currentTarget as HTMLElement).releasePointerCapture(pointer.current.id)
} catch {}
}
pointer.current = { id: null, x: 0, y: 0, dragging: false, captured: false, tapIgnored: false }
pointer.current = { id: null, x: 0, y: 0, dragging: false, captured: false, tapIgnored: false, noSwipe: false }
if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current)
rafRef.current = null

View File

@ -1,3 +1,5 @@
// frontend\src\components\ui\Tabs.tsx
'use client'
import * as React from 'react'
@ -237,10 +239,13 @@ export default function Tabs({
disabled && 'cursor-not-allowed opacity-50 hover:bg-transparent hover:text-gray-500 dark:hover:text-gray-400'
)}
>
<span className="inline-flex items-center justify-center">
{tab.label}
<span className="inline-flex max-w-full min-w-0 items-center justify-center">
<span className="min-w-0 truncate whitespace-nowrap" title={tab.label}>
{tab.label}
</span>
{tab.count !== undefined ? (
<span className="ml-2 rounded-full bg-white/70 px-2 py-0.5 text-xs font-medium text-gray-900 dark:bg-white/10 dark:text-white">
<span className="ml-2 shrink-0 tabular-nums min-w-[2.25rem] rounded-full bg-white/70 px-2 py-0.5 text-center text-xs font-medium text-gray-900 dark:bg-white/10 dark:text-white">
{tab.count}
</span>
) : null}

View File

@ -1,3 +1,5 @@
// frontend\src\components\ui\TagBadge.tsx
'use client'
import * as React from 'react'
@ -40,7 +42,7 @@ export default function TagBadge({
// Styling: Basis wie in ModelsTab
const base = clsx(
'inline-flex items-center truncate rounded-md px-2 py-0.5 text-xs',
'inline-flex shrink-0 items-center truncate rounded-md px-2 py-0.5 text-xs',
maxWidthClassName,
'bg-sky-50 text-sky-700 dark:bg-sky-500/10 dark:text-sky-200'
)

View File

@ -0,0 +1,265 @@
// frontend\src\components\ui\TagOverflowRow.tsx
'use client'
import * as React from 'react'
import TagBadge from './TagBadge'
type Props = {
rowKey: string
tags: string[]
activeTagSet: Set<string>
lower: (s: string) => string
onToggleTagFilter: (tag: string) => void
maxVisible?: number
maxWidthClassName?: string
gapPx?: number
className?: string
}
export default function TagOverflowRow({
rowKey,
tags,
activeTagSet,
lower,
onToggleTagFilter,
maxVisible,
maxWidthClassName,
gapPx = 6,
className,
}: Props) {
const [open, setOpen] = React.useState(false)
// ✅ misst die komplette Zeile (Tags + Button)
const rowWrapRef = React.useRef<HTMLDivElement | null>(null)
// ✅ hidden “all tags” measurement
const measureRef = React.useRef<HTMLDivElement | null>(null)
// ✅ worst-case +X measurement
const plusMeasureRef = React.useRef<HTMLButtonElement | null>(null)
React.useEffect(() => setOpen(false), [rowKey])
React.useEffect(() => {
if (!open) return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [open])
const cap = React.useMemo(() => {
return typeof maxVisible === 'number' && maxVisible > 0 ? maxVisible : Number.POSITIVE_INFINITY
}, [maxVisible])
const [visibleCount, setVisibleCount] = React.useState<number>(() => {
const total = Math.min(tags.length, cap)
return Math.min(total, 6) // optional initial guess; recalc korrigiert direkt
})
const recalc = React.useCallback(() => {
const rowWrap = rowWrapRef.current
const measure = measureRef.current
if (!rowWrap || !measure) return
const rowW = Math.floor(rowWrap.getBoundingClientRect().width)
if (rowW <= 0) return
const totalTags = Math.min(tags.length, cap)
if (totalTags <= 0) {
setVisibleCount(0)
return
}
// Breiten der TagBadges (aus hidden measure row)
const widths = Array.from(measure.children).map((el) => (el as HTMLElement).offsetWidth)
const widthsCapped = widths.slice(0, totalTags)
const plusW = plusMeasureRef.current?.offsetWidth ?? 0
const EPS = 2 // ✅ verhindert “letzter Tag halb abgeschnitten”
const fitCount = (availableW: number) => {
let used = 0
let count = 0
for (let i = 0; i < widthsCapped.length; i++) {
const w = widthsCapped[i] + (count > 0 ? gapPx : 0)
if (used + w <= availableW - EPS) {
used += w
count++
} else {
break
}
}
return count
}
// 1) Ohne +X versuchen
let count = fitCount(rowW)
// 2) Wenn Overflow: Platz für +X reservieren und nochmal fitten
if (count < totalTags) {
const reserved = plusW > 0 ? plusW + gapPx : 0
const availableForTags = Math.max(0, rowW - reserved)
count = fitCount(availableForTags)
// Safety: wenn sogar 1 Tag + Button nicht passt, mindestens 0 Tags zulassen
count = Math.max(0, count)
}
setVisibleCount(Math.max(0, Math.min(count, totalTags)))
}, [tags, cap, gapPx])
React.useLayoutEffect(() => {
recalc()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [recalc, tags.join('\u0000'), maxWidthClassName, cap])
React.useEffect(() => {
const el = rowWrapRef.current
if (!el) return
const ro = new ResizeObserver(() => recalc())
ro.observe(el)
return () => ro.disconnect()
}, [recalc])
const totalTags = Math.min(tags.length, cap)
const visibleTags = tags.slice(0, Math.min(visibleCount, totalTags))
const stop = (e: React.SyntheticEvent) => e.stopPropagation()
// ✅ Hinweis: auch wenn cap < tags.length, zählt "rest" nur gegen totalTags (capped)
// willst du immer gegen alle tags: rest = tags.length - visibleTags.length
const restAll = tags.length - visibleTags.length
return (
<>
{/* collapsed row (in footer) */}
{!open ? (
<div
ref={rowWrapRef}
className={['mt-2 h-6 flex items-center gap-1.5', className].filter(Boolean).join(' ')}
onClick={stop}
onMouseDown={stop}
onPointerDown={stop}
>
<div className="min-w-0 flex-1 overflow-hidden">
<div className="flex flex-nowrap items-center gap-1.5">
{visibleTags.length > 0 ? (
visibleTags.map((t) => (
<TagBadge
key={t}
tag={t}
active={activeTagSet.has(lower(t))}
onClick={onToggleTagFilter}
maxWidthClassName={maxWidthClassName}
/>
))
) : (
<span className="text-xs text-gray-400 dark:text-gray-500"></span>
)}
</div>
</div>
{restAll > 0 ? (
<button
type="button"
className={[
// TagBadge-like sizing + shape
'inline-flex shrink-0 items-center truncate rounded-md px-2 py-0.5 text-xs',
// TagBadge-like focus behavior
'cursor-pointer focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500',
// neutral colors (damit es sich als “Control” abhebt)
'bg-gray-100 text-gray-700 hover:bg-gray-200/70',
'dark:bg-white/10 dark:text-gray-200 dark:hover:bg-white/20',
].join(' ')}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setOpen(true)
}}
title="Alle Tags anzeigen"
aria-haspopup="dialog"
aria-expanded={false}
>
+{restAll}
</button>
) : null}
</div>
) : null}
{/* overlay that covers the whole footer host */}
{open ? (
<div
className={[
'absolute inset-0 z-30',
'bg-white/60 dark:bg-gray-950',
'px-3 py-3', // etwas weniger padding -> mehr Platz
'pointer-events-auto',
].join(' ')}
onClick={stop}
onMouseDown={stop}
onPointerDown={stop}
role="dialog"
aria-label="Tags"
>
{/* Close nur als Icon oben rechts */}
<button
type="button"
className="
absolute right-2 top-2 z-10
rounded-md bg-gray-100/80 px-2 py-1 text-xs font-semibold text-gray-700
hover:bg-gray-200/80
dark:bg-white/10 dark:text-gray-200 dark:hover:bg-white/20
"
onClick={() => setOpen(false)}
aria-label="Schließen"
title="Schließen"
>
</button>
{/* volle Fläche für Tags */}
<div className="h-full overflow-auto pr-1">
<div className="flex flex-wrap gap-1.5">
{tags.map((t) => (
<TagBadge
key={t}
tag={t}
active={activeTagSet.has(lower(t))}
onClick={onToggleTagFilter}
maxWidthClassName={maxWidthClassName}
/>
))}
</div>
</div>
</div>
) : null}
{/* hidden measure area */}
<div className="absolute -left-[9999px] -top-[9999px] opacity-0 pointer-events-none">
<div ref={measureRef} className="flex flex-nowrap items-center gap-1.5">
{tags.slice(0, totalTags).map((t) => (
<TagBadge
key={t}
tag={t}
active={activeTagSet.has(lower(t))}
onClick={onToggleTagFilter}
maxWidthClassName={maxWidthClassName}
/>
))}
</div>
<button
ref={plusMeasureRef}
type="button"
className="inline-flex items-center rounded-md bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-200"
>
+99
</button>
</div>
</>
)
}

View File

@ -0,0 +1,150 @@
// frontend\src\components\ui\TaskList.tsx
'use client'
export type TaskItem = {
id: string
status: 'idle' | 'running' | 'done' | 'error' | 'cancelled'
title: string
text?: string
done?: number
total?: number
err?: string
cancellable?: boolean // zeigt X nur wenn true
fading?: boolean // für sanftes Ausblenden
}
type Props = {
tasks: TaskItem[]
onCancel?: (id: string) => void
}
function pct(done?: number, total?: number) {
const d = Number(done ?? 0)
const t = Number(total ?? 0)
if (!t || t <= 0) return 0
return Math.max(0, Math.min(100, Math.round((d / t) * 100)))
}
export default function TaskList({ tasks, onCancel }: Props) {
const visible = (tasks || []).filter((t) => t.status !== 'idle')
return (
<div className="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-950/40">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Hintergrundaufgaben</div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
Laufende Hintergrundaufgaben (z.B. Assets/Previews).
</div>
</div>
</div>
<div className="mt-3 space-y-3">
{visible.length === 0 ? (
<div className="rounded-xl border border-gray-200 bg-white px-3 py-2 text-sm text-gray-600 dark:border-white/10 dark:bg-white/5 dark:text-gray-300">
Keine laufenden Aufgaben.
</div>
) : null}
{visible.map((t) => {
const p = pct(t.done, t.total)
const isRunning = t.status === 'running'
const hasProgress = isRunning && (t.total ?? 0) > 0
const title = (t.title ?? '').trim()
const suffix = (t.text ?? '').trim()
return (
<div
key={t.id}
className={
"rounded-xl border border-gray-200 bg-white px-3 py-2 dark:border-white/10 dark:bg-white/5 " +
"transition-opacity duration-500 " +
(t.fading ? "opacity-0" : "opacity-100")
}
>
<div className="flex items-center gap-3">
{/* Left icon: Cancel X (running+cancellable) OR green check (done) */}
<div className="shrink-0">
{isRunning && t.cancellable && onCancel ? (
<button
type="button"
onClick={() => onCancel?.(t.id)}
className="inline-flex h-7 w-7 items-center justify-center rounded-md
text-red-700 hover:bg-red-50 hover:text-red-900
dark:text-red-300 dark:hover:bg-red-500/10 dark:hover:text-red-200"
title="Abbrechen"
aria-label="Task abbrechen"
>
</button>
) : t.status === 'done' ? (
<span
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-green-700 dark:text-green-300"
title="Fertig"
aria-label="Fertig"
>
</span>
) : (
<span className="inline-block h-7 w-7" />
)}
</div>
{/* One-line row: title (optional) + status + progress */}
<div className="min-w-0 flex-1 flex items-center gap-2">
<div className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white">
{title || 'Aufgabe'}
{suffix ? (
<span className="font-normal text-gray-600 dark:text-gray-300">
{' · '}{suffix}
</span>
) : null}
</div>
{/* Status badge (running bewusst ohne Badge) */}
{t.status === 'done' ? (
<span className="shrink-0 inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-[11px] font-semibold text-green-800 ring-1 ring-inset ring-green-200 dark:bg-green-500/20 dark:text-green-200 dark:ring-green-400/30">
fertig
</span>
) : t.status === 'cancelled' ? (
<span className="shrink-0 inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-semibold text-gray-700 ring-1 ring-inset ring-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10">
abgebrochen
</span>
) : t.status === 'error' ? (
<span className="shrink-0 inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-[11px] font-semibold text-red-800 ring-1 ring-inset ring-red-200 dark:bg-red-500/20 dark:text-red-200 dark:ring-red-400/30">
fehler
</span>
) : null}
{/* Spacer */}
<div className="flex-1" />
{/* Progress compact on same line */}
{hasProgress ? (
<div className="shrink-0 flex items-center gap-3 text-xs text-gray-600 dark:text-gray-300">
<span className="tabular-nums">{t.done ?? 0}/{t.total ?? 0}</span>
<span className="tabular-nums">{p}%</span>
{/* Progressbar: auf Mobile ausblenden, ab sm anzeigen */}
<div className="hidden sm:block h-2 w-40 overflow-hidden rounded-full bg-gray-200 dark:bg-white/10">
<div className="h-full bg-indigo-500" style={{ width: `${p}%` }} />
</div>
</div>
) : null}
</div>
</div>
{/* Error text optional (unter der Zeile) */}
{t.status === 'error' && t.err ? (
<div className="mt-2 text-xs text-red-700 dark:text-red-200">
{t.err}
</div>
) : null}
</div>
)
})}
</div>
</div>
)
}

View File

@ -0,0 +1,30 @@
// frontend/src/components/ui/formatters.ts
export function formatResolution(r?: { w: number; h: number } | null): string {
if (!r) return ''
const w = Math.round(Number(r.w))
const h = Math.round(Number(r.h))
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return ''
// "p" orientiert sich an der kleineren Seite:
// - Landscape: min = Höhe
// - Portrait: min = Breite (damit 1080×1920 => 1080p)
const p = Math.min(w, h)
// Toleranz, weil Encoder manchmal krumme Werte liefern (z.B. 1072 statt 1080)
const tol = Math.max(12, Math.round(p * 0.02))
const pick = (target: number) => Math.abs(p - target) <= tol
if (pick(4320)) return '8K'
if (pick(2160) || p >= 2000) return '4K'
if (pick(1440)) return '1440p'
if (pick(1080)) return '1080p'
if (pick(720)) return '720p'
if (pick(480)) return '480p'
if (pick(360)) return '360p'
if (pick(240)) return '240p'
// Fallback: z.B. 1600p (Ultrawide) oder 800p
return `${p}p`
}

View File

@ -0,0 +1,32 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import type { RecordJob } from '../types'
import { subscribeSSE } from './sseSingleton'
export function useRecordJobsSSE(initialJobs: RecordJob[]) {
const [jobs, setJobs] = useState<RecordJob[]>(initialJobs)
// optional: super simple dedupe (hilft, falls Server identische snapshots pusht)
const lastLenRef = useRef<number>(initialJobs.length)
useEffect(() => {
const unsub = subscribeSSE<RecordJob[]>(
'/api/record/stream',
'jobs',
(data) => {
if (!Array.isArray(data)) return
// kleine Heuristik gegen “same snapshot” (billig)
if (data.length === lastLenRef.current) {
// trotzdem setzen ist ok; wenn du härter dedupen willst, siehe Stufe 2/3
}
lastLenRef.current = data.length
setJobs(data)
}
)
return () => unsub()
}, [])
return jobs
}

View File

@ -8,6 +8,24 @@ export type PostWorkKeyStatus = {
maxParallel?: number // cap(ffmpegSem)
}
export type VideoMeta = {
version?: number
// entspricht meta.json
durationSeconds: number
fileSize: number
fileModUnix: number
videoWidth?: number
videoHeight?: number
fps?: number
resolution?: string
sourceUrl?: string
updatedAtUnix?: number
}
export type RecordJob = {
id: string
sourceUrl?: string
@ -22,6 +40,8 @@ export type RecordJob = {
videoHeight?: number
fps?: number
meta?: VideoMeta
phase?: string
progress?: number