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"
"strings"
"time"
"golang.org/x/image/webp"
)
type analyzeVideoReq struct {
@ -92,7 +90,7 @@ func extractSpriteFrames(spritePath string, ps previewSpriteMetaFileInfo) ([]ima
}
defer f.Close()
img, err := webp.Decode(f)
img, _, err := image.Decode(f)
if err != nil {
return nil, err
}
@ -395,9 +393,9 @@ func analyzeVideoFromSprite(ctx context.Context, outPath, goal string) ([]analyz
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 {
return nil, fmt.Errorf("preview-sprite.webp nicht gefunden")
return nil, fmt.Errorf("preview-sprite.jpg nicht gefunden")
}
durationSec, _ := durationSecondsForAnalyze(ctx, outPath)

View File

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

View File

@ -11,6 +11,7 @@ import (
"strings"
)
/*
const (
previewSpriteCols = 10
previewSpriteRows = 8
@ -18,6 +19,15 @@ const (
previewSpriteCellW = 160
previewSpriteCellH = 90
)
*/
const (
previewSpriteCols = 6
previewSpriteRows = 5
previewSpriteFrameCount = previewSpriteCols * previewSpriteRows
previewSpriteCellW = 120
previewSpriteCellH = 68
)
func fixedPreviewSpriteLayout() (cols, rows, count, cellW, cellH int) {
return previewSpriteCols, previewSpriteRows, previewSpriteFrameCount, previewSpriteCellW, previewSpriteCellH
@ -36,9 +46,9 @@ func previewSpriteStepSeconds(durationSec float64) float64 {
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.
func generatePreviewSpriteWebP(
func generatePreviewSpriteJPG(
ctx context.Context,
videoPath string,
outPath string,
@ -52,19 +62,19 @@ func generatePreviewSpriteWebP(
outPath = strings.TrimSpace(outPath)
if videoPath == "" {
return fmt.Errorf("generatePreviewSpriteWebP: empty videoPath")
return fmt.Errorf("generatePreviewSpriteJPG: empty videoPath")
}
if outPath == "" {
return fmt.Errorf("generatePreviewSpriteWebP: empty outPath")
return fmt.Errorf("generatePreviewSpriteJPG: empty outPath")
}
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 {
return fmt.Errorf("generatePreviewSpriteWebP: invalid stepSec %.3f", stepSec)
return fmt.Errorf("generatePreviewSpriteJPG: invalid stepSec %.3f", stepSec)
}
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 {
@ -73,7 +83,7 @@ func generatePreviewSpriteWebP(
ext := filepath.Ext(outPath)
if ext == "" {
ext = ".webp"
ext = ".jpg"
}
base := strings.TrimSuffix(outPath, ext)
tmpPath := base + ".tmp" + ext
@ -115,14 +125,12 @@ func generatePreviewSpriteWebP(
"-i", videoPath,
"-an",
"-sn",
"-threads", "1",
"-threads", "0",
"-vf", vf,
"-frames:v", "1",
"-c:v", "libwebp",
"-lossless", "0",
"-compression_level", "3",
"-q:v", "65",
"-f", "webp",
"-c:v", "mjpeg",
"-q:v", "4",
"-f", "image2",
tmpPath,
)

View File

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

View File

@ -33,7 +33,7 @@ import (
// - ffmpegPath, previewSem
// - notifyJobsChanged()
// - assetIDForJob(job *RecordJob) string
// - startLiveThumbWebPLoop(ctx, job)
// - startLiveThumbJPGLoop(ctx, job)
// ============================================================
// 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=...
// preview.webp bleibt in preview.go (servePreviewWebPAlias).
// preview.jpg bleibt in preview.go (servePreviewJPGAlias).
func recordPreviewFile(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
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.
// 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 {
if strings.TrimSpace(ffmpegPath) == "" {
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()
}()
startLiveThumbWebPLoop(ctx, job)
startLiveThumbJPGLoop(ctx, job)
return nil
}

View File

@ -72,8 +72,8 @@ type RecordJob struct {
// Thumbnail cache (verhindert, dass pro HTTP-Request ffmpeg läuft)
previewMu sync.Mutex `json:"-"`
previewWebp []byte `json:"-"`
previewWebpAt time.Time `json:"-"`
previewJPG []byte `json:"-"`
previewJPGAt time.Time `json:"-"`
previewGen bool `json:"-"`
PreviewM3U8 string `json:"-"` // HLS url, die ffmpeg inputt
@ -484,6 +484,7 @@ func initFFmpegSemaphores() {
genSem = NewDynSem(genN, genCap)
durSem = NewDynSem(durN, durCap)
/*
fmt.Printf(
"🔧 semaphores(init): preview=%d/%d thumb=%d/%d gen=%d/%d dur=%d/%d (cpu=%d)\n",
previewSem.Max(), previewSem.Cap(),
@ -497,6 +498,7 @@ func initFFmpegSemaphores() {
"🔧 semaphores: preview=%d thumb=%d gen=%d dur=%d (cpu=%d)\n",
previewN, thumbN, genN, durN, cpu,
)
*/
}
func startAdaptiveSemController(ctx context.Context) {
@ -1371,9 +1373,9 @@ func generatedMetaRoot() (string, error) {
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.
func generatedThumbWebPFile(assetID string) (string, error) {
func generatedThumbJPGFile(assetID string) (string, error) {
assetID = stripHotPrefix(strings.TrimSpace(assetID))
if assetID == "" {
return "", fmt.Errorf("empty assetID")
@ -1391,7 +1393,7 @@ func generatedThumbWebPFile(assetID string) (string, error) {
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):

View File

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

View File

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

View File

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

View File

@ -320,7 +320,7 @@ func previewSpriteTruthForID(id string) previewSpriteMetaResp {
}
genDir := filepath.Dir(metaPath)
spriteFile := filepath.Join(genDir, "preview-sprite.webp")
spriteFile := filepath.Join(genDir, "preview-sprite.jpg")
fi, err := os.Stat(spriteFile)
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
}
spritePath := filepath.Join(dir, "preview-sprite.webp")
spritePath := filepath.Join(dir, "preview-sprite.jpg")
fi, err := os.Stat(spritePath)
if err != nil || fi.IsDir() || fi.Size() <= 0 {
@ -245,11 +245,11 @@ func recordPreviewSprite(w http.ResponseWriter, r *http.Request) {
}
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("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 ----------------
@ -703,7 +703,16 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
setPhase("analyze", 5)
{
actx, cancel := context.WithTimeout(ctx, 45*time.Second)
defer cancel()
id := assetIDFromVideoPath(out)
if strings.TrimSpace(id) == "" {
fmt.Println("⚠️ postwork analyze: keine asset id ableitbar")
} else {
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 {
@ -725,8 +734,8 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
fmt.Println("⚠️ writeVideoAIForFile:", werr)
}
}
cancel()
}
}
}
setPhase("analyze", 100)

View File

@ -15,5 +15,5 @@
"teaserPlayback": "hover",
"teaserAudio": false,
"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
ffmpegPath = detectFFmpegPath()
fmt.Println("🔍 ffmpegPath:", ffmpegPath)
//fmt.Println("🔍 ffmpegPath:", ffmpegPath)
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)
await ensureCover(r.tag, thumb, model, true)

View File

@ -1,3 +1,5 @@
// frontend\src\components\ui\CookieModal.tsx
'use client'
import { Dialog } from '@headlessui/react'
@ -24,7 +26,6 @@ export default function CookieModal({
const [cookies, setCookies] = useState<CookieEntry[]>([])
const wasOpen = useRef(false)
// ✅ Beim Öffnen: Inputs resetten UND Cookies aus Props übernehmen
useEffect(() => {
if (open && !wasOpen.current) {
setName('')
@ -59,55 +60,144 @@ export default function CookieModal({
return (
<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">
<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
className="
w-full max-w-2xl rounded-xl
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="border-b border-gray-200/70 px-6 py-4 dark:border-white/10">
<Dialog.Title className="text-base font-semibold text-gray-900 dark:text-white">
Zusätzliche Cookies
</Dialog.Title>
<p className="mt-1 text-sm text-gray-600 dark:text-gray-300">
Füge zusätzliche Cookies hinzu oder aktualisiere bestehende Werte.
</p>
</div>
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-2">
<div className="space-y-4 px-6 py-5">
<div className="rounded-lg border border-gray-200/70 bg-gray-50/70 p-3 dark:border-white/10 dark:bg-white/5">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
<div className="sm:col-span-1">
<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="Name (z. B. cf_clearance)"
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"
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="sm:col-span-2">
<label className="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">
Wert
</label>
<input
value={value}
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"
placeholder="Cookie-Wert"
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>
<div className="mt-2">
<Button size="sm" variant="secondary" onClick={addCookie} disabled={!name.trim() || !value.trim()}>
<div className="mt-3 flex justify-end">
<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>
<div className="mt-4">
{cookies.length === 0 ? (
<div className="text-sm text-gray-500 dark:text-gray-400">Noch keine Cookies hinzugefügt.</div>
<div className="px-4 py-6 text-sm text-gray-500 dark:text-gray-400">
Noch keine Cookies hinzugefügt.
</div>
) : (
<table className="min-w-full text-sm border divide-y dark:divide-white/10">
<thead className="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th className="px-3 py-2 text-left font-medium">Name</th>
<th className="px-3 py-2 text-left font-medium">Wert</th>
<th className="px-3 py-2" />
<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 dark:divide-white/10">
<tbody className="divide-y divide-gray-200/70 dark:divide-white/10">
{cookies.map((c) => (
<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>
<td className="px-3 py-2 text-right">
<tr
key={c.name}
className="bg-white hover:bg-gray-50/70 dark:bg-transparent dark:hover:bg-white/5"
>
<td className="px-4 py-3 align-top">
<div
className="
inline-flex max-w-full items-center rounded-md
border border-gray-200 bg-gray-50 px-2 py-1
font-mono text-xs text-gray-800
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="text-xs text-red-600 hover:underline dark:text-red-400"
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>
@ -116,12 +206,18 @@ export default function CookieModal({
))}
</tbody>
</table>
</div>
)}
</div>
</div>
<div className="mt-6 flex justify-end gap-2">
<Button variant="secondary" onClick={onClose}>Abbrechen</Button>
<Button variant="primary" onClick={applyAndClose}>Übernehmen</Button>
<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="primary" onClick={applyAndClose}>
Übernehmen
</Button>
</div>
</Dialog.Panel>
</div>

View File

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

View File

@ -587,7 +587,7 @@ export default function FinishedDownloadsGalleryView({
</div>
{/* 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 */}
<div className="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) */}
<div
className="mt-2"
className="mt-2 shrink-0"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>

View File

@ -342,7 +342,7 @@ export default function FinishedDownloadsTableView({
</div>
{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
rowKey={keyFor(j)}
tags={tags}
@ -552,7 +552,7 @@ export default function FinishedDownloadsTableView({
)
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
rows={rows}
columns={columns}
@ -569,7 +569,7 @@ export default function FinishedDownloadsTableView({
/>
{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="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>

View File

@ -1161,16 +1161,6 @@ export default function ModelDetails({
{/* Pills */}
<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 ? (
<span
className={pill(
@ -1225,9 +1215,11 @@ export default function ModelDetails({
handleToggleWatchModel()
}}
className={cn(
'inline-flex items-center justify-center rounded-full p-1.5 ring-1 ring-inset backdrop-blur',
'cursor-pointer hover:scale-[1.03] active:scale-[0.98] transition',
model?.watching ? 'bg-sky-500/25 ring-sky-200/30' : 'bg-black/20 ring-white/15'
'inline-flex items-center justify-center rounded-full p-1.5 ring-1 backdrop-blur shadow-sm',
'cursor-pointer transition hover:scale-[1.03] active:scale-[0.98]',
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'}
aria-pressed={Boolean(model?.watching)}
@ -1238,14 +1230,14 @@ export default function ModelDetails({
className={cn(
'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',
'text-white/70'
'text-gray-600 dark:text-white/70'
)}
/>
<EyeSolidIcon
className={cn(
'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',
'text-sky-200'
'text-sky-600 dark:text-sky-200'
)}
/>
</span>
@ -1260,9 +1252,11 @@ export default function ModelDetails({
handleToggleFavoriteModel()
}}
className={cn(
'inline-flex items-center justify-center rounded-full p-1.5 ring-1 ring-inset backdrop-blur',
'cursor-pointer hover:scale-[1.03] active:scale-[0.98] transition',
model?.favorite ? 'bg-amber-500/25 ring-amber-200/30' : 'bg-black/20 ring-white/15'
'inline-flex items-center justify-center rounded-full p-1.5 ring-1 backdrop-blur shadow-sm',
'cursor-pointer transition hover:scale-[1.03] active:scale-[0.98]',
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'}
aria-pressed={Boolean(model?.favorite)}
@ -1273,14 +1267,14 @@ export default function ModelDetails({
className={cn(
'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',
'text-white/70'
'text-gray-600 dark:text-white/70'
)}
/>
<StarSolidIcon
className={cn(
'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',
'text-amber-200'
'text-amber-500 dark:text-amber-200'
)}
/>
</span>
@ -1295,9 +1289,11 @@ export default function ModelDetails({
handleToggleLikeModel()
}}
className={cn(
'inline-flex items-center justify-center rounded-full p-1.5 ring-1 ring-inset backdrop-blur',
'cursor-pointer hover:scale-[1.03] active:scale-[0.98] transition',
model?.liked ? 'bg-rose-500/25 ring-rose-200/30' : 'bg-black/20 ring-white/15'
'inline-flex items-center justify-center rounded-full p-1.5 ring-1 backdrop-blur shadow-sm',
'cursor-pointer transition hover:scale-[1.03] active:scale-[0.98]',
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'}
aria-pressed={model?.liked === true}
@ -1308,14 +1304,14 @@ export default function ModelDetails({
className={cn(
'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',
'text-white/70'
'text-gray-600 dark:text-white/70'
)}
/>
<HeartSolidIcon
className={cn(
'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',
'text-rose-200'
'text-rose-500 dark:text-rose-200'
)}
/>
</span>

View File

@ -27,7 +27,7 @@ type Props = {
fastRetryMax?: number
fastRetryWindowMs?: number
thumbsWebpUrl?: string | null
thumbsJPGUrl?: string | null
thumbsCandidates?: Array<string | null | undefined>
}
@ -44,7 +44,7 @@ export default function ModelPreview({
fastRetryMs,
fastRetryMax,
fastRetryWindowMs,
thumbsWebpUrl,
thumbsJPGUrl,
thumbsCandidates,
}: Props) {
const blurCls = blur ? 'blur-md' : ''
@ -92,7 +92,7 @@ export default function ModelPreview({
const thumbsCandidatesKey = useMemo(() => {
const list = [
thumbsWebpUrl,
thumbsJPGUrl,
...(Array.isArray(thumbsCandidates) ? thumbsCandidates : []),
]
.map(normalizeUrl)
@ -100,7 +100,7 @@ export default function ModelPreview({
// Reihenfolge behalten, nur dedupe
return Array.from(new Set(list)).join('|')
}, [thumbsWebpUrl, thumbsCandidates])
}, [thumbsJPGUrl, thumbsCandidates])
// ✅ visibilitychange -> nur REF updaten
useEffect(() => {
@ -383,7 +383,7 @@ export default function ModelPreview({
else setApiImgError(false)
}}
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) {
setDirectImgError(true)
return

View File

@ -1451,8 +1451,8 @@ export default function ModelsTab() {
className={clsx(
'h-8 min-w-0 px-0 shadow-none',
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-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-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 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'}
onClick={(e) => {
@ -1473,8 +1473,8 @@ export default function ModelsTab() {
className={clsx(
'h-8 min-w-0 px-0 shadow-none',
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-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-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 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'}
onClick={(e) => {
@ -1496,8 +1496,8 @@ export default function ModelsTab() {
className={clsx(
'h-8 min-w-0 px-0 shadow-none',
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-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-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 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'}
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-100',
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-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-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 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'}
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-100',
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-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-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 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'}
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-100',
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-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-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 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'}
onClick={(e) => {

View File

@ -132,7 +132,7 @@ function PageButton({
roundedCls,
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
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'
)}
aria-current={active ? 'page' : undefined}

View File

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

View File

@ -534,35 +534,6 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang
{/* Rechts: Alerts + Button */}
<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
variant="primary"
color={saveButton.color}
@ -704,7 +675,7 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang
</div>
{/* 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="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">

View File

@ -247,7 +247,7 @@ export default function Tabs({
aria-hidden="true"
className={clsx(
'size-4 shrink-0 transition-transform',
tab.spinIcon && 'animate-spin',
tab.spinIcon && 'animate-spin [animation-duration:2s]',
selected
? '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'

View File

@ -42,20 +42,25 @@ export default function TagBadge({
// Styling: Basis wie in ModelsTab
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,
'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
? '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()

View File

@ -39,6 +39,14 @@ export default function TagOverflowRow({
// ✅ worst-case +X measurement
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(() => {
@ -118,6 +126,40 @@ export default function TagOverflowRow({
setVisibleCount(Math.max(0, Math.min(count, totalTags)))
}, [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(() => {
recalc()
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -141,18 +183,18 @@ export default function TagOverflowRow({
const restAll = sortedTags.length - visibleTags.length
return (
<>
<div ref={hostRef} className="relative h-full min-h-[1.75rem]">
{/* collapsed row (in footer) */}
{!open ? (
<div
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}
onMouseDown={stop}
onPointerDown={stop}
>
<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.map((t) => (
<TagBadge
@ -174,7 +216,7 @@ export default function TagOverflowRow({
type="button"
className={[
// 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
'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)
@ -199,11 +241,13 @@ export default function TagOverflowRow({
{/* overlay that covers the whole footer host */}
{open ? (
<div
style={overlayStyle}
className={[
'absolute inset-0 z-30',
'bg-white/60 dark:bg-gray-950',
'px-3 py-3', // etwas weniger padding -> mehr Platz
'absolute z-30',
'border border-gray-200 bg-white shadow-sm',
'dark:border-white/10 dark:bg-gray-950',
'pointer-events-auto',
'flex flex-col',
].join(' ')}
onClick={stop}
onMouseDown={stop}
@ -211,7 +255,6 @@ export default function TagOverflowRow({
role="dialog"
aria-label="Tags"
>
{/* Close nur als Icon oben rechts */}
<button
type="button"
className="
@ -227,9 +270,14 @@ export default function TagOverflowRow({
</button>
{/* volle Fläche für Tags */}
<div className="h-full overflow-auto pr-1">
<div className="flex flex-wrap gap-1.5">
<div className="min-h-0 flex-1 overflow-auto px-3 pb-3 pr-2 pt-3">
<div className="mb-2 pr-10">
<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) => (
<TagBadge
key={t}
@ -261,11 +309,11 @@ export default function TagOverflowRow({
<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"
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
</button>
</div>
</>
</div>
)
}

View File

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