updated to jpg

This commit is contained in:
Linrador 2026-03-16 12:46:38 +01:00
parent 5e5b8025e8
commit dffe5dde16
28 changed files with 480 additions and 365 deletions

View File

@ -19,8 +19,6 @@ import (
"sort" "sort"
"strings" "strings"
"time" "time"
"golang.org/x/image/webp"
) )
type analyzeVideoReq struct { type analyzeVideoReq struct {
@ -92,7 +90,7 @@ func extractSpriteFrames(spritePath string, ps previewSpriteMetaFileInfo) ([]ima
} }
defer f.Close() defer f.Close()
img, err := webp.Decode(f) img, _, err := image.Decode(f)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -395,9 +393,9 @@ func analyzeVideoFromSprite(ctx context.Context, outPath, goal string) ([]analyz
return nil, fmt.Errorf("previewSprite count fehlt") return nil, fmt.Errorf("previewSprite count fehlt")
} }
spritePath := filepath.Join(filepath.Dir(metaPath), "preview-sprite.webp") spritePath := filepath.Join(filepath.Dir(metaPath), "preview-sprite.jpg")
if fi, err := os.Stat(spritePath); err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { if fi, err := os.Stat(spritePath); err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
return nil, fmt.Errorf("preview-sprite.webp nicht gefunden") return nil, fmt.Errorf("preview-sprite.jpg nicht gefunden")
} }
durationSec, _ := durationSecondsForAnalyze(ctx, outPath) durationSec, _ := durationSecondsForAnalyze(ctx, outPath)

View File

