updated to jpg
This commit is contained in:
parent
5e5b8025e8
commit
dffe5dde16
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -72,8 +72,8 @@ type RecordJob struct {
|
|||||||
|
|
||||||
// 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
|
||||||
@ -484,6 +484,7 @@ func initFFmpegSemaphores() {
|
|||||||
genSem = NewDynSem(genN, genCap)
|
genSem = NewDynSem(genN, genCap)
|
||||||
durSem = NewDynSem(durN, durCap)
|
durSem = NewDynSem(durN, durCap)
|
||||||
|
|
||||||
|
/*
|
||||||
fmt.Printf(
|
fmt.Printf(
|
||||||
"🔧 semaphores(init): preview=%d/%d thumb=%d/%d gen=%d/%d dur=%d/%d (cpu=%d)\n",
|
"🔧 semaphores(init): preview=%d/%d thumb=%d/%d gen=%d/%d dur=%d/%d (cpu=%d)\n",
|
||||||
previewSem.Max(), previewSem.Cap(),
|
previewSem.Max(), previewSem.Cap(),
|
||||||
@ -497,6 +498,7 @@ func initFFmpegSemaphores() {
|
|||||||
"🔧 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):
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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,7 +703,16 @@ 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()
|
||||||
|
|
||||||
|
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)
|
durationSec, _ := durationSecondsForAnalyze(actx, out)
|
||||||
hits, aerr := analyzeVideoFromSprite(actx, out, "nsfw")
|
hits, aerr := analyzeVideoFromSprite(actx, out, "nsfw")
|
||||||
if aerr != nil {
|
if aerr != nil {
|
||||||
@ -725,8 +734,8 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|||||||
fmt.Println("⚠️ writeVideoAIForFile:", werr)
|
fmt.Println("⚠️ writeVideoAIForFile:", werr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
cancel()
|
}
|
||||||
}
|
}
|
||||||
setPhase("analyze", 100)
|
setPhase("analyze", 100)
|
||||||
|
|
||||||
|
|||||||
@ -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": ""
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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,55 +60,144 @@ 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
|
||||||
|
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">
|
<Dialog.Title className="text-base font-semibold text-gray-900 dark:text-white">
|
||||||
Zusätzliche Cookies
|
Zusätzliche Cookies
|
||||||
</Dialog.Title>
|
</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
|
<input
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="Name (z. B. cf_clearance)"
|
placeholder="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"
|
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
|
<input
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => setValue(e.target.value)}
|
onChange={(e) => setValue(e.target.value)}
|
||||||
placeholder="Wert"
|
placeholder="Cookie-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"
|
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>
|
||||||
|
|
||||||
<div className="mt-2">
|
<div className="mt-3 flex justify-end">
|
||||||
<Button size="sm" variant="secondary" onClick={addCookie} disabled={!name.trim() || !value.trim()}>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={addCookie}
|
||||||
|
disabled={!name.trim() || !value.trim()}
|
||||||
|
>
|
||||||
Hinzufügen
|
Hinzufügen
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 ? (
|
{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">
|
<div className="overflow-hidden">
|
||||||
<thead className="bg-gray-50 dark:bg-gray-700/50">
|
<table className="min-w-full table-fixed text-sm">
|
||||||
<tr>
|
<thead className="bg-gray-50 text-gray-700 dark:bg-white/5 dark:text-gray-200">
|
||||||
<th className="px-3 py-2 text-left font-medium">Name</th>
|
<tr className="border-b border-gray-200/70 dark:border-white/10">
|
||||||
<th className="px-3 py-2 text-left font-medium">Wert</th>
|
<th className="w-[180px] px-4 py-2.5 text-left font-medium">Name</th>
|
||||||
<th className="px-3 py-2" />
|
<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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y dark:divide-white/10">
|
|
||||||
|
<tbody className="divide-y divide-gray-200/70 dark:divide-white/10">
|
||||||
{cookies.map((c) => (
|
{cookies.map((c) => (
|
||||||
<tr key={c.name}>
|
<tr
|
||||||
<td className="px-3 py-2 font-mono">{c.name}</td>
|
key={c.name}
|
||||||
<td className="px-3 py-2 truncate max-w-[240px]">{c.value}</td>
|
className="bg-white hover:bg-gray-50/70 dark:bg-transparent dark:hover:bg-white/5"
|
||||||
<td className="px-3 py-2 text-right">
|
>
|
||||||
|
<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
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => removeCookie(c.name)}
|
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
|
Entfernen
|
||||||
</button>
|
</button>
|
||||||
@ -116,12 +206,18 @@ export default function CookieModal({
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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()}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -1161,16 +1161,6 @@ 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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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'
|
||||||
|
|||||||
@ -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()
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user