@ -70,7 +70,7 @@ func assetIDFromVideoPath(videoPath string) string {
return strings.TrimSpace(id) return strings.TrimSpace(id)
} }
// Liefert die standardisierten Pfade (preview.webp / preview.mp4 / preview-sprite.webp / meta.json) // Liefert die standardisierten Pfade (preview.jpg / preview.mp4 / preview-sprite.jpg / meta.json)
func assetPathsForID(id string) (assetDir, thumbPath, previewPath, spritePath, metaPath string, err error) { func assetPathsForID(id string) (assetDir, thumbPath, previewPath, spritePath, metaPath string, err error) {
id = strings.TrimSpace(id) id = strings.TrimSpace(id)
if id == "" { if id == "" {
@ -82,9 +82,9 @@ func assetPathsForID(id string) (assetDir, thumbPath, previewPath, spritePath, m
return "", "", "", "", "", fmt.Errorf("generated dir: %v", err) return "", "", "", "", "", fmt.Errorf("generated dir: %v", err)
} }
thumbPath = filepath.Join(assetDir, "preview.webp") thumbPath = filepath.Join(assetDir, "preview.jpg")
previewPath = filepath.Join(assetDir, "preview.mp4") previewPath = filepath.Join(assetDir, "preview.mp4")
spritePath = filepath.Join(assetDir, "preview-sprite.webp") spritePath = filepath.Join(assetDir, "preview-sprite.jpg")
metaPath, _ = metaJSONPathForAssetID(id) // bevorzugt generated/meta/<id>/meta.json metaPath, _ = metaJSONPathForAssetID(id) // bevorzugt generated/meta/<id>/meta.json
if strings.TrimSpace(metaPath) == "" { if strings.TrimSpace(metaPath) == "" {
@ -294,7 +294,7 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
progress(0) progress(0)
// ---------------- // ----------------
// Thumbs (WebP-only) // Thumbs (JPG-only)
// ---------------- // ----------------
if thumbBefore { if thumbBefore {
progress(thumbsW) progress(thumbsW)
@ -318,7 +318,7 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
progress(0.10) progress(0.10)
// ✅ Immer letztes Frame bevorzugen (Preview soll “Endzustand” zeigen) // ✅ Immer letztes Frame bevorzugen (Preview soll “Endzustand” zeigen)
img, e1 := extractLastFrameWebP(videoPath) img, e1 := extractLastFrameJPG(videoPath)
if e1 != nil || len(img) == 0 { if e1 != nil || len(img) == 0 {
// Fallback: wenn wir Duration kennen, versuche kurz vor Ende // Fallback: wenn wir Duration kennen, versuche kurz vor Ende
if meta.durSec > 0 { if meta.durSec > 0 {
@ -326,11 +326,11 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
if t < 0 { if t < 0 {
t = 0 t = 0
} }
img, e1 = extractFrameAtTimeWebP(videoPath, t) img, e1 = extractFrameAtTimeJPG(videoPath, t)
} }
// Letzter Fallback: erstes Frame // Letzter Fallback: erstes Frame
if e1 != nil || len(img) == 0 { if e1 != nil || len(img) == 0 {
img, e1 = extractFirstFrameWebPScaled(videoPath, 720, 75) img, e1 = extractFirstFrameJPGScaled(videoPath, 720, 75)
} }
} }
@ -463,7 +463,7 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
cols, rows, _, cellW, cellH := fixedPreviewSpriteLayout() cols, rows, _, cellW, cellH := fixedPreviewSpriteLayout()
stepSec := previewSpriteStepSeconds(meta.durSec) stepSec := previewSpriteStepSeconds(meta.durSec)
if err := generatePreviewSpriteWebP( if err := generatePreviewSpriteJPG(
genCtx, genCtx,
videoPath, videoPath,
spritePath, spritePath,
@ -474,12 +474,10 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
cellH, cellH,
); err != nil { ); err != nil {
if sfi, statErr := os.Stat(spritePath); statErr == nil && sfi != nil && !sfi.IsDir() && sfi.Size() > 0 { if sfi, statErr := os.Stat(spritePath); statErr == nil && sfi != nil && !sfi.IsDir() && sfi.Size() > 0 {
// Sprite existiert am Ende trotzdem -> Warnung unterdrücken
return return
} }
// Optional: nur kurze Meldung statt voller ffmpeg-Ausgabe fmt.Printf("⚠️ preview sprite failed for %s: %v\n", videoPath, err)
//fmt.Printf("⚠️ preview sprite failed for %s\n", videoPath)
return return
} }
@ -581,7 +579,7 @@ func prepareVideoForSplit(ctx context.Context, videoPath, sourceURL, goal string
return out, fmt.Errorf("video datei nicht gefunden") return out, fmt.Errorf("video datei nicht gefunden")
} }
// 1) Assets sicherstellen (preview.webp / preview.mp4 / preview-sprite.webp / meta.json) // 1) Assets sicherstellen (preview.jpg / preview.mp4 / preview-sprite.jpg / meta.json)
assetsRes, err := ensureAssetsForVideoDetailed(ctx, videoPath, sourceURL, nil) assetsRes, err := ensureAssetsForVideoDetailed(ctx, videoPath, sourceURL, nil)
if err != nil { if err != nil {
return out, err return out, err

View File

@ -11,6 +11,7 @@ import (
"strings" "strings"
) )
/*
const ( const (
previewSpriteCols = 10 previewSpriteCols = 10
previewSpriteRows = 8 previewSpriteRows = 8
@ -18,6 +19,15 @@ const (
previewSpriteCellW = 160 previewSpriteCellW = 160
previewSpriteCellH = 90 previewSpriteCellH = 90
) )
*/
const (
previewSpriteCols = 6
previewSpriteRows = 5
previewSpriteFrameCount = previewSpriteCols * previewSpriteRows
previewSpriteCellW = 120
previewSpriteCellH = 68
)
func fixedPreviewSpriteLayout() (cols, rows, count, cellW, cellH int) { func fixedPreviewSpriteLayout() (cols, rows, count, cellW, cellH int) {
return previewSpriteCols, previewSpriteRows, previewSpriteFrameCount, previewSpriteCellW, previewSpriteCellH return previewSpriteCols, previewSpriteRows, previewSpriteFrameCount, previewSpriteCellW, previewSpriteCellH
@ -36,9 +46,9 @@ func previewSpriteStepSeconds(durationSec float64) float64 {
return step return step
} }
// generatePreviewSpriteWebP erzeugt ein statisches WebP-Spritesheet aus einem Video. // generatePreviewSpriteJPG erzeugt ein statisches JPG-Spritesheet aus einem Video.
// ffmpeg muss im PATH verfügbar sein. // ffmpeg muss im PATH verfügbar sein.
func generatePreviewSpriteWebP( func generatePreviewSpriteJPG(
ctx context.Context, ctx context.Context,
videoPath string, videoPath string,
outPath string, outPath string,
@ -52,19 +62,19 @@ func generatePreviewSpriteWebP(
outPath = strings.TrimSpace(outPath) outPath = strings.TrimSpace(outPath)
if videoPath == "" { if videoPath == "" {
return fmt.Errorf("generatePreviewSpriteWebP: empty videoPath") return fmt.Errorf("generatePreviewSpriteJPG: empty videoPath")
} }
if outPath == "" { if outPath == "" {
return fmt.Errorf("generatePreviewSpriteWebP: empty outPath") return fmt.Errorf("generatePreviewSpriteJPG: empty outPath")
} }
if cols <= 0 || rows <= 0 { if cols <= 0 || rows <= 0 {
return fmt.Errorf("generatePreviewSpriteWebP: invalid grid %dx%d", cols, rows) return fmt.Errorf("generatePreviewSpriteJPG: invalid grid %dx%d", cols, rows)
} }
if stepSec <= 0 { if stepSec <= 0 {
return fmt.Errorf("generatePreviewSpriteWebP: invalid stepSec %.3f", stepSec) return fmt.Errorf("generatePreviewSpriteJPG: invalid stepSec %.3f", stepSec)
} }
if cellW <= 0 || cellH <= 0 { if cellW <= 0 || cellH <= 0 {
return fmt.Errorf("generatePreviewSpriteWebP: invalid cell size %dx%d", cellW, cellH) return fmt.Errorf("generatePreviewSpriteJPG: invalid cell size %dx%d", cellW, cellH)
} }
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
@ -73,7 +83,7 @@ func generatePreviewSpriteWebP(
ext := filepath.Ext(outPath) ext := filepath.Ext(outPath)
if ext == "" { if ext == "" {
ext = ".webp" ext = ".jpg"
} }
base := strings.TrimSuffix(outPath, ext) base := strings.TrimSuffix(outPath, ext)
tmpPath := base + ".tmp" + ext tmpPath := base + ".tmp" + ext
@ -115,14 +125,12 @@ func generatePreviewSpriteWebP(
"-i", videoPath, "-i", videoPath,
"-an", "-an",
"-sn", "-sn",
"-threads", "1", "-threads", "0",
"-vf", vf, "-vf", vf,
"-frames:v", "1", "-frames:v", "1",
"-c:v", "libwebp", "-c:v", "mjpeg",
"-lossless", "0", "-q:v", "4",
"-compression_level", "3", "-f", "image2",
"-q:v", "65",
"-f", "webp",
tmpPath, tmpPath,
) )

View File

@ -30,8 +30,6 @@ func makeFrontendHandler() (http.Handler, bool) {
return nil, false return nil, false
} }
fmt.Println("🖼️ Frontend dist: embedded web/dist")
fileServer := http.FileServer(http.FS(distFS)) fileServer := http.FileServer(http.FS(distFS))
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View File

@ -33,7 +33,7 @@ import (
// - ffmpegPath, previewSem // - ffmpegPath, previewSem
// - notifyJobsChanged() // - notifyJobsChanged()
// - assetIDForJob(job *RecordJob) string // - assetIDForJob(job *RecordJob) string
// - startLiveThumbWebPLoop(ctx, job) // - startLiveThumbJPGLoop(ctx, job)
// ============================================================ // ============================================================
// Allowed files that may be served out of PreviewDir. // Allowed files that may be served out of PreviewDir.
@ -122,7 +122,7 @@ func recordPreviewLive(w http.ResponseWriter, r *http.Request) {
} }
// recordPreviewFile serves ONLY the HLS file requests for /api/preview?file=... // recordPreviewFile serves ONLY the HLS file requests for /api/preview?file=...
// preview.webp bleibt in preview.go (servePreviewWebPAlias). // preview.jpg bleibt in preview.go (servePreviewJPGAlias).
func recordPreviewFile(w http.ResponseWriter, r *http.Request) { func recordPreviewFile(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead { if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
@ -337,7 +337,7 @@ func classifyPreviewFFmpegStderr(stderr string) (state string, httpStatus int) {
} }
// startPreviewHLS starts ffmpeg to generate HLS segments in previewDir. // startPreviewHLS starts ffmpeg to generate HLS segments in previewDir.
// It also starts your existing live-thumb loop: startLiveThumbWebPLoop(ctx, job). // It also starts your existing live-thumb loop: startLiveThumbJPGLoop(ctx, job).
func startPreviewHLS(ctx context.Context, job *RecordJob, m3u8URL, previewDir, httpCookie, userAgent string) error { func startPreviewHLS(ctx context.Context, job *RecordJob, m3u8URL, previewDir, httpCookie, userAgent string) error {
if strings.TrimSpace(ffmpegPath) == "" { if strings.TrimSpace(ffmpegPath) == "" {
return fmt.Errorf("kein ffmpeg gefunden setze FFMPEG_PATH oder lege ffmpeg(.exe) neben das Backend") return fmt.Errorf("kein ffmpeg gefunden setze FFMPEG_PATH oder lege ffmpeg(.exe) neben das Backend")
@ -440,7 +440,7 @@ func startPreviewHLS(ctx context.Context, job *RecordJob, m3u8URL, previewDir, h
jobsMu.Unlock() jobsMu.Unlock()
}() }()
startLiveThumbWebPLoop(ctx, job) startLiveThumbJPGLoop(ctx, job)
return nil return nil
} }

View File

@ -71,10 +71,10 @@ type RecordJob struct {
PreviewStateMsg string `json:"previewStateMsg,omitempty"` // kurze Info PreviewStateMsg string `json:"previewStateMsg,omitempty"` // kurze Info
// Thumbnail cache (verhindert, dass pro HTTP-Request ffmpeg läuft) // Thumbnail cache (verhindert, dass pro HTTP-Request ffmpeg läuft)
previewMu sync.Mutex `json:"-"` previewMu sync.Mutex `json:"-"`
previewWebp []byte `json:"-"` previewJPG []byte `json:"-"`
previewWebpAt time.Time `json:"-"` previewJPGAt time.Time `json:"-"`
previewGen bool `json:"-"` previewGen bool `json:"-"`
PreviewM3U8 string `json:"-"` // HLS url, die ffmpeg inputt PreviewM3U8 string `json:"-"` // HLS url, die ffmpeg inputt
PreviewCookie string `json:"-"` // Cookie header (falls nötig) PreviewCookie string `json:"-"` // Cookie header (falls nötig)
@ -484,19 +484,21 @@ func initFFmpegSemaphores() {
genSem = NewDynSem(genN, genCap) genSem = NewDynSem(genN, genCap)
durSem = NewDynSem(durN, durCap) durSem = NewDynSem(durN, durCap)
fmt.Printf( /*
"🔧 semaphores(init): preview=%d/%d thumb=%d/%d gen=%d/%d dur=%d/%d (cpu=%d)\n", fmt.Printf(
previewSem.Max(), previewSem.Cap(), "🔧 semaphores(init): preview=%d/%d thumb=%d/%d gen=%d/%d dur=%d/%d (cpu=%d)\n",
thumbSem.Max(), thumbSem.Cap(), previewSem.Max(), previewSem.Cap(),
genSem.Max(), genSem.Cap(), thumbSem.Max(), thumbSem.Cap(),
durSem.Max(), durSem.Cap(), genSem.Max(), genSem.Cap(),
cpu, durSem.Max(), durSem.Cap(),
) cpu,
)
fmt.Printf( fmt.Printf(
"🔧 semaphores: preview=%d thumb=%d gen=%d dur=%d (cpu=%d)\n", "🔧 semaphores: preview=%d thumb=%d gen=%d dur=%d (cpu=%d)\n",
previewN, thumbN, genN, durN, cpu, previewN, thumbN, genN, durN, cpu,
) )
*/
} }
func startAdaptiveSemController(ctx context.Context) { func startAdaptiveSemController(ctx context.Context) {
@ -1371,9 +1373,9 @@ func generatedMetaRoot() (string, error) {
return resolvePathRelativeToApp(filepath.Join("generated", "meta")) return resolvePathRelativeToApp(filepath.Join("generated", "meta"))
} }
// generatedThumbWebPFile gibt den Pfad zu generated/<assetID>/preview.webp zurück. // generatedThumbJPGFile gibt den Pfad zu generated/<assetID>/preview.jpg zurück.
// assetID darf "HOT " enthalten; wird entfernt und wie überall sonst sanitisiert. // assetID darf "HOT " enthalten; wird entfernt und wie überall sonst sanitisiert.
func generatedThumbWebPFile(assetID string) (string, error) { func generatedThumbJPGFile(assetID string) (string, error) {
assetID = stripHotPrefix(strings.TrimSpace(assetID)) assetID = stripHotPrefix(strings.TrimSpace(assetID))
if assetID == "" { if assetID == "" {
return "", fmt.Errorf("empty assetID") return "", fmt.Errorf("empty assetID")
@ -1391,7 +1393,7 @@ func generatedThumbWebPFile(assetID string) (string, error) {
return "", fmt.Errorf("ensureGeneratedDir: %w", err) return "", fmt.Errorf("ensureGeneratedDir: %w", err)
} }
return filepath.Join(dir, "preview.webp"), nil return filepath.Join(dir, "preview.jpg"), nil
} }
// Legacy (falls noch alte Assets liegen): // Legacy (falls noch alte Assets liegen):

View File

@ -525,7 +525,7 @@ func generatedThumbFile(id string) (string, error) {
if err != nil { if err != nil {
return "", err return "", err
} }
return filepath.Join(dir, "preview.webp"), nil return filepath.Join(dir, "preview.jpg"), nil
} }
func generatedPreviewFile(id string) (string, error) { func generatedPreviewFile(id string) (string, error) {
@ -541,7 +541,7 @@ func generatedPreviewSpriteFile(id string) (string, error) {
if err != nil { if err != nil {
return "", err return "", err
} }
return filepath.Join(dir, "preview-sprite.webp"), nil return filepath.Join(dir, "preview-sprite.jpg"), nil
} }
func ensureGeneratedDirs() error { func ensureGeneratedDirs() error {

View File

@ -138,10 +138,6 @@ func initNSFWDetector() error {
globalNSFW.session = session globalNSFW.session = session
globalNSFW.initialized = true globalNSFW.initialized = true
fmt.Println("[NSFW] ONNX detector bereit")
fmt.Println("[NSFW] model:", modelPath)
fmt.Println("[NSFW] dll:", dllPath)
return nil return nil
} }

View File

@ -44,7 +44,7 @@ import (
// - atomicWriteFile, ensureGeneratedDirs, ensureGeneratedDir // - atomicWriteFile, ensureGeneratedDirs, ensureGeneratedDir
// - durationSecondsCached, parseFFmpegOutTime, ffmpegInputTol // - durationSecondsCached, parseFFmpegOutTime, ffmpegInputTol
// - jobs, jobsMu, RecordJob, previewSem, thumbSem, JobRunning, notifyJobsChanged // - jobs, jobsMu, RecordJob, previewSem, thumbSem, JobRunning, notifyJobsChanged
// - sanitizeID, findFinishedFileByID, stripHotPrefix, assetIDForJob, generatedThumbWebPFile // - sanitizeID, findFinishedFileByID, stripHotPrefix, assetIDForJob, generatedThumbJPGFile
// Bitte diese Abhängigkeiten NICHT löschen preview.go nutzt sie. // Bitte diese Abhängigkeiten NICHT löschen preview.go nutzt sie.
// ============================================================ // ============================================================
@ -453,8 +453,6 @@ func detectImageExt(contentType string, b []byte) (ext string, ct string) {
return ".jpg", "image/jpeg" return ".jpg", "image/jpeg"
case strings.Contains(ct, "image/png"): case strings.Contains(ct, "image/png"):
return ".png", "image/png" return ".png", "image/png"
case strings.Contains(ct, "image/webp"):
return ".webp", "image/webp"
case strings.Contains(ct, "image/gif"): case strings.Contains(ct, "image/gif"):
return ".gif", "image/gif" return ".gif", "image/gif"
} }
@ -465,7 +463,7 @@ func detectImageExt(contentType string, b []byte) (ext string, ct string) {
return ".png", "image/png" return ".png", "image/png"
} }
if len(b) >= 12 && string(b[:4]) == "RIFF" && string(b[8:12]) == "WEBP" { if len(b) >= 12 && string(b[:4]) == "RIFF" && string(b[8:12]) == "WEBP" {
return ".webp", "image/webp" return ".jpg", "image/jpeg"
} }
if len(b) >= 6 && (string(b[:6]) == "GIF87a" || string(b[:6]) == "GIF89a") { if len(b) >= 6 && (string(b[:6]) == "GIF87a" || string(b[:6]) == "GIF89a") {
return ".gif", "image/gif" return ".gif", "image/gif"
@ -492,7 +490,7 @@ func findExistingCoverFile(key string) (string, os.FileInfo, bool) {
if err != nil || strings.TrimSpace(root) == "" { if err != nil || strings.TrimSpace(root) == "" {
return "", nil, false return "", nil, false
} }
ext := []string{".jpg", ".png", ".webp", ".gif"} ext := []string{".jpg", ".png", ".gif"}
for _, e := range ext { for _, e := range ext {
p := filepath.Join(root, key+e) p := filepath.Join(root, key+e)
if fi, err := os.Stat(p); err == nil && !fi.IsDir() && fi.Size() > 0 { if fi, err := os.Stat(p); err == nil && !fi.IsDir() && fi.Size() > 0 {
@ -545,8 +543,6 @@ func downloadBytes(ctx context.Context, rawURL string, ua string) ([]byte, strin
ct = "image/jpeg" ct = "image/jpeg"
case ".png": case ".png":
ct = "image/png" ct = "image/png"
case ".webp":
ct = "image/webp"
case ".gif": case ".gif":
ct = "image/gif" ct = "image/gif"
} }
@ -721,7 +717,7 @@ func generatedCoverInfoList(w http.ResponseWriter, r *http.Request) {
isCoverExt := func(ext string) bool { isCoverExt := func(ext string) bool {
switch strings.ToLower(ext) { switch strings.ToLower(ext) {
case ".jpg", ".jpeg", ".png", ".webp", ".gif": case ".jpg", ".jpeg", ".png", ".gif":
return true return true
default: default:
return false return false
@ -868,8 +864,8 @@ func generatedCover(w http.ResponseWriter, r *http.Request) {
switch ext { switch ext {
case ".png": case ".png":
w.Header().Set("Content-Type", "image/png") w.Header().Set("Content-Type", "image/png")
case ".webp": case ".jpg", ".jpeg":
w.Header().Set("Content-Type", "image/webp") w.Header().Set("Content-Type", "image/jpeg")
case ".gif": case ".gif":
w.Header().Set("Content-Type", "image/gif") w.Header().Set("Content-Type", "image/gif")
default: default:
@ -966,8 +962,8 @@ func generatedCover(w http.ResponseWriter, r *http.Request) {
switch ext2 { switch ext2 {
case ".png": case ".png":
w.Header().Set("Content-Type", "image/png") w.Header().Set("Content-Type", "image/png")
case ".webp": case ".jpg":
w.Header().Set("Content-Type", "image/webp") w.Header().Set("Content-Type", "image/jpeg")
case ".gif": case ".gif":
w.Header().Set("Content-Type", "image/gif") w.Header().Set("Content-Type", "image/gif")
default: default:
@ -1050,7 +1046,7 @@ func generatedCover(w http.ResponseWriter, r *http.Request) {
} }
root, _ := coversRoot() root, _ := coversRoot()
for _, e := range []string{".jpg", ".png", ".webp", ".gif"} { for _, e := range []string{".jpg", ".png", ".gif"} {
_ = os.Remove(filepath.Join(root, key+e)) _ = os.Remove(filepath.Join(root, key+e))
} }
_ = os.Remove(filepath.Join(root, key+".info.json")) _ = os.Remove(filepath.Join(root, key+".info.json"))
@ -1163,15 +1159,15 @@ func servePreviewStatusSVG(w http.ResponseWriter, label string, status int) {
} }
// ============================================================ // ============================================================
// WebP extraction + preview endpoint // JPG extraction + preview endpoint
// Route: // Route:
// - /api/preview?id=<jobID> (returns preview.webp / 204 / svg) // - /api/preview?id=<jobID> (returns preview.jpg / 204 / svg)
// - /api/preview?id=<jobID>&file=preview.webp // - /api/preview?id=<jobID>&file=preview.jpg
// ============================================================ // ============================================================
// --- WebP extraction helpers --- // --- JPG extraction helpers ---
func extractLastFrameWebP(path string) ([]byte, error) { func extractLastFrameJPG(path string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel() defer cancel()
@ -1180,32 +1176,16 @@ func extractLastFrameWebP(path string) ([]byte, error) {
ffmpegPath, ffmpegPath,
"-hide_banner", "-hide_banner",
"-loglevel", "error", "-loglevel", "error",
// relativ zum Dateiende suchen
"-sseof", "-0.25", "-sseof", "-0.25",
"-i", path, "-i", path,
// nur den ersten Video-Stream verwenden
"-map", "0:v:0", "-map", "0:v:0",
// alles andere hart abschalten
"-an", "-an",
"-sn", "-sn",
"-dn", "-dn",
// genau 1 Frame
"-frames:v", "1", "-frames:v", "1",
// schneller skalieren
"-vf", "scale=720:-2:flags=fast_bilinear", "-vf", "scale=720:-2:flags=fast_bilinear",
"-vcodec", "mjpeg",
// WebP: Qualität + schnellerer Encode "-q:v", "4",
"-vcodec", "libwebp",
"-quality", "75",
"-compression_level", "2",
"-preset", "photo",
"-f", "image2pipe", "-f", "image2pipe",
"pipe:1", "pipe:1",
) )
@ -1217,20 +1197,20 @@ func extractLastFrameWebP(path string) ([]byte, error) {
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
if ctx.Err() == context.DeadlineExceeded { if ctx.Err() == context.DeadlineExceeded {
return nil, fmt.Errorf("ffmpeg last-frame webp: timeout") return nil, fmt.Errorf("ffmpeg last-frame jpg: timeout")
} }
return nil, fmt.Errorf("ffmpeg last-frame webp: %w (%s)", err, strings.TrimSpace(stderr.String())) return nil, fmt.Errorf("ffmpeg last-frame jpg: %w (%s)", err, strings.TrimSpace(stderr.String()))
} }
b := out.Bytes() b := out.Bytes()
if len(b) == 0 { if len(b) == 0 {
return nil, fmt.Errorf("ffmpeg last-frame webp: empty output") return nil, fmt.Errorf("ffmpeg last-frame jpg: empty output")
} }
return b, nil return b, nil
} }
func extractFrameAtTimeWebP(path string, seconds float64) ([]byte, error) { func extractFrameAtTimeJPG(path string, seconds float64) ([]byte, error) {
if seconds < 0 { if seconds < 0 {
seconds = 0 seconds = 0
} }
@ -1243,26 +1223,27 @@ func extractFrameAtTimeWebP(path string, seconds float64) ([]byte, error) {
"-i", path, "-i", path,
"-frames:v", "1", "-frames:v", "1",
"-vf", "scale=720:-2", "-vf", "scale=720:-2",
"-quality", "75", "-vcodec", "mjpeg",
"-q:v", "4",
"-f", "image2pipe", "-f", "image2pipe",
"-vcodec", "libwebp",
"pipe:1", "pipe:1",
) )
var out bytes.Buffer var out bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
cmd.Stdout = &out cmd.Stdout = &out
cmd.Stderr = &stderr cmd.Stderr = &stderr
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("ffmpeg frame-at-time webp: %w (%s)", err, strings.TrimSpace(stderr.String())) return nil, fmt.Errorf("ffmpeg frame-at-time jpg: %w (%s)", err, strings.TrimSpace(stderr.String()))
} }
b := out.Bytes() b := out.Bytes()
if len(b) == 0 { if len(b) == 0 {
return nil, fmt.Errorf("ffmpeg frame-at-time webp: empty output") return nil, fmt.Errorf("ffmpeg frame-at-time jpg: empty output")
} }
return b, nil return b, nil
} }
func extractLastFrameWebPScaled(path string, width int, quality int) ([]byte, error) { func extractLastFrameJPGScaled(path string, width int, quality int) ([]byte, error) {
if width <= 0 { if width <= 0 {
width = 320 width = 320
} }
@ -1270,6 +1251,15 @@ func extractLastFrameWebPScaled(path string, width int, quality int) ([]byte, er
quality = 70 quality = 70
} }
qv := "5"
if quality >= 80 {
qv = "3"
} else if quality >= 65 {
qv = "5"
} else {
qv = "7"
}
cmd := exec.Command( cmd := exec.Command(
ffmpegPath, ffmpegPath,
"-hide_banner", "-loglevel", "error", "-hide_banner", "-loglevel", "error",
@ -1277,9 +1267,9 @@ func extractLastFrameWebPScaled(path string, width int, quality int) ([]byte, er
"-i", path, "-i", path,
"-frames:v", "1", "-frames:v", "1",
"-vf", fmt.Sprintf("scale=%d:-2", width), "-vf", fmt.Sprintf("scale=%d:-2", width),
"-quality", strconv.Itoa(quality), "-vcodec", "mjpeg",
"-q:v", qv,
"-f", "image2pipe", "-f", "image2pipe",
"-vcodec", "libwebp",
"pipe:1", "pipe:1",
) )
@ -1288,16 +1278,16 @@ func extractLastFrameWebPScaled(path string, width int, quality int) ([]byte, er
cmd.Stdout = &out cmd.Stdout = &out
cmd.Stderr = &stderr cmd.Stderr = &stderr
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("ffmpeg last-frame scaled webp: %w (%s)", err, strings.TrimSpace(stderr.String())) return nil, fmt.Errorf("ffmpeg last-frame scaled jpg: %w (%s)", err, strings.TrimSpace(stderr.String()))
} }
b := out.Bytes() b := out.Bytes()
if len(b) == 0 { if len(b) == 0 {
return nil, fmt.Errorf("ffmpeg last-frame scaled webp: empty output") return nil, fmt.Errorf("ffmpeg last-frame scaled jpg: empty output")
} }
return b, nil return b, nil
} }
func extractFirstFrameWebPScaled(path string, width int, quality int) ([]byte, error) { func extractFirstFrameJPGScaled(path string, width int, quality int) ([]byte, error) {
if width <= 0 { if width <= 0 {
width = 320 width = 320
} }
@ -1312,9 +1302,9 @@ func extractFirstFrameWebPScaled(path string, width int, quality int) ([]byte, e
"-i", path, "-i", path,
"-frames:v", "1", "-frames:v", "1",
"-vf", fmt.Sprintf("scale=%d:-2", width), "-vf", fmt.Sprintf("scale=%d:-2", width),
"-quality", strconv.Itoa(quality), "-vcodec", "mjpeg",
"-q:v", "5",
"-f", "image2pipe", "-f", "image2pipe",
"-vcodec", "libwebp",
"pipe:1", "pipe:1",
) )
@ -1323,11 +1313,11 @@ func extractFirstFrameWebPScaled(path string, width int, quality int) ([]byte, e
cmd.Stdout = &out cmd.Stdout = &out
cmd.Stderr = &stderr cmd.Stderr = &stderr
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("ffmpeg first-frame scaled webp: %w (%s)", err, strings.TrimSpace(stderr.String())) return nil, fmt.Errorf("ffmpeg first-frame scaled jpg: %w (%s)", err, strings.TrimSpace(stderr.String()))
} }
b := out.Bytes() b := out.Bytes()
if len(b) == 0 { if len(b) == 0 {
return nil, fmt.Errorf("ffmpeg first-frame scaled webp: empty output") return nil, fmt.Errorf("ffmpeg first-frame scaled jpg: empty output")
} }
return b, nil return b, nil
} }
@ -1356,31 +1346,31 @@ func latestPreviewSegment(previewDir string) (string, error) {
return filepath.Join(previewDir, best), nil return filepath.Join(previewDir, best), nil
} }
func extractLastFrameFromPreviewDirThumbWebP(previewDir string) ([]byte, error) { func extractLastFrameFromPreviewDirThumbJPG(previewDir string) ([]byte, error) {
seg, err := latestPreviewSegment(previewDir) seg, err := latestPreviewSegment(previewDir)
if err != nil { if err != nil {
return nil, err return nil, err
} }
img, err := extractLastFrameWebPScaled(seg, 320, 70) img, err := extractLastFrameJPGScaled(seg, 320, 70)
if err == nil && len(img) > 0 { if err == nil && len(img) > 0 {
return img, nil return img, nil
} }
return extractFirstFrameWebPScaled(seg, 320, 70) return extractFirstFrameJPGScaled(seg, 320, 70)
} }
func extractLastFrameFromPreviewDirWebP(previewDir string) ([]byte, error) { func extractLastFrameFromPreviewDirJPG(previewDir string) ([]byte, error) {
seg, err := latestPreviewSegment(previewDir) seg, err := latestPreviewSegment(previewDir)
if err != nil { if err != nil {
return nil, err return nil, err
} }
img, err := extractLastFrameWebP(seg) img, err := extractLastFrameJPG(seg)
if err != nil { if err != nil {
return extractFirstFrameWebPScaled(seg, 720, 75) return extractFirstFrameJPGScaled(seg, 720, 75)
} }
return img, nil return img, nil
} }
func serveLivePreviewWebPFile(w http.ResponseWriter, r *http.Request, path string) { func serveLivePreviewJPGFile(w http.ResponseWriter, r *http.Request, path string) {
f, err := os.Open(path) f, err := os.Open(path)
if err != nil { if err != nil {
http.NotFound(w, r) http.NotFound(w, r)
@ -1394,12 +1384,12 @@ func serveLivePreviewWebPFile(w http.ResponseWriter, r *http.Request, path strin
return return
} }
w.Header().Set("Content-Type", "image/webp") w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
http.ServeContent(w, r, "preview.webp", st.ModTime(), f) http.ServeContent(w, r, "preview.jpg", st.ModTime(), f)
} }
func servePreviewWebPFile(w http.ResponseWriter, r *http.Request, path string) { func servePreviewJPGFile(w http.ResponseWriter, r *http.Request, path string) {
f, err := os.Open(path) f, err := os.Open(path)
if err != nil { if err != nil {
http.NotFound(w, r) http.NotFound(w, r)
@ -1413,35 +1403,35 @@ func servePreviewWebPFile(w http.ResponseWriter, r *http.Request, path string) {
return return
} }
w.Header().Set("Content-Type", "image/webp") w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Cache-Control", "public, max-age=600") w.Header().Set("Cache-Control", "public, max-age=600")
http.ServeContent(w, r, filepath.Base(path), st.ModTime(), f) http.ServeContent(w, r, filepath.Base(path), st.ModTime(), f)
} }
func servePreviewWebPBytes(w http.ResponseWriter, b []byte) { func servePreviewJPGBytes(w http.ResponseWriter, b []byte) {
if len(b) == 0 { if len(b) == 0 {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
return return
} }
w.Header().Set("Content-Type", "image/webp") w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Cache-Control", "public, max-age=60") w.Header().Set("Cache-Control", "public, max-age=60")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, _ = w.Write(b) _, _ = w.Write(b)
} }
func serveLivePreviewWebPBytes(w http.ResponseWriter, b []byte) { func serveLivePreviewJPGBytes(w http.ResponseWriter, b []byte) {
if len(b) == 0 { if len(b) == 0 {
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
return return
} }
w.Header().Set("Content-Type", "image/webp") w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, _ = w.Write(b) _, _ = w.Write(b)
} }
func servePreviewWebPAlias(w http.ResponseWriter, r *http.Request, id string) { func servePreviewJPGAlias(w http.ResponseWriter, r *http.Request, id string) {
jobsMu.Lock() jobsMu.Lock()
job := jobs[id] job := jobs[id]
jobsMu.Unlock() jobsMu.Unlock()
@ -1449,12 +1439,12 @@ func servePreviewWebPAlias(w http.ResponseWriter, r *http.Request, id string) {
if job != nil { if job != nil {
assetID := assetIDForJob(job) assetID := assetIDForJob(job)
if assetID != "" { if assetID != "" {
if webpPath, err := generatedThumbWebPFile(assetID); err == nil { if jpgPath, err := generatedThumbJPGFile(assetID); err == nil {
if st, err := os.Stat(webpPath); err == nil && !st.IsDir() && st.Size() > 0 { if st, err := os.Stat(jpgPath); err == nil && !st.IsDir() && st.Size() > 0 {
if job.Status == JobRunning { if job.Status == JobRunning {
serveLivePreviewWebPFile(w, r, webpPath) serveLivePreviewJPGFile(w, r, jpgPath)
} else { } else {
servePreviewWebPFile(w, r, webpPath) servePreviewJPGFile(w, r, jpgPath)
} }
return return
} }
@ -1463,10 +1453,10 @@ func servePreviewWebPAlias(w http.ResponseWriter, r *http.Request, id string) {
if job.Status == JobRunning { if job.Status == JobRunning {
job.previewMu.Lock() job.previewMu.Lock()
cached := job.previewWebp cached := job.previewJPG
job.previewMu.Unlock() job.previewMu.Unlock()
if len(cached) > 0 { if len(cached) > 0 {
serveLivePreviewWebPBytes(w, cached) serveLivePreviewJPGBytes(w, cached)
return return
} }
} }
@ -1480,9 +1470,9 @@ func servePreviewWebPAlias(w http.ResponseWriter, r *http.Request, id string) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
if webpPath, err := generatedThumbWebPFile(assetID); err == nil { if jpgPath, err := generatedThumbJPGFile(assetID); err == nil {
if st, err := os.Stat(webpPath); err == nil && !st.IsDir() && st.Size() > 0 { if st, err := os.Stat(jpgPath); err == nil && !st.IsDir() && st.Size() > 0 {
servePreviewWebPFile(w, r, webpPath) servePreviewJPGFile(w, r, jpgPath)
return return
} }
} }
@ -1513,9 +1503,9 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri
if file := strings.TrimSpace(r.URL.Query().Get("file")); file != "" { if file := strings.TrimSpace(r.URL.Query().Get("file")); file != "" {
low := strings.ToLower(strings.TrimSpace(file)) low := strings.ToLower(strings.TrimSpace(file))
// ✅ preview.webp weiterhin hier behandeln // ✅ preview.jpg weiterhin hier behandeln
if low == "preview.webp" { if low == "preview.jpg" {
servePreviewWebPAlias(w, r, id) servePreviewJPGAlias(w, r, id)
return return
} }
@ -1524,7 +1514,7 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri
return return
} }
// WebP preview (running jobs have live thumb behavior) // JPG preview (running jobs have live thumb behavior)
jobsMu.Lock() jobsMu.Lock()
job, ok := jobs[id] job, ok := jobs[id]
jobsMu.Unlock() jobsMu.Unlock()
@ -1533,9 +1523,9 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri
if job.Status == JobRunning { if job.Status == JobRunning {
assetID := assetIDForJob(job) assetID := assetIDForJob(job)
if assetID != "" { if assetID != "" {
if webpPath, err := generatedThumbWebPFile(assetID); err == nil { if jpgPath, err := generatedThumbJPGFile(assetID); err == nil {
if st, err := os.Stat(webpPath); err == nil && !st.IsDir() && st.Size() > 0 { if st, err := os.Stat(jpgPath); err == nil && !st.IsDir() && st.Size() > 0 {
serveLivePreviewWebPFile(w, r, webpPath) serveLivePreviewJPGFile(w, r, jpgPath)
return return
} }
} }
@ -1543,8 +1533,8 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri
} }
job.previewMu.Lock() job.previewMu.Lock()
cached := job.previewWebp cached := job.previewJPG
cachedAt := job.previewWebpAt cachedAt := job.previewJPGAt
fresh := len(cached) > 0 && !cachedAt.IsZero() && time.Since(cachedAt) < 8*time.Second fresh := len(cached) > 0 && !cachedAt.IsZero() && time.Since(cachedAt) < 8*time.Second
if !fresh && !job.previewGen { if !fresh && !job.previewGen {
@ -1561,7 +1551,7 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri
previewDir := strings.TrimSpace(j.PreviewDir) previewDir := strings.TrimSpace(j.PreviewDir)
if previewDir != "" { if previewDir != "" {
img, genErr = extractLastFrameFromPreviewDirWebP(previewDir) img, genErr = extractLastFrameFromPreviewDirJPG(previewDir)
} }
if genErr != nil || len(img) == 0 { if genErr != nil || len(img) == 0 {
@ -1574,9 +1564,9 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri
} }
} }
if fi, err := os.Stat(outPath); err == nil && !fi.IsDir() && fi.Size() > 0 { if fi, err := os.Stat(outPath); err == nil && !fi.IsDir() && fi.Size() > 0 {
img, genErr = extractLastFrameWebP(outPath) img, genErr = extractLastFrameJPG(outPath)
if genErr != nil { if genErr != nil {
img, _ = extractFirstFrameWebPScaled(outPath, 720, 75) img, _ = extractFirstFrameJPGScaled(outPath, 720, 75)
} }
} }
} }
@ -1584,8 +1574,8 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri
if len(img) > 0 { if len(img) > 0 {
j.previewMu.Lock() j.previewMu.Lock()
j.previewWebp = img j.previewJPG = img
j.previewWebpAt = time.Now() j.previewJPGAt = time.Now()
j.previewMu.Unlock() j.previewMu.Unlock()
} }
}(job) }(job)
@ -1595,7 +1585,7 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri
job.previewMu.Unlock() job.previewMu.Unlock()
if len(out) > 0 { if len(out) > 0 {
serveLivePreviewWebPBytes(w, out) serveLivePreviewJPGBytes(w, out)
return return
} }
@ -1621,7 +1611,7 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri
servePreviewForFinishedFile(w, r, id) servePreviewForFinishedFile(w, r, id)
} }
func updateLiveThumbWebPOnce(ctx context.Context, job *RecordJob) { func updateLiveThumbJPGOnce(ctx context.Context, job *RecordJob) {
jobsMu.Lock() jobsMu.Lock()
status := job.Status status := job.Status
previewDir := job.PreviewDir previewDir := job.PreviewDir
@ -1633,7 +1623,7 @@ func updateLiveThumbWebPOnce(ctx context.Context, job *RecordJob) {
} }
assetID := assetIDForJob(job) assetID := assetIDForJob(job)
thumbPath, err := generatedThumbWebPFile(assetID) thumbPath, err := generatedThumbJPGFile(assetID)
if err != nil { if err != nil {
return return
} }
@ -1655,12 +1645,12 @@ func updateLiveThumbWebPOnce(ctx context.Context, job *RecordJob) {
var img []byte var img []byte
if previewDir != "" { if previewDir != "" {
if b, err := extractLastFrameFromPreviewDirThumbWebP(previewDir); err == nil && len(b) > 0 { if b, err := extractLastFrameFromPreviewDirThumbJPG(previewDir); err == nil && len(b) > 0 {
img = b img = b
} }
} }
if len(img) == 0 && out != "" { if len(img) == 0 && out != "" {
if b, err := extractLastFrameWebPScaled(out, 320, 70); err == nil && len(b) > 0 { if b, err := extractLastFrameJPGScaled(out, 320, 70); err == nil && len(b) > 0 {
img = b img = b
} }
} }
@ -1670,7 +1660,7 @@ func updateLiveThumbWebPOnce(ctx context.Context, job *RecordJob) {
_ = atomicWriteFile(thumbPath, img) _ = atomicWriteFile(thumbPath, img)
} }
func startLiveThumbWebPLoop(ctx context.Context, job *RecordJob) { func startLiveThumbJPGLoop(ctx context.Context, job *RecordJob) {
jobsMu.Lock() jobsMu.Lock()
if job.LiveThumbStarted { if job.LiveThumbStarted {
jobsMu.Unlock() jobsMu.Unlock()
@ -1680,7 +1670,7 @@ func startLiveThumbWebPLoop(ctx context.Context, job *RecordJob) {
jobsMu.Unlock() jobsMu.Unlock()
go func() { go func() {
updateLiveThumbWebPOnce(ctx, job) updateLiveThumbJPGOnce(ctx, job)
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
@ -1692,7 +1682,7 @@ func startLiveThumbWebPLoop(ctx context.Context, job *RecordJob) {
if st != JobRunning { if st != JobRunning {
return return
} }
updateLiveThumbWebPOnce(ctx, job) updateLiveThumbJPGOnce(ctx, job)
} }
} }
}() }()
@ -1734,17 +1724,17 @@ func servePreviewForFinishedFile(w http.ResponseWriter, r *http.Request, id stri
sec = 0 sec = 0
} }
img, err := extractFrameAtTimeWebP(outPath, sec) img, err := extractFrameAtTimeJPG(outPath, sec)
if err == nil && len(img) > 0 { if err == nil && len(img) > 0 {
servePreviewWebPBytes(w, img) servePreviewJPGBytes(w, img)
return return
} }
} }
} }
thumbPath := filepath.Join(assetDir, "preview.webp") thumbPath := filepath.Join(assetDir, "preview.jpg")
if fi, err := os.Stat(thumbPath); err == nil && !fi.IsDir() && fi.Size() > 0 { if fi, err := os.Stat(thumbPath); err == nil && !fi.IsDir() && fi.Size() > 0 {
servePreviewWebPFile(w, r, thumbPath) servePreviewJPGFile(w, r, thumbPath)
return return
} }
@ -1752,7 +1742,7 @@ func servePreviewForFinishedFile(w http.ResponseWriter, r *http.Request, id stri
defer cancel() defer cancel()
// ✅ Immer letztes Frame bevorzugen // ✅ Immer letztes Frame bevorzugen
img, err := extractLastFrameWebP(outPath) img, err := extractLastFrameJPG(outPath)
if err != nil || len(img) == 0 { if err != nil || len(img) == 0 {
// Fallback: kurz vor Ende, falls Duration verfügbar // Fallback: kurz vor Ende, falls Duration verfügbar
@ -1761,12 +1751,12 @@ func servePreviewForFinishedFile(w http.ResponseWriter, r *http.Request, id stri
if t < 0 { if t < 0 {
t = 0 t = 0
} }
img, err = extractFrameAtTimeWebP(outPath, t) img, err = extractFrameAtTimeJPG(outPath, t)
} }
// Letzter Fallback: erstes Frame // Letzter Fallback: erstes Frame
if err != nil || len(img) == 0 { if err != nil || len(img) == 0 {
img, err = extractFirstFrameWebPScaled(outPath, 720, 75) img, err = extractFirstFrameJPGScaled(outPath, 720, 75)
if err != nil || len(img) == 0 { if err != nil || len(img) == 0 {
http.Error(w, "konnte preview nicht erzeugen", http.StatusInternalServerError) http.Error(w, "konnte preview nicht erzeugen", http.StatusInternalServerError)
return return
@ -1775,7 +1765,7 @@ func servePreviewForFinishedFile(w http.ResponseWriter, r *http.Request, id stri
} }
_ = atomicWriteFile(thumbPath, img) _ = atomicWriteFile(thumbPath, img)
servePreviewWebPBytes(w, img) servePreviewJPGBytes(w, img)
} }
// ============================================================ // ============================================================

View File

@ -320,7 +320,7 @@ func previewSpriteTruthForID(id string) previewSpriteMetaResp {
} }
genDir := filepath.Dir(metaPath) genDir := filepath.Dir(metaPath)
spriteFile := filepath.Join(genDir, "preview-sprite.webp") spriteFile := filepath.Join(genDir, "preview-sprite.jpg")
fi, err := os.Stat(spriteFile) fi, err := os.Stat(spriteFile)
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {

View File

@ -230,7 +230,7 @@ func recordPreviewSprite(w http.ResponseWriter, r *http.Request) {
return return
} }
spritePath := filepath.Join(dir, "preview-sprite.webp") spritePath := filepath.Join(dir, "preview-sprite.jpg")
fi, err := os.Stat(spritePath) fi, err := os.Stat(spritePath)
if err != nil || fi.IsDir() || fi.Size() <= 0 { if err != nil || fi.IsDir() || fi.Size() <= 0 {
@ -245,11 +245,11 @@ func recordPreviewSprite(w http.ResponseWriter, r *http.Request) {
} }
defer f.Close() defer f.Close()
w.Header().Set("Content-Type", "image/webp") w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Cache-Control", "private, max-age=31536000, immutable") w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Content-Type-Options", "nosniff")
http.ServeContent(w, r, "preview-sprite.webp", fi.ModTime(), f) http.ServeContent(w, r, "preview-sprite.jpg", fi.ModTime(), f)
} }
// ---------------- Start + run job ---------------- // ---------------- Start + run job ----------------
@ -703,30 +703,39 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
setPhase("analyze", 5) setPhase("analyze", 5)
{ {
actx, cancel := context.WithTimeout(ctx, 45*time.Second) actx, cancel := context.WithTimeout(ctx, 45*time.Second)
defer cancel()
durationSec, _ := durationSecondsForAnalyze(actx, out) id := assetIDFromVideoPath(out)
hits, aerr := analyzeVideoFromSprite(actx, out, "nsfw") if strings.TrimSpace(id) == "" {
if aerr != nil { fmt.Println("⚠️ postwork analyze: keine asset id ableitbar")
fmt.Println("⚠️ postwork analyze:", aerr)
} else { } else {
setPhase("analyze", 65) ps := previewSpriteTruthForID(id)
if !ps.Exists {
fmt.Println("⚠️ postwork analyze: preview-sprite.jpg nicht gefunden")
} else {
durationSec, _ := durationSecondsForAnalyze(actx, out)
hits, aerr := analyzeVideoFromSprite(actx, out, "nsfw")
if aerr != nil {
fmt.Println("⚠️ postwork analyze:", aerr)
} else {
setPhase("analyze", 65)
segments := buildSegmentsFromAnalyzeHits(hits, durationSec) segments := buildSegmentsFromAnalyzeHits(hits, durationSec)
ai := &aiAnalysisMeta{ ai := &aiAnalysisMeta{
Goal: "nsfw", Goal: "nsfw",
Mode: "sprite", Mode: "sprite",
Hits: hits, Hits: hits,
Segments: segments, Segments: segments,
AnalyzedAtUnix: time.Now().Unix(), AnalyzedAtUnix: time.Now().Unix(),
} }
if werr := writeVideoAIForFile(actx, out, job.SourceURL, ai); werr != nil { if werr := writeVideoAIForFile(actx, out, job.SourceURL, ai); werr != nil {
fmt.Println("⚠️ writeVideoAIForFile:", werr) fmt.Println("⚠️ writeVideoAIForFile:", werr)
}
}
} }
} }
cancel()
} }
setPhase("analyze", 100) setPhase("analyze", 100)

View File

@ -15,5 +15,5 @@
"teaserPlayback": "hover", "teaserPlayback": "hover",
"teaserAudio": false, "teaserAudio": false,
"enableNotifications": true, "enableNotifications": true,
"encryptedCookies": "3EPvjFs7b4JIdKUT3G2fOZKc26YmYL283VVHmG+dCLAUe+xURUkM0rZMCrf8Ug7eyXZOreLItE09FSCZrA3afNgmHg5c648hhvYhkv/mW7J8ap4tMz1m8ahcvcfoLhrx5AqU4MWXnqz+VHHglqkfPn9aFcrgFnWbOPHJ1A3S77cs2gWR0/shqn3l8nk6HmIWqJ1TnAA6z2CYDngB27sv/NflLKoujezlWitEa8wEpEW8GDSEtPjpT7X9L24wP4TK/TnxZUovaRXDDbboebk2KeKP04C5tWhhpIfKl3/ipf9dPgHdV4jLheFyczMRZN5Z6yF5WRn3NgDbdCcoldRwqgTwv1NgLri8nJKp4SGmRpGFrbq6m7/26muyGbTzsU3tniae6iYHbYrPz0pMOBLcFPxnil4yT0Xgnph+P9EYYWJxtjUXi7nsiREjHBxqU/OSogavsOjlFqJgWBBCL705R2Fap0VjlgWtJEXKu+vAlexX873uoeFzFw9niwJlNRFKJtGMjJGYE5c=" "encryptedCookies": ""
} }

View File

@ -171,10 +171,10 @@ func loadSettings() {
// ffmpeg-Pfad anhand Settings/Env/PATH bestimmen // ffmpeg-Pfad anhand Settings/Env/PATH bestimmen
ffmpegPath = detectFFmpegPath() ffmpegPath = detectFFmpegPath()
fmt.Println("🔍 ffmpegPath:", ffmpegPath) //fmt.Println("🔍 ffmpegPath:", ffmpegPath)
ffprobePath = detectFFprobePath() ffprobePath = detectFFprobePath()
fmt.Println("🔍 ffprobePath:", ffprobePath) //fmt.Println("🔍 ffprobePath:", ffprobePath)
} }

View File

@ -376,7 +376,7 @@ export default function CategoriesTab() {
} }
} }
const thumb = `/generated/meta/${encodeURIComponent(id)}/preview.webp` const thumb = `/generated/meta/${encodeURIComponent(id)}/preview.jpg`
const model = modelKeyFromFilename(pick) const model = modelKeyFromFilename(pick)
await ensureCover(r.tag, thumb, model, true) await ensureCover(r.tag, thumb, model, true)

View File

@ -1,3 +1,5 @@
// frontend\src\components\ui\CookieModal.tsx
'use client' 'use client'
import { Dialog } from '@headlessui/react' import { Dialog } from '@headlessui/react'
@ -24,7 +26,6 @@ export default function CookieModal({
const [cookies, setCookies] = useState<CookieEntry[]>([]) const [cookies, setCookies] = useState<CookieEntry[]>([])
const wasOpen = useRef(false) const wasOpen = useRef(false)
// ✅ Beim Öffnen: Inputs resetten UND Cookies aus Props übernehmen
useEffect(() => { useEffect(() => {
if (open && !wasOpen.current) { if (open && !wasOpen.current) {
setName('') setName('')
@ -59,72 +60,167 @@ export default function CookieModal({
return ( return (
<Dialog open={open} onClose={onClose} className="relative z-50"> <Dialog open={open} onClose={onClose} className="relative z-50">
<div className="fixed inset-0 bg-black/40" aria-hidden="true" /> <div className="fixed inset-0 bg-black/40 backdrop-blur-[2px]" aria-hidden="true" />
<div className="fixed inset-0 flex items-center justify-center p-4"> <div className="fixed inset-0 flex items-center justify-center p-4">
<Dialog.Panel className="w-full max-w-lg rounded-lg bg-white dark:bg-gray-800 p-6 shadow-xl dark:outline dark:-outline-offset-1 dark:outline-white/10"> <Dialog.Panel
<Dialog.Title className="text-base font-semibold text-gray-900 dark:text-white"> className="
Zusätzliche Cookies w-full max-w-2xl rounded-xl
</Dialog.Title> border border-gray-200/80 bg-white shadow-xl
dark:border-white/10 dark:bg-gray-800 dark:outline dark:-outline-offset-1 dark:outline-white/10
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-2"> "
<input >
value={name} <div className="border-b border-gray-200/70 px-6 py-4 dark:border-white/10">
onChange={(e) => setName(e.target.value)} <Dialog.Title className="text-base font-semibold text-gray-900 dark:text-white">
placeholder="Name (z. B. cf_clearance)" Zusätzliche Cookies
className="col-span-1 truncate rounded-md px-3 py-2 text-sm bg-white text-gray-900 dark:bg-white/10 dark:text-white" </Dialog.Title>
/> <p className="mt-1 text-sm text-gray-600 dark:text-gray-300">
<input Füge zusätzliche Cookies hinzu oder aktualisiere bestehende Werte.
value={value} </p>
onChange={(e) => setValue(e.target.value)}
placeholder="Wert"
className="col-span-1 truncate sm:col-span-2 rounded-md px-3 py-2 text-sm bg-white text-gray-900 dark:bg-white/10 dark:text-white"
/>
</div> </div>
<div className="mt-2"> <div className="space-y-4 px-6 py-5">
<Button size="sm" variant="secondary" onClick={addCookie} disabled={!name.trim() || !value.trim()}> <div className="rounded-lg border border-gray-200/70 bg-gray-50/70 p-3 dark:border-white/10 dark:bg-white/5">
Hinzufügen <div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
</Button> <div className="sm:col-span-1">
</div> <label className="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">
Name
</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="z. B. cf_clearance"
className="
w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm
placeholder:text-gray-400
focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20
dark:border-white/10 dark:bg-white/10 dark:text-white dark:placeholder:text-gray-400
dark:focus:border-indigo-400 dark:focus:ring-indigo-400/20
"
/>
</div>
<div className="mt-4"> <div className="sm:col-span-2">
{cookies.length === 0 ? ( <label className="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">
<div className="text-sm text-gray-500 dark:text-gray-400">Noch keine Cookies hinzugefügt.</div> Wert
) : ( </label>
<table className="min-w-full text-sm border divide-y dark:divide-white/10"> <input
<thead className="bg-gray-50 dark:bg-gray-700/50"> value={value}
<tr> onChange={(e) => setValue(e.target.value)}
<th className="px-3 py-2 text-left font-medium">Name</th> placeholder="Cookie-Wert"
<th className="px-3 py-2 text-left font-medium">Wert</th> className="
<th className="px-3 py-2" /> w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm
</tr> placeholder:text-gray-400
</thead> focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20
<tbody className="divide-y dark:divide-white/10"> dark:border-white/10 dark:bg-white/10 dark:text-white dark:placeholder:text-gray-400
{cookies.map((c) => ( dark:focus:border-indigo-400 dark:focus:ring-indigo-400/20
<tr key={c.name}> "
<td className="px-3 py-2 font-mono">{c.name}</td> />
<td className="px-3 py-2 truncate max-w-[240px]">{c.value}</td> </div>
<td className="px-3 py-2 text-right"> </div>
<button
onClick={() => removeCookie(c.name)} <div className="mt-3 flex justify-end">
className="text-xs text-red-600 hover:underline dark:text-red-400" <Button
size="sm"
variant="secondary"
onClick={addCookie}
disabled={!name.trim() || !value.trim()}
>
Hinzufügen
</Button>
</div>
</div>
<div className="rounded-lg border border-gray-200/70 bg-white/70 shadow-sm dark:border-white/10 dark:bg-white/5">
<div className="border-b border-gray-200/70 px-4 py-3 dark:border-white/10">
<div className="text-sm font-semibold text-gray-900 dark:text-white">
Aktuelle Cookies
</div>
<div className="mt-0.5 text-xs text-gray-600 dark:text-gray-300">
{cookies.length} Eintrag{cookies.length === 1 ? '' : 'e'}
</div>
</div>
{cookies.length === 0 ? (
<div className="px-4 py-6 text-sm text-gray-500 dark:text-gray-400">
Noch keine Cookies hinzugefügt.
</div>
) : (
<div className="overflow-hidden">
<table className="min-w-full table-fixed text-sm">
<thead className="bg-gray-50 text-gray-700 dark:bg-white/5 dark:text-gray-200">
<tr className="border-b border-gray-200/70 dark:border-white/10">
<th className="w-[180px] px-4 py-2.5 text-left font-medium">Name</th>
<th className="px-4 py-2.5 text-left font-medium">Wert</th>
<th className="w-[110px] px-4 py-2.5 text-right font-medium">Aktion</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200/70 dark:divide-white/10">
{cookies.map((c) => (
<tr
key={c.name}
className="bg-white hover:bg-gray-50/70 dark:bg-transparent dark:hover:bg-white/5"
> >
Entfernen <td className="px-4 py-3 align-top">
</button> <div
</td> className="
</tr> inline-flex max-w-full items-center rounded-md
))} border border-gray-200 bg-gray-50 px-2 py-1
</tbody> font-mono text-xs text-gray-800
</table> dark:border-white/10 dark:bg-white/5 dark:text-gray-100
)} "
title={c.name}
>
<span className="truncate">{c.name}</span>
</div>
</td>
<td className="px-4 py-3 align-top">
<div
className="
rounded-md border border-gray-200 bg-gray-50 px-3 py-2
font-mono text-xs text-gray-700
dark:border-white/10 dark:bg-black/10 dark:text-gray-200
"
title={c.value}
>
<span className="block truncate">{c.value}</span>
</div>
</td>
<td className="px-4 py-3 text-right align-top">
<button
type="button"
onClick={() => removeCookie(c.name)}
className="
inline-flex items-center rounded-md px-2 py-1 text-xs font-medium
text-red-700 hover:bg-red-50
dark:text-red-300 dark:hover:bg-red-500/10
"
>
Entfernen
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div> </div>
<div className="mt-6 flex justify-end gap-2"> <div className="flex justify-end gap-2 border-t border-gray-200/70 px-6 py-4 dark:border-white/10">
<Button variant="secondary" onClick={onClose}>Abbrechen</Button> <Button variant="secondary" onClick={onClose}>
<Button variant="primary" onClick={applyAndClose}>Übernehmen</Button> Abbrechen
</Button>
<Button variant="primary" onClick={applyAndClose}>
Übernehmen
</Button>
</div> </div>
</Dialog.Panel> </Dialog.Panel>
</div> </div>
</Dialog> </Dialog>
) )
} }

View File

@ -223,20 +223,20 @@ const absUrlMaybe = (u?: string | null): string => {
return `/${s}` return `/${s}`
} }
const jobThumbsWebpCandidates = (job: RecordJob): string[] => { const jobThumbsJPGCandidates = (job: RecordJob): string[] => {
const j = job as any const j = job as any
const direct = [ const direct = [
j.thumbsWebpUrl, j.thumbsJPGUrl,
j.thumbsUrl, j.thumbsUrl,
j.previewThumbsUrl, j.previewThumbsUrl,
j.thumbnailSheetUrl, j.thumbnailSheetUrl,
] ]
const base = [ const base = [
j.previewBaseUrl ? `${String(j.previewBaseUrl).replace(/\/+$/, '')}/preview.webp` : '', j.previewBaseUrl ? `${String(j.previewBaseUrl).replace(/\/+$/, '')}/preview.jpg` : '',
j.assetBaseUrl ? `${String(j.assetBaseUrl).replace(/\/+$/, '')}/preview.webp` : '', j.assetBaseUrl ? `${String(j.assetBaseUrl).replace(/\/+$/, '')}/preview.jpg` : '',
j.thumbsBaseUrl ? `${String(j.thumbsBaseUrl).replace(/\/+$/, '')}/preview.webp` : '', j.thumbsBaseUrl ? `${String(j.thumbsBaseUrl).replace(/\/+$/, '')}/preview.jpg` : '',
] ]
return [...direct, ...base] return [...direct, ...base]
@ -656,7 +656,7 @@ function DownloadsCardRow({
fastRetryMs={1000} fastRetryMs={1000}
fastRetryMax={25} fastRetryMax={25}
fastRetryWindowMs={60_000} fastRetryWindowMs={60_000}
thumbsCandidates={jobThumbsWebpCandidates(j)} thumbsCandidates={jobThumbsJPGCandidates(j)}
className="w-full h-full" className="w-full h-full"
/> />
</div> </div>
@ -1297,7 +1297,7 @@ export default function Downloads({
fastRetryMs={1000} fastRetryMs={1000}
fastRetryMax={25} fastRetryMax={25}
fastRetryWindowMs={60_000} fastRetryWindowMs={60_000}
thumbsCandidates={jobThumbsWebpCandidates(j)} thumbsCandidates={jobThumbsJPGCandidates(j)}
className="w-full h-full" className="w-full h-full"
/> />
</div> </div>

View File

@ -587,7 +587,7 @@ export default function FinishedDownloadsGalleryView({
</div> </div>
{/* Footer / Meta */} {/* Footer / Meta */}
<div className="relative min-h-[118px] px-4 py-3 rounded-b-lg border-t border-black/5 dark:border-white/10 bg-white dark:bg-gray-900"> <div className="relative flex min-h-[118px] flex-col px-4 py-3 rounded-b-lg border-t border-black/5 dark:border-white/10 bg-white dark:bg-gray-900">
{/* ✅ stashapp-like: Dateiname zuerst */} {/* ✅ stashapp-like: Dateiname zuerst */}
<div className="min-w-0"> <div className="min-w-0">
<div className="mt-0.5 flex items-start gap-2 min-w-0"> <div className="mt-0.5 flex items-start gap-2 min-w-0">
@ -641,7 +641,7 @@ export default function FinishedDownloadsGalleryView({
{/* Actions (wie CardView: im Footer statt im Video) */} {/* Actions (wie CardView: im Footer statt im Video) */}
<div <div
className="mt-2" className="mt-2 shrink-0"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
> >

View File

@ -342,7 +342,7 @@ export default function FinishedDownloadsTableView({
</div> </div>
{tags.length > 0 ? ( {tags.length > 0 ? (
<div className="mt-1" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}> <div className="mt-1 py-0.5 overflow-visible" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
<TagOverflowRow <TagOverflowRow
rowKey={keyFor(j)} rowKey={keyFor(j)}
tags={tags} tags={tags}
@ -552,7 +552,7 @@ export default function FinishedDownloadsTableView({
) )
return ( return (
<div className="relative"> <div className="relative overflow-x-auto rounded-2xl border border-gray-200/80 bg-white/80 p-2 shadow-sm dark:border-white/10 dark:bg-transparent dark:p-0">
<Table <Table
rows={rows} rows={rows}
columns={columns} columns={columns}
@ -569,7 +569,7 @@ export default function FinishedDownloadsTableView({
/> />
{isLoading && rows.length === 0 ? ( {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="absolute inset-0 z-20 grid place-items-center rounded-2xl 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="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="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 className="text-sm font-medium text-gray-800 dark:text-gray-100">Lade</div>

View File

@ -1160,17 +1160,7 @@ export default function ModelDetails({
/> />
{/* Pills */} {/* Pills */}
<div className="absolute left-3 top-3 flex flex-wrap items-center gap-2"> <div className="absolute left-3 top-3 flex flex-wrap items-center gap-2">
{showPill ? (
<span
className={pill(
'bg-emerald-500/10 text-emerald-900 ring-emerald-200 backdrop-blur dark:text-emerald-200 dark:ring-emerald-400/20'
)}
>
{showPill}
</span>
) : null}
{effectivePresenceLabel ? ( {effectivePresenceLabel ? (
<span <span
className={pill( className={pill(
@ -1225,9 +1215,11 @@ export default function ModelDetails({
handleToggleWatchModel() handleToggleWatchModel()
}} }}
className={cn( className={cn(
'inline-flex items-center justify-center rounded-full p-1.5 ring-1 ring-inset backdrop-blur', 'inline-flex items-center justify-center rounded-full p-1.5 ring-1 backdrop-blur shadow-sm',
'cursor-pointer hover:scale-[1.03] active:scale-[0.98] transition', 'cursor-pointer transition hover:scale-[1.03] active:scale-[0.98]',
model?.watching ? 'bg-sky-500/25 ring-sky-200/30' : 'bg-black/20 ring-white/15' model?.watching
? 'bg-sky-100/95 text-sky-700 ring-sky-200 hover:bg-sky-200/95 dark:bg-sky-500/25 dark:text-sky-200 dark:ring-sky-200/30 dark:hover:bg-sky-500/30'
: 'bg-white/90 text-gray-700 ring-gray-200 hover:bg-white dark:bg-black/20 dark:text-white dark:ring-white/15 dark:hover:bg-black/30'
)} )}
title={model?.watching ? 'Watch entfernen' : 'Auf Watch setzen'} title={model?.watching ? 'Watch entfernen' : 'Auf Watch setzen'}
aria-pressed={Boolean(model?.watching)} aria-pressed={Boolean(model?.watching)}
@ -1238,14 +1230,14 @@ export default function ModelDetails({
className={cn( className={cn(
'absolute inset-0 size-4 transition-all duration-200 ease-out motion-reduce:transition-none', 'absolute inset-0 size-4 transition-all duration-200 ease-out motion-reduce:transition-none',
model?.watching ? 'opacity-0 scale-75 rotate-12' : 'opacity-100 scale-100 rotate-0', model?.watching ? 'opacity-0 scale-75 rotate-12' : 'opacity-100 scale-100 rotate-0',
'text-white/70' 'text-gray-600 dark:text-white/70'
)} )}
/> />
<EyeSolidIcon <EyeSolidIcon
className={cn( className={cn(
'absolute inset-0 size-4 transition-all duration-200 ease-out motion-reduce:transition-none', 'absolute inset-0 size-4 transition-all duration-200 ease-out motion-reduce:transition-none',
model?.watching ? 'opacity-100 scale-110 rotate-0' : 'opacity-0 scale-75 -rotate-12', model?.watching ? 'opacity-100 scale-110 rotate-0' : 'opacity-0 scale-75 -rotate-12',
'text-sky-200' 'text-sky-600 dark:text-sky-200'
)} )}
/> />
</span> </span>
@ -1260,9 +1252,11 @@ export default function ModelDetails({
handleToggleFavoriteModel() handleToggleFavoriteModel()
}} }}
className={cn( className={cn(
'inline-flex items-center justify-center rounded-full p-1.5 ring-1 ring-inset backdrop-blur', 'inline-flex items-center justify-center rounded-full p-1.5 ring-1 backdrop-blur shadow-sm',
'cursor-pointer hover:scale-[1.03] active:scale-[0.98] transition', 'cursor-pointer transition hover:scale-[1.03] active:scale-[0.98]',
model?.favorite ? 'bg-amber-500/25 ring-amber-200/30' : 'bg-black/20 ring-white/15' model?.favorite
? 'bg-amber-100/95 text-amber-700 ring-amber-200 hover:bg-amber-200/95 dark:bg-amber-500/25 dark:text-amber-200 dark:ring-amber-200/30 dark:hover:bg-amber-500/30'
: 'bg-white/90 text-gray-700 ring-gray-200 hover:bg-white dark:bg-black/20 dark:text-white dark:ring-white/15 dark:hover:bg-black/30'
)} )}
title={model?.favorite ? 'Favorit entfernen' : 'Als Favorit markieren'} title={model?.favorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
aria-pressed={Boolean(model?.favorite)} aria-pressed={Boolean(model?.favorite)}
@ -1273,14 +1267,14 @@ export default function ModelDetails({
className={cn( className={cn(
'absolute inset-0 size-4 transition-all duration-200 ease-out motion-reduce:transition-none', 'absolute inset-0 size-4 transition-all duration-200 ease-out motion-reduce:transition-none',
model?.favorite ? 'opacity-0 scale-75 rotate-12' : 'opacity-100 scale-100 rotate-0', model?.favorite ? 'opacity-0 scale-75 rotate-12' : 'opacity-100 scale-100 rotate-0',
'text-white/70' 'text-gray-600 dark:text-white/70'
)} )}
/> />
<StarSolidIcon <StarSolidIcon
className={cn( className={cn(
'absolute inset-0 size-4 transition-all duration-200 ease-out motion-reduce:transition-none', 'absolute inset-0 size-4 transition-all duration-200 ease-out motion-reduce:transition-none',
model?.favorite ? 'opacity-100 scale-110 rotate-0' : 'opacity-0 scale-75 -rotate-12', model?.favorite ? 'opacity-100 scale-110 rotate-0' : 'opacity-0 scale-75 -rotate-12',
'text-amber-200' 'text-amber-500 dark:text-amber-200'
)} )}
/> />
</span> </span>
@ -1295,9 +1289,11 @@ export default function ModelDetails({
handleToggleLikeModel() handleToggleLikeModel()
}} }}
className={cn( className={cn(
'inline-flex items-center justify-center rounded-full p-1.5 ring-1 ring-inset backdrop-blur', 'inline-flex items-center justify-center rounded-full p-1.5 ring-1 backdrop-blur shadow-sm',
'cursor-pointer hover:scale-[1.03] active:scale-[0.98] transition', 'cursor-pointer transition hover:scale-[1.03] active:scale-[0.98]',
model?.liked ? 'bg-rose-500/25 ring-rose-200/30' : 'bg-black/20 ring-white/15' model?.liked
? 'bg-rose-100/95 text-rose-700 ring-rose-200 hover:bg-rose-200/95 dark:bg-rose-500/25 dark:text-rose-200 dark:ring-rose-200/30 dark:hover:bg-rose-500/30'
: 'bg-white/90 text-gray-700 ring-gray-200 hover:bg-white dark:bg-black/20 dark:text-white dark:ring-white/15 dark:hover:bg-black/30'
)} )}
title={model?.liked ? 'Like entfernen' : 'Liken'} title={model?.liked ? 'Like entfernen' : 'Liken'}
aria-pressed={model?.liked === true} aria-pressed={model?.liked === true}
@ -1308,14 +1304,14 @@ export default function ModelDetails({
className={cn( className={cn(
'absolute inset-0 size-4 transition-all duration-200 ease-out motion-reduce:transition-none', 'absolute inset-0 size-4 transition-all duration-200 ease-out motion-reduce:transition-none',
model?.liked ? 'opacity-0 scale-75 rotate-12' : 'opacity-100 scale-100 rotate-0', model?.liked ? 'opacity-0 scale-75 rotate-12' : 'opacity-100 scale-100 rotate-0',
'text-white/70' 'text-gray-600 dark:text-white/70'
)} )}
/> />
<HeartSolidIcon <HeartSolidIcon
className={cn( className={cn(
'absolute inset-0 size-4 transition-all duration-200 ease-out motion-reduce:transition-none', 'absolute inset-0 size-4 transition-all duration-200 ease-out motion-reduce:transition-none',
model?.liked ? 'opacity-100 scale-110 rotate-0' : 'opacity-0 scale-75 -rotate-12', model?.liked ? 'opacity-100 scale-110 rotate-0' : 'opacity-0 scale-75 -rotate-12',
'text-rose-200' 'text-rose-500 dark:text-rose-200'
)} )}
/> />
</span> </span>

View File

@ -27,7 +27,7 @@ type Props = {
fastRetryMax?: number fastRetryMax?: number
fastRetryWindowMs?: number fastRetryWindowMs?: number
thumbsWebpUrl?: string | null thumbsJPGUrl?: string | null
thumbsCandidates?: Array<string | null | undefined> thumbsCandidates?: Array<string | null | undefined>
} }
@ -44,7 +44,7 @@ export default function ModelPreview({
fastRetryMs, fastRetryMs,
fastRetryMax, fastRetryMax,
fastRetryWindowMs, fastRetryWindowMs,
thumbsWebpUrl, thumbsJPGUrl,
thumbsCandidates, thumbsCandidates,
}: Props) { }: Props) {
const blurCls = blur ? 'blur-md' : '' const blurCls = blur ? 'blur-md' : ''
@ -92,7 +92,7 @@ export default function ModelPreview({
const thumbsCandidatesKey = useMemo(() => { const thumbsCandidatesKey = useMemo(() => {
const list = [ const list = [
thumbsWebpUrl, thumbsJPGUrl,
...(Array.isArray(thumbsCandidates) ? thumbsCandidates : []), ...(Array.isArray(thumbsCandidates) ? thumbsCandidates : []),
] ]
.map(normalizeUrl) .map(normalizeUrl)
@ -100,7 +100,7 @@ export default function ModelPreview({
// Reihenfolge behalten, nur dedupe // Reihenfolge behalten, nur dedupe
return Array.from(new Set(list)).join('|') return Array.from(new Set(list)).join('|')
}, [thumbsWebpUrl, thumbsCandidates]) }, [thumbsJPGUrl, thumbsCandidates])
// ✅ visibilitychange -> nur REF updaten // ✅ visibilitychange -> nur REF updaten
useEffect(() => { useEffect(() => {
@ -383,7 +383,7 @@ export default function ModelPreview({
else setApiImgError(false) else setApiImgError(false)
}} }}
onError={() => { onError={() => {
// 1) Wenn direkte preview.webp fehlschlägt -> auf API-Fallback umschalten // 1) Wenn direkte preview.jpg fehlschlägt -> auf API-Fallback umschalten
if (useDirectThumb) { if (useDirectThumb) {
setDirectImgError(true) setDirectImgError(true)
return return

View File

@ -1451,8 +1451,8 @@ export default function ModelsTab() {
className={clsx( className={clsx(
'h-8 min-w-0 px-0 shadow-none', 'h-8 min-w-0 px-0 shadow-none',
watch watch
? 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-500/20 dark:text-indigo-200 dark:hover:bg-indigo-500/30' ? 'bg-indigo-50 text-indigo-700 ring-1 ring-indigo-200 shadow-xs hover:bg-indigo-100 dark:bg-indigo-500/20 dark:text-indigo-200 dark:ring-0 dark:shadow-none dark:hover:bg-indigo-500/30'
: 'bg-white text-gray-700 ring-1 ring-gray-200 hover:bg-gray-100 dark:bg-white/5 dark:text-slate-200 dark:ring-0 dark:hover:bg-white/10' : 'bg-white text-gray-700 ring-1 ring-gray-200 shadow-none hover:bg-gray-100 dark:bg-white/5 dark:text-slate-200 dark:ring-0 dark:hover:bg-white/10'
)} )}
title={watch ? 'Nicht mehr beobachten' : 'Beobachten'} title={watch ? 'Nicht mehr beobachten' : 'Beobachten'}
onClick={(e) => { onClick={(e) => {
@ -1473,8 +1473,8 @@ export default function ModelsTab() {
className={clsx( className={clsx(
'h-8 min-w-0 px-0 shadow-none', 'h-8 min-w-0 px-0 shadow-none',
fav fav
? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-500/20 dark:text-amber-200 dark:hover:bg-amber-500/30' ? 'bg-amber-50 text-amber-800 ring-1 ring-amber-200 shadow-xs hover:bg-amber-100 dark:bg-amber-500/20 dark:text-amber-200 dark:ring-0 dark:shadow-none dark:hover:bg-amber-500/30'
: 'bg-white text-gray-700 ring-1 ring-gray-200 hover:bg-gray-100 dark:bg-white/5 dark:text-slate-200 dark:ring-0 dark:hover:bg-white/10' : 'bg-white text-gray-700 ring-1 ring-gray-200 shadow-none hover:bg-gray-100 dark:bg-white/5 dark:text-slate-200 dark:ring-0 dark:hover:bg-white/10'
)} )}
title={fav ? 'Favorit entfernen' : 'Als Favorit markieren'} title={fav ? 'Favorit entfernen' : 'Als Favorit markieren'}
onClick={(e) => { onClick={(e) => {
@ -1496,8 +1496,8 @@ export default function ModelsTab() {
className={clsx( className={clsx(
'h-8 min-w-0 px-0 shadow-none', 'h-8 min-w-0 px-0 shadow-none',
liked liked
? 'bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-500/20 dark:text-rose-200 dark:hover:bg-rose-500/30' ? 'bg-rose-50 text-rose-700 ring-1 ring-rose-200 shadow-xs hover:bg-rose-100 dark:bg-rose-500/20 dark:text-rose-200 dark:ring-0 dark:shadow-none dark:hover:bg-rose-500/30'
: 'bg-white text-gray-700 ring-1 ring-gray-200 hover:bg-gray-100 dark:bg-white/5 dark:text-slate-200 dark:ring-0 dark:hover:bg-white/10' : 'bg-white text-gray-700 ring-1 ring-gray-200 shadow-none hover:bg-gray-100 dark:bg-white/5 dark:text-slate-200 dark:ring-0 dark:hover:bg-white/10'
)} )}
title={liked ? 'Gefällt mir entfernen' : 'Gefällt mir'} title={liked ? 'Gefällt mir entfernen' : 'Gefällt mir'}
onClick={(e) => { onClick={(e) => {
@ -1546,8 +1546,8 @@ export default function ModelsTab() {
? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity' ? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity'
: 'opacity-100', : 'opacity-100',
watch watch
? 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-500/20 dark:text-indigo-200 dark:hover:bg-indigo-500/30' ? 'bg-indigo-50 text-indigo-700 ring-1 ring-indigo-200 shadow-xs hover:bg-indigo-100 dark:bg-indigo-500/20 dark:text-indigo-200 dark:ring-0 dark:shadow-none dark:hover:bg-indigo-500/30'
: 'bg-white text-gray-700 ring-1 ring-gray-200 hover:bg-gray-100 dark:bg-white/5 dark:text-slate-200 dark:ring-0 dark:hover:bg-white/10' : 'bg-white text-gray-700 ring-1 ring-gray-200 shadow-none hover:bg-gray-100 dark:bg-white/5 dark:text-slate-200 dark:ring-0 dark:hover:bg-white/10'
)} )}
title={watch ? 'Nicht mehr beobachten' : 'Beobachten'} title={watch ? 'Nicht mehr beobachten' : 'Beobachten'}
onClick={(e) => { onClick={(e) => {
@ -1571,8 +1571,8 @@ export default function ModelsTab() {
? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity' ? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity'
: 'opacity-100', : 'opacity-100',
fav fav
? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-500/20 dark:text-amber-200 dark:hover:bg-amber-500/30' ? 'bg-amber-50 text-amber-800 ring-1 ring-amber-200 shadow-xs hover:bg-amber-100 dark:bg-amber-500/20 dark:text-amber-200 dark:ring-0 dark:shadow-none dark:hover:bg-amber-500/30'
: 'bg-white text-gray-700 ring-1 ring-gray-200 hover:bg-gray-100 dark:bg-white/5 dark:text-slate-200 dark:ring-0 dark:hover:bg-white/10' : 'bg-white text-gray-700 ring-1 ring-gray-200 shadow-none hover:bg-gray-100 dark:bg-white/5 dark:text-slate-200 dark:ring-0 dark:hover:bg-white/10'
)} )}
title={fav ? 'Favorit entfernen' : 'Als Favorit markieren'} title={fav ? 'Favorit entfernen' : 'Als Favorit markieren'}
onClick={(e) => { onClick={(e) => {
@ -1597,8 +1597,8 @@ export default function ModelsTab() {
? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity' ? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity'
: 'opacity-100', : 'opacity-100',
liked liked
? 'bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-500/20 dark:text-rose-200 dark:hover:bg-rose-500/30' ? 'bg-rose-50 text-rose-700 ring-1 ring-rose-200 shadow-xs hover:bg-rose-100 dark:bg-rose-500/20 dark:text-rose-200 dark:ring-0 dark:shadow-none dark:hover:bg-rose-500/30'
: 'bg-white text-gray-700 ring-1 ring-gray-200 hover:bg-gray-100 dark:bg-white/5 dark:text-slate-200 dark:ring-0 dark:hover:bg-white/10' : 'bg-white text-gray-700 ring-1 ring-gray-200 shadow-none hover:bg-gray-100 dark:bg-white/5 dark:text-slate-200 dark:ring-0 dark:hover:bg-white/10'
)} )}
title={liked ? 'Gefällt mir entfernen' : 'Gefällt mir'} title={liked ? 'Gefällt mir entfernen' : 'Gefällt mir'}
onClick={(e) => { onClick={(e) => {

View File

@ -132,7 +132,7 @@ function PageButton({
roundedCls, roundedCls,
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer', disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
active active
? 'z-10 bg-indigo-600 text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:focus-visible:outline-indigo-500' ? 'z-10 !bg-indigo-600 !text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:focus-visible:outline-indigo-500'
: 'text-gray-900 inset-ring inset-ring-gray-300 hover:bg-gray-50 dark:text-gray-200 dark:inset-ring-gray-700 dark:hover:bg-white/5' : 'text-gray-900 inset-ring inset-ring-gray-300 hover:bg-gray-50 dark:text-gray-200 dark:inset-ring-gray-700 dark:hover:bg-white/5'
)} )}
aria-current={active ? 'page' : undefined} aria-current={active ? 'page' : undefined}

View File

@ -416,7 +416,7 @@ export default function Player({
// Vorschaubild oben // Vorschaubild oben
const previewA = React.useMemo( const previewA = React.useMemo(
() => apiUrl(`/api/preview?id=${encodeURIComponent(previewId)}&file=preview.webp`), () => apiUrl(`/api/preview?id=${encodeURIComponent(previewId)}&file=preview.jpg`),
[previewId] [previewId]
) )

View File

@ -534,35 +534,6 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang
{/* Rechts: Alerts + Button */} {/* Rechts: Alerts + Button */}
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
{/* Alerts links neben Button */}
{saveUiState !== 'success' ? (
<div className="hidden sm:flex min-w-0 max-w-[520px] items-stretch">
{err ? (
<div
className="
inline-flex items-center
px-3 py-[7px] text-sm
rounded-md border border-red-200 bg-red-50 text-red-700
dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200
"
>
{err}
</div>
) : msg ? (
<div
className="
inline-flex items-center
px-3 py-[7px] text-sm
rounded-md border border-green-200 bg-green-50 text-green-700
dark:border-green-500/30 dark:bg-green-500/10 dark:text-green-200
"
>
{msg}
</div>
) : null}
</div>
) : null}
<Button <Button
variant="primary" variant="primary"
color={saveButton.color} color={saveButton.color}
@ -704,7 +675,7 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang
</div> </div>
{/* Datenbank */} {/* Datenbank */}
<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="mb-3 rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-950/40">
<div className="mb-3"> <div className="mb-3">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Datenbank</div> <div className="text-sm font-semibold text-gray-900 dark:text-white">Datenbank</div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300"> <div className="mt-1 text-xs text-gray-600 dark:text-gray-300">

View File

@ -247,7 +247,7 @@ export default function Tabs({
aria-hidden="true" aria-hidden="true"
className={clsx( className={clsx(
'size-4 shrink-0 transition-transform', 'size-4 shrink-0 transition-transform',
tab.spinIcon && 'animate-spin', tab.spinIcon && 'animate-spin [animation-duration:2s]',
selected selected
? 'text-indigo-600 dark:text-indigo-400' ? 'text-indigo-600 dark:text-indigo-400'
: 'text-gray-400 group-hover:text-gray-500 dark:text-gray-500 dark:group-hover:text-gray-300' : 'text-gray-400 group-hover:text-gray-500 dark:text-gray-500 dark:group-hover:text-gray-300'

View File

@ -42,20 +42,25 @@ export default function TagBadge({
// Styling: Basis wie in ModelsTab // Styling: Basis wie in ModelsTab
const base = clsx( const base = clsx(
'inline-flex shrink-0 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 font-medium',
maxWidthClassName, maxWidthClassName,
'bg-sky-50 text-sky-700 dark:bg-sky-500/10 dark:text-sky-200' 'ring-1 shadow-xs',
active
? 'bg-sky-100 text-sky-800 ring-sky-200 dark:bg-sky-400/20 dark:text-sky-100 dark:ring-sky-400/20 dark:shadow-none'
: 'bg-white text-sky-700 ring-sky-200 dark:bg-sky-500/10 dark:text-sky-200 dark:ring-sky-400/20 dark:shadow-none'
) )
const activeCls = active
? 'bg-sky-100 text-sky-800 dark:bg-sky-400/20 dark:text-sky-100'
: ''
const clickableCls = onClick const clickableCls = onClick
? 'cursor-pointer hover:bg-sky-100 dark:hover:bg-sky-400/20 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500' ? clsx(
'cursor-pointer transition-colors',
active
? 'hover:bg-sky-200 dark:hover:bg-sky-400/25'
: 'hover:bg-sky-50 dark:hover:bg-sky-400/20',
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500'
)
: '' : ''
const cls = clsx(base, activeCls, clickableCls, className) const cls = clsx(base, clickableCls, className)
const stop = (e: React.SyntheticEvent) => e.stopPropagation() const stop = (e: React.SyntheticEvent) => e.stopPropagation()

View File

@ -39,6 +39,14 @@ export default function TagOverflowRow({
// ✅ worst-case +X measurement // ✅ worst-case +X measurement
const plusMeasureRef = React.useRef<HTMLButtonElement | null>(null) const plusMeasureRef = React.useRef<HTMLButtonElement | null>(null)
const hostRef = React.useRef<HTMLDivElement | null>(null)
const [overlayStyle, setOverlayStyle] = React.useState<React.CSSProperties>({
top: 0,
left: 0,
right: 0,
height: 0,
})
React.useEffect(() => setOpen(false), [rowKey]) React.useEffect(() => setOpen(false), [rowKey])
React.useEffect(() => { React.useEffect(() => {
@ -118,6 +126,40 @@ export default function TagOverflowRow({
setVisibleCount(Math.max(0, Math.min(count, totalTags))) setVisibleCount(Math.max(0, Math.min(count, totalTags)))
}, [sortedTags, cap, gapPx]) }, [sortedTags, cap, gapPx])
const recalcOverlay = React.useCallback(() => {
const host = hostRef.current
if (!host) return
const parent = host.offsetParent as HTMLElement | null
if (!parent) return
setOverlayStyle({
top: -host.offsetTop,
left: -host.offsetLeft,
width: parent.clientWidth,
height: parent.clientHeight,
})
}, [])
React.useLayoutEffect(() => {
if (!open) return
recalcOverlay()
const host = hostRef.current
const parent = host?.offsetParent as HTMLElement | null
if (!parent) return
const ro = new ResizeObserver(() => {
recalcOverlay()
})
ro.observe(parent)
if (host) ro.observe(host)
return () => ro.disconnect()
}, [open, recalcOverlay])
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
recalc() recalc()
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@ -141,18 +183,18 @@ export default function TagOverflowRow({
const restAll = sortedTags.length - visibleTags.length const restAll = sortedTags.length - visibleTags.length
return ( return (
<> <div ref={hostRef} className="relative h-full min-h-[1.75rem]">
{/* collapsed row (in footer) */} {/* collapsed row (in footer) */}
{!open ? ( {!open ? (
<div <div
ref={rowWrapRef} ref={rowWrapRef}
className={['mt-2 h-6 flex items-center gap-1.5', className].filter(Boolean).join(' ')} className={['min-h-[1.75rem] h-full flex items-start gap-1.5 overflow-visible', className].filter(Boolean).join(' ')}
onClick={stop} onClick={stop}
onMouseDown={stop} onMouseDown={stop}
onPointerDown={stop} onPointerDown={stop}
> >
<div className="min-w-0 flex-1 overflow-hidden"> <div className="min-w-0 flex-1 overflow-hidden">
<div className="flex flex-nowrap items-center gap-1.5"> <div className="flex flex-nowrap items-center gap-1.5 overflow-visible p-0.5">
{visibleTags.length > 0 ? ( {visibleTags.length > 0 ? (
visibleTags.map((t) => ( visibleTags.map((t) => (
<TagBadge <TagBadge
@ -174,7 +216,7 @@ export default function TagOverflowRow({
type="button" type="button"
className={[ className={[
// TagBadge-like sizing + shape // TagBadge-like sizing + shape
'inline-flex shrink-0 items-center truncate rounded-md px-2 py-0.5 text-xs', 'inline-flex min-h-[1.375rem] shrink-0 items-center rounded-md px-2 py-0.5 text-xs font-medium leading-none',
// TagBadge-like focus behavior // 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', '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) // neutral colors (damit es sich als “Control” abhebt)
@ -199,11 +241,13 @@ export default function TagOverflowRow({
{/* overlay that covers the whole footer host */} {/* overlay that covers the whole footer host */}
{open ? ( {open ? (
<div <div
style={overlayStyle}
className={[ className={[
'absolute inset-0 z-30', 'absolute z-30',
'bg-white/60 dark:bg-gray-950', 'border border-gray-200 bg-white shadow-sm',
'px-3 py-3', // etwas weniger padding -> mehr Platz 'dark:border-white/10 dark:bg-gray-950',
'pointer-events-auto', 'pointer-events-auto',
'flex flex-col',
].join(' ')} ].join(' ')}
onClick={stop} onClick={stop}
onMouseDown={stop} onMouseDown={stop}
@ -211,7 +255,6 @@ export default function TagOverflowRow({
role="dialog" role="dialog"
aria-label="Tags" aria-label="Tags"
> >
{/* Close nur als Icon oben rechts */}
<button <button
type="button" type="button"
className=" className="
@ -227,9 +270,14 @@ export default function TagOverflowRow({
</button> </button>
{/* volle Fläche für Tags */} <div className="min-h-0 flex-1 overflow-auto px-3 pb-3 pr-2 pt-3">
<div className="h-full overflow-auto pr-1"> <div className="mb-2 pr-10">
<div className="flex flex-wrap gap-1.5"> <div className="text-sm font-semibold text-gray-900 dark:text-white">
Alle Tags
</div>
</div>
<div className="flex flex-wrap items-start gap-1.5 overflow-visible p-0.5">
{sortedTags.map((t) => ( {sortedTags.map((t) => (
<TagBadge <TagBadge
key={t} key={t}
@ -261,11 +309,11 @@ export default function TagOverflowRow({
<button <button
ref={plusMeasureRef} ref={plusMeasureRef}
type="button" 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" className="inline-flex min-h-[1.375rem] items-center rounded-md bg-gray-100 px-2 py-0.5 text-xs font-medium leading-none text-gray-700 ring-1 ring-inset ring-gray-200"
> >
+99 +99
</button> </button>
</div> </div>
</> </div>
) )
} }

View File

@ -257,7 +257,7 @@ function previewIdFromJob(job: RecordJob | null): string {
function previewStillSrcFromJob(job: RecordJob | null): string { function previewStillSrcFromJob(job: RecordJob | null): string {
const id = previewIdFromJob(job) const id = previewIdFromJob(job)
if (!id) return '' if (!id) return ''
return `/api/preview?id=${encodeURIComponent(id)}&file=preview.webp` return `/api/preview?id=${encodeURIComponent(id)}&file=preview.jpg`
} }
function previewSpritesSrcFromJob(job: RecordJob | null): string { function previewSpritesSrcFromJob(job: RecordJob | null): string {
@ -1042,7 +1042,7 @@ export default function VideoSplitModal({
}} }}
title="Klicken zum Springen" title="Klicken zum Springen"
> >
{/* preview-sprites.webp als Hintergrund */} {/* preview-sprites.jpg als Hintergrund */}
{spriteMeta?.exists && spriteMeta.path && timelinePreviewTiles.length > 0 ? ( {spriteMeta?.exists && spriteMeta.path && timelinePreviewTiles.length > 0 ? (
<div <div
className="absolute inset-0 grid gap-0 bg-black/20" className="absolute inset-0 grid gap-0 bg-black/20"