updated to jpg
This commit is contained in:
parent
5e5b8025e8
commit
dffe5dde16
@ -19,8 +19,6 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
type analyzeVideoReq struct {
|
||||
@ -92,7 +90,7 @@ func extractSpriteFrames(spritePath string, ps previewSpriteMetaFileInfo) ([]ima
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
img, err := webp.Decode(f)
|
||||
img, _, err := image.Decode(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -395,9 +393,9 @@ func analyzeVideoFromSprite(ctx context.Context, outPath, goal string) ([]analyz
|
||||
return nil, fmt.Errorf("previewSprite count fehlt")
|
||||
}
|
||||
|
||||
spritePath := filepath.Join(filepath.Dir(metaPath), "preview-sprite.webp")
|
||||
spritePath := filepath.Join(filepath.Dir(metaPath), "preview-sprite.jpg")
|
||||
if fi, err := os.Stat(spritePath); err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
|
||||
return nil, fmt.Errorf("preview-sprite.webp nicht gefunden")
|
||||
return nil, fmt.Errorf("preview-sprite.jpg nicht gefunden")
|
||||
}
|
||||
|
||||
durationSec, _ := durationSecondsForAnalyze(ctx, outPath)
|
||||
|
||||
@ -70,7 +70,7 @@ func assetIDFromVideoPath(videoPath string) string {
|
||||
return strings.TrimSpace(id)
|
||||
}
|
||||
|
||||
// Liefert die standardisierten Pfade (preview.webp / preview.mp4 / preview-sprite.webp / meta.json)
|
||||
// Liefert die standardisierten Pfade (preview.jpg / preview.mp4 / preview-sprite.jpg / meta.json)
|
||||
func assetPathsForID(id string) (assetDir, thumbPath, previewPath, spritePath, metaPath string, err error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
@ -82,9 +82,9 @@ func assetPathsForID(id string) (assetDir, thumbPath, previewPath, spritePath, m
|
||||
return "", "", "", "", "", fmt.Errorf("generated dir: %v", err)
|
||||
}
|
||||
|
||||
thumbPath = filepath.Join(assetDir, "preview.webp")
|
||||
thumbPath = filepath.Join(assetDir, "preview.jpg")
|
||||
previewPath = filepath.Join(assetDir, "preview.mp4")
|
||||
spritePath = filepath.Join(assetDir, "preview-sprite.webp")
|
||||
spritePath = filepath.Join(assetDir, "preview-sprite.jpg")
|
||||
|
||||
metaPath, _ = metaJSONPathForAssetID(id) // bevorzugt generated/meta/<id>/meta.json
|
||||
if strings.TrimSpace(metaPath) == "" {
|
||||
@ -294,7 +294,7 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
|
||||
progress(0)
|
||||
|
||||
// ----------------
|
||||
// Thumbs (WebP-only)
|
||||
// Thumbs (JPG-only)
|
||||
// ----------------
|
||||
if thumbBefore {
|
||||
progress(thumbsW)
|
||||
@ -318,7 +318,7 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
|
||||
progress(0.10)
|
||||
|
||||
// ✅ Immer letztes Frame bevorzugen (Preview soll “Endzustand” zeigen)
|
||||
img, e1 := extractLastFrameWebP(videoPath)
|
||||
img, e1 := extractLastFrameJPG(videoPath)
|
||||
if e1 != nil || len(img) == 0 {
|
||||
// Fallback: wenn wir Duration kennen, versuche kurz vor Ende
|
||||
if meta.durSec > 0 {
|
||||
@ -326,11 +326,11 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
|
||||
if t < 0 {
|
||||
t = 0
|
||||
}
|
||||
img, e1 = extractFrameAtTimeWebP(videoPath, t)
|
||||
img, e1 = extractFrameAtTimeJPG(videoPath, t)
|
||||
}
|
||||
// Letzter Fallback: erstes Frame
|
||||
if e1 != nil || len(img) == 0 {
|
||||
img, e1 = extractFirstFrameWebPScaled(videoPath, 720, 75)
|
||||
img, e1 = extractFirstFrameJPGScaled(videoPath, 720, 75)
|
||||
}
|
||||
}
|
||||
|
||||
@ -463,7 +463,7 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
|
||||
cols, rows, _, cellW, cellH := fixedPreviewSpriteLayout()
|
||||
stepSec := previewSpriteStepSeconds(meta.durSec)
|
||||
|
||||
if err := generatePreviewSpriteWebP(
|
||||
if err := generatePreviewSpriteJPG(
|
||||
genCtx,
|
||||
videoPath,
|
||||
spritePath,
|
||||
@ -474,12 +474,10 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
|
||||
cellH,
|
||||
); err != nil {
|
||||
if sfi, statErr := os.Stat(spritePath); statErr == nil && sfi != nil && !sfi.IsDir() && sfi.Size() > 0 {
|
||||
// Sprite existiert am Ende trotzdem -> Warnung unterdrücken
|
||||
return
|
||||
}
|
||||
|
||||
// Optional: nur kurze Meldung statt voller ffmpeg-Ausgabe
|
||||
//fmt.Printf("⚠️ preview sprite failed for %s\n", videoPath)
|
||||
fmt.Printf("⚠️ preview sprite failed for %s: %v\n", videoPath, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -581,7 +579,7 @@ func prepareVideoForSplit(ctx context.Context, videoPath, sourceURL, goal string
|
||||
return out, fmt.Errorf("video datei nicht gefunden")
|
||||
}
|
||||
|
||||
// 1) Assets sicherstellen (preview.webp / preview.mp4 / preview-sprite.webp / meta.json)
|
||||
// 1) Assets sicherstellen (preview.jpg / preview.mp4 / preview-sprite.jpg / meta.json)
|
||||
assetsRes, err := ensureAssetsForVideoDetailed(ctx, videoPath, sourceURL, nil)
|
||||
if err != nil {
|
||||
return out, err
|
||||
|
||||
@ -11,6 +11,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
/*
|
||||
const (
|
||||
previewSpriteCols = 10
|
||||
previewSpriteRows = 8
|
||||
@ -18,6 +19,15 @@ const (
|
||||
previewSpriteCellW = 160
|
||||
previewSpriteCellH = 90
|
||||
)
|
||||
*/
|
||||
|
||||
const (
|
||||
previewSpriteCols = 6
|
||||
previewSpriteRows = 5
|
||||
previewSpriteFrameCount = previewSpriteCols * previewSpriteRows
|
||||
previewSpriteCellW = 120
|
||||
previewSpriteCellH = 68
|
||||
)
|
||||
|
||||
func fixedPreviewSpriteLayout() (cols, rows, count, cellW, cellH int) {
|
||||
return previewSpriteCols, previewSpriteRows, previewSpriteFrameCount, previewSpriteCellW, previewSpriteCellH
|
||||
@ -36,9 +46,9 @@ func previewSpriteStepSeconds(durationSec float64) float64 {
|
||||
return step
|
||||
}
|
||||
|
||||
// generatePreviewSpriteWebP erzeugt ein statisches WebP-Spritesheet aus einem Video.
|
||||
// generatePreviewSpriteJPG erzeugt ein statisches JPG-Spritesheet aus einem Video.
|
||||
// ffmpeg muss im PATH verfügbar sein.
|
||||
func generatePreviewSpriteWebP(
|
||||
func generatePreviewSpriteJPG(
|
||||
ctx context.Context,
|
||||
videoPath string,
|
||||
outPath string,
|
||||
@ -52,19 +62,19 @@ func generatePreviewSpriteWebP(
|
||||
outPath = strings.TrimSpace(outPath)
|
||||
|
||||
if videoPath == "" {
|
||||
return fmt.Errorf("generatePreviewSpriteWebP: empty videoPath")
|
||||
return fmt.Errorf("generatePreviewSpriteJPG: empty videoPath")
|
||||
}
|
||||
if outPath == "" {
|
||||
return fmt.Errorf("generatePreviewSpriteWebP: empty outPath")
|
||||
return fmt.Errorf("generatePreviewSpriteJPG: empty outPath")
|
||||
}
|
||||
if cols <= 0 || rows <= 0 {
|
||||
return fmt.Errorf("generatePreviewSpriteWebP: invalid grid %dx%d", cols, rows)
|
||||
return fmt.Errorf("generatePreviewSpriteJPG: invalid grid %dx%d", cols, rows)
|
||||
}
|
||||
if stepSec <= 0 {
|
||||
return fmt.Errorf("generatePreviewSpriteWebP: invalid stepSec %.3f", stepSec)
|
||||
return fmt.Errorf("generatePreviewSpriteJPG: invalid stepSec %.3f", stepSec)
|
||||
}
|
||||
if cellW <= 0 || cellH <= 0 {
|
||||
return fmt.Errorf("generatePreviewSpriteWebP: invalid cell size %dx%d", cellW, cellH)
|
||||
return fmt.Errorf("generatePreviewSpriteJPG: invalid cell size %dx%d", cellW, cellH)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
|
||||
@ -73,7 +83,7 @@ func generatePreviewSpriteWebP(
|
||||
|
||||
ext := filepath.Ext(outPath)
|
||||
if ext == "" {
|
||||
ext = ".webp"
|
||||
ext = ".jpg"
|
||||
}
|
||||
base := strings.TrimSuffix(outPath, ext)
|
||||
tmpPath := base + ".tmp" + ext
|
||||
@ -115,14 +125,12 @@ func generatePreviewSpriteWebP(
|
||||
"-i", videoPath,
|
||||
"-an",
|
||||
"-sn",
|
||||
"-threads", "1",
|
||||
"-threads", "0",
|
||||
"-vf", vf,
|
||||
"-frames:v", "1",
|
||||
"-c:v", "libwebp",
|
||||
"-lossless", "0",
|
||||
"-compression_level", "3",
|
||||
"-q:v", "65",
|
||||
"-f", "webp",
|
||||
"-c:v", "mjpeg",
|
||||
"-q:v", "4",
|
||||
"-f", "image2",
|
||||
tmpPath,
|
||||
)
|
||||
|
||||
|
||||
@ -30,8 +30,6 @@ func makeFrontendHandler() (http.Handler, bool) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
fmt.Println("🖼️ Frontend dist: embedded web/dist")
|
||||
|
||||
fileServer := http.FileServer(http.FS(distFS))
|
||||
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@ -33,7 +33,7 @@ import (
|
||||
// - ffmpegPath, previewSem
|
||||
// - notifyJobsChanged()
|
||||
// - assetIDForJob(job *RecordJob) string
|
||||
// - startLiveThumbWebPLoop(ctx, job)
|
||||
// - startLiveThumbJPGLoop(ctx, job)
|
||||
// ============================================================
|
||||
|
||||
// Allowed files that may be served out of PreviewDir.
|
||||
@ -122,7 +122,7 @@ func recordPreviewLive(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// recordPreviewFile serves ONLY the HLS file requests for /api/preview?file=...
|
||||
// preview.webp bleibt in preview.go (servePreviewWebPAlias).
|
||||
// preview.jpg bleibt in preview.go (servePreviewJPGAlias).
|
||||
func recordPreviewFile(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
@ -337,7 +337,7 @@ func classifyPreviewFFmpegStderr(stderr string) (state string, httpStatus int) {
|
||||
}
|
||||
|
||||
// startPreviewHLS starts ffmpeg to generate HLS segments in previewDir.
|
||||
// It also starts your existing live-thumb loop: startLiveThumbWebPLoop(ctx, job).
|
||||
// It also starts your existing live-thumb loop: startLiveThumbJPGLoop(ctx, job).
|
||||
func startPreviewHLS(ctx context.Context, job *RecordJob, m3u8URL, previewDir, httpCookie, userAgent string) error {
|
||||
if strings.TrimSpace(ffmpegPath) == "" {
|
||||
return fmt.Errorf("kein ffmpeg gefunden – setze FFMPEG_PATH oder lege ffmpeg(.exe) neben das Backend")
|
||||
@ -440,7 +440,7 @@ func startPreviewHLS(ctx context.Context, job *RecordJob, m3u8URL, previewDir, h
|
||||
jobsMu.Unlock()
|
||||
}()
|
||||
|
||||
startLiveThumbWebPLoop(ctx, job)
|
||||
startLiveThumbJPGLoop(ctx, job)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@ -71,10 +71,10 @@ type RecordJob struct {
|
||||
PreviewStateMsg string `json:"previewStateMsg,omitempty"` // kurze Info
|
||||
|
||||
// Thumbnail cache (verhindert, dass pro HTTP-Request ffmpeg läuft)
|
||||
previewMu sync.Mutex `json:"-"`
|
||||
previewWebp []byte `json:"-"`
|
||||
previewWebpAt time.Time `json:"-"`
|
||||
previewGen bool `json:"-"`
|
||||
previewMu sync.Mutex `json:"-"`
|
||||
previewJPG []byte `json:"-"`
|
||||
previewJPGAt time.Time `json:"-"`
|
||||
previewGen bool `json:"-"`
|
||||
|
||||
PreviewM3U8 string `json:"-"` // HLS url, die ffmpeg inputt
|
||||
PreviewCookie string `json:"-"` // Cookie header (falls nötig)
|
||||
@ -484,19 +484,21 @@ func initFFmpegSemaphores() {
|
||||
genSem = NewDynSem(genN, genCap)
|
||||
durSem = NewDynSem(durN, durCap)
|
||||
|
||||
fmt.Printf(
|
||||
"🔧 semaphores(init): preview=%d/%d thumb=%d/%d gen=%d/%d dur=%d/%d (cpu=%d)\n",
|
||||
previewSem.Max(), previewSem.Cap(),
|
||||
thumbSem.Max(), thumbSem.Cap(),
|
||||
genSem.Max(), genSem.Cap(),
|
||||
durSem.Max(), durSem.Cap(),
|
||||
cpu,
|
||||
)
|
||||
/*
|
||||
fmt.Printf(
|
||||
"🔧 semaphores(init): preview=%d/%d thumb=%d/%d gen=%d/%d dur=%d/%d (cpu=%d)\n",
|
||||
previewSem.Max(), previewSem.Cap(),
|
||||
thumbSem.Max(), thumbSem.Cap(),
|
||||
genSem.Max(), genSem.Cap(),
|
||||
durSem.Max(), durSem.Cap(),
|
||||
cpu,
|
||||
)
|
||||
|
||||
fmt.Printf(
|
||||
"🔧 semaphores: preview=%d thumb=%d gen=%d dur=%d (cpu=%d)\n",
|
||||
previewN, thumbN, genN, durN, cpu,
|
||||
)
|
||||
fmt.Printf(
|
||||
"🔧 semaphores: preview=%d thumb=%d gen=%d dur=%d (cpu=%d)\n",
|
||||
previewN, thumbN, genN, durN, cpu,
|
||||
)
|
||||
*/
|
||||
}
|
||||
|
||||
func startAdaptiveSemController(ctx context.Context) {
|
||||
@ -1371,9 +1373,9 @@ func generatedMetaRoot() (string, error) {
|
||||
return resolvePathRelativeToApp(filepath.Join("generated", "meta"))
|
||||
}
|
||||
|
||||
// generatedThumbWebPFile gibt den Pfad zu generated/<assetID>/preview.webp zurück.
|
||||
// generatedThumbJPGFile gibt den Pfad zu generated/<assetID>/preview.jpg zurück.
|
||||
// assetID darf "HOT " enthalten; wird entfernt und wie überall sonst sanitisiert.
|
||||
func generatedThumbWebPFile(assetID string) (string, error) {
|
||||
func generatedThumbJPGFile(assetID string) (string, error) {
|
||||
assetID = stripHotPrefix(strings.TrimSpace(assetID))
|
||||
if assetID == "" {
|
||||
return "", fmt.Errorf("empty assetID")
|
||||
@ -1391,7 +1393,7 @@ func generatedThumbWebPFile(assetID string) (string, error) {
|
||||
return "", fmt.Errorf("ensureGeneratedDir: %w", err)
|
||||
}
|
||||
|
||||
return filepath.Join(dir, "preview.webp"), nil
|
||||
return filepath.Join(dir, "preview.jpg"), nil
|
||||
}
|
||||
|
||||
// Legacy (falls noch alte Assets liegen):
|
||||
|
||||
@ -525,7 +525,7 @@ func generatedThumbFile(id string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, "preview.webp"), nil
|
||||
return filepath.Join(dir, "preview.jpg"), nil
|
||||
}
|
||||
|
||||
func generatedPreviewFile(id string) (string, error) {
|
||||
@ -541,7 +541,7 @@ func generatedPreviewSpriteFile(id string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, "preview-sprite.webp"), nil
|
||||
return filepath.Join(dir, "preview-sprite.jpg"), nil
|
||||
}
|
||||
|
||||
func ensureGeneratedDirs() error {
|
||||
|
||||
@ -138,10 +138,6 @@ func initNSFWDetector() error {
|
||||
globalNSFW.session = session
|
||||
globalNSFW.initialized = true
|
||||
|
||||
fmt.Println("[NSFW] ONNX detector bereit")
|
||||
fmt.Println("[NSFW] model:", modelPath)
|
||||
fmt.Println("[NSFW] dll:", dllPath)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@ -44,7 +44,7 @@ import (
|
||||
// - atomicWriteFile, ensureGeneratedDirs, ensureGeneratedDir
|
||||
// - durationSecondsCached, parseFFmpegOutTime, ffmpegInputTol
|
||||
// - jobs, jobsMu, RecordJob, previewSem, thumbSem, JobRunning, notifyJobsChanged
|
||||
// - sanitizeID, findFinishedFileByID, stripHotPrefix, assetIDForJob, generatedThumbWebPFile
|
||||
// - sanitizeID, findFinishedFileByID, stripHotPrefix, assetIDForJob, generatedThumbJPGFile
|
||||
// Bitte diese Abhängigkeiten NICHT löschen – preview.go nutzt sie.
|
||||
|
||||
// ============================================================
|
||||
@ -453,8 +453,6 @@ func detectImageExt(contentType string, b []byte) (ext string, ct string) {
|
||||
return ".jpg", "image/jpeg"
|
||||
case strings.Contains(ct, "image/png"):
|
||||
return ".png", "image/png"
|
||||
case strings.Contains(ct, "image/webp"):
|
||||
return ".webp", "image/webp"
|
||||
case strings.Contains(ct, "image/gif"):
|
||||
return ".gif", "image/gif"
|
||||
}
|
||||
@ -465,7 +463,7 @@ func detectImageExt(contentType string, b []byte) (ext string, ct string) {
|
||||
return ".png", "image/png"
|
||||
}
|
||||
if len(b) >= 12 && string(b[:4]) == "RIFF" && string(b[8:12]) == "WEBP" {
|
||||
return ".webp", "image/webp"
|
||||
return ".jpg", "image/jpeg"
|
||||
}
|
||||
if len(b) >= 6 && (string(b[:6]) == "GIF87a" || string(b[:6]) == "GIF89a") {
|
||||
return ".gif", "image/gif"
|
||||
@ -492,7 +490,7 @@ func findExistingCoverFile(key string) (string, os.FileInfo, bool) {
|
||||
if err != nil || strings.TrimSpace(root) == "" {
|
||||
return "", nil, false
|
||||
}
|
||||
ext := []string{".jpg", ".png", ".webp", ".gif"}
|
||||
ext := []string{".jpg", ".png", ".gif"}
|
||||
for _, e := range ext {
|
||||
p := filepath.Join(root, key+e)
|
||||
if fi, err := os.Stat(p); err == nil && !fi.IsDir() && fi.Size() > 0 {
|
||||
@ -545,8 +543,6 @@ func downloadBytes(ctx context.Context, rawURL string, ua string) ([]byte, strin
|
||||
ct = "image/jpeg"
|
||||
case ".png":
|
||||
ct = "image/png"
|
||||
case ".webp":
|
||||
ct = "image/webp"
|
||||
case ".gif":
|
||||
ct = "image/gif"
|
||||
}
|
||||
@ -721,7 +717,7 @@ func generatedCoverInfoList(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
isCoverExt := func(ext string) bool {
|
||||
switch strings.ToLower(ext) {
|
||||
case ".jpg", ".jpeg", ".png", ".webp", ".gif":
|
||||
case ".jpg", ".jpeg", ".png", ".gif":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@ -868,8 +864,8 @@ func generatedCover(w http.ResponseWriter, r *http.Request) {
|
||||
switch ext {
|
||||
case ".png":
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
case ".webp":
|
||||
w.Header().Set("Content-Type", "image/webp")
|
||||
case ".jpg", ".jpeg":
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
case ".gif":
|
||||
w.Header().Set("Content-Type", "image/gif")
|
||||
default:
|
||||
@ -966,8 +962,8 @@ func generatedCover(w http.ResponseWriter, r *http.Request) {
|
||||
switch ext2 {
|
||||
case ".png":
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
case ".webp":
|
||||
w.Header().Set("Content-Type", "image/webp")
|
||||
case ".jpg":
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
case ".gif":
|
||||
w.Header().Set("Content-Type", "image/gif")
|
||||
default:
|
||||
@ -1050,7 +1046,7 @@ func generatedCover(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
root, _ := coversRoot()
|
||||
for _, e := range []string{".jpg", ".png", ".webp", ".gif"} {
|
||||
for _, e := range []string{".jpg", ".png", ".gif"} {
|
||||
_ = os.Remove(filepath.Join(root, key+e))
|
||||
}
|
||||
_ = os.Remove(filepath.Join(root, key+".info.json"))
|
||||
@ -1163,15 +1159,15 @@ func servePreviewStatusSVG(w http.ResponseWriter, label string, status int) {
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// WebP extraction + preview endpoint
|
||||
// JPG extraction + preview endpoint
|
||||
// Route:
|
||||
// - /api/preview?id=<jobID> (returns preview.webp / 204 / svg)
|
||||
// - /api/preview?id=<jobID>&file=preview.webp
|
||||
// - /api/preview?id=<jobID> (returns preview.jpg / 204 / svg)
|
||||
// - /api/preview?id=<jobID>&file=preview.jpg
|
||||
// ============================================================
|
||||
|
||||
// --- WebP extraction helpers ---
|
||||
// --- JPG extraction helpers ---
|
||||
|
||||
func extractLastFrameWebP(path string) ([]byte, error) {
|
||||
func extractLastFrameJPG(path string) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@ -1180,32 +1176,16 @@ func extractLastFrameWebP(path string) ([]byte, error) {
|
||||
ffmpegPath,
|
||||
"-hide_banner",
|
||||
"-loglevel", "error",
|
||||
|
||||
// relativ zum Dateiende suchen
|
||||
"-sseof", "-0.25",
|
||||
|
||||
"-i", path,
|
||||
|
||||
// nur den ersten Video-Stream verwenden
|
||||
"-map", "0:v:0",
|
||||
|
||||
// alles andere hart abschalten
|
||||
"-an",
|
||||
"-sn",
|
||||
"-dn",
|
||||
|
||||
// genau 1 Frame
|
||||
"-frames:v", "1",
|
||||
|
||||
// schneller skalieren
|
||||
"-vf", "scale=720:-2:flags=fast_bilinear",
|
||||
|
||||
// WebP: Qualität + schnellerer Encode
|
||||
"-vcodec", "libwebp",
|
||||
"-quality", "75",
|
||||
"-compression_level", "2",
|
||||
"-preset", "photo",
|
||||
|
||||
"-vcodec", "mjpeg",
|
||||
"-q:v", "4",
|
||||
"-f", "image2pipe",
|
||||
"pipe:1",
|
||||
)
|
||||
@ -1217,20 +1197,20 @@ func extractLastFrameWebP(path string) ([]byte, error) {
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return nil, fmt.Errorf("ffmpeg last-frame webp: timeout")
|
||||
return nil, fmt.Errorf("ffmpeg last-frame jpg: timeout")
|
||||
}
|
||||
return nil, fmt.Errorf("ffmpeg last-frame webp: %w (%s)", err, strings.TrimSpace(stderr.String()))
|
||||
return nil, fmt.Errorf("ffmpeg last-frame jpg: %w (%s)", err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
|
||||
b := out.Bytes()
|
||||
if len(b) == 0 {
|
||||
return nil, fmt.Errorf("ffmpeg last-frame webp: empty output")
|
||||
return nil, fmt.Errorf("ffmpeg last-frame jpg: empty output")
|
||||
}
|
||||
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func extractFrameAtTimeWebP(path string, seconds float64) ([]byte, error) {
|
||||
func extractFrameAtTimeJPG(path string, seconds float64) ([]byte, error) {
|
||||
if seconds < 0 {
|
||||
seconds = 0
|
||||
}
|
||||
@ -1243,26 +1223,27 @@ func extractFrameAtTimeWebP(path string, seconds float64) ([]byte, error) {
|
||||
"-i", path,
|
||||
"-frames:v", "1",
|
||||
"-vf", "scale=720:-2",
|
||||
"-quality", "75",
|
||||
"-vcodec", "mjpeg",
|
||||
"-q:v", "4",
|
||||
"-f", "image2pipe",
|
||||
"-vcodec", "libwebp",
|
||||
"pipe:1",
|
||||
)
|
||||
|
||||
var out bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("ffmpeg frame-at-time webp: %w (%s)", err, strings.TrimSpace(stderr.String()))
|
||||
return nil, fmt.Errorf("ffmpeg frame-at-time jpg: %w (%s)", err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
b := out.Bytes()
|
||||
if len(b) == 0 {
|
||||
return nil, fmt.Errorf("ffmpeg frame-at-time webp: empty output")
|
||||
return nil, fmt.Errorf("ffmpeg frame-at-time jpg: empty output")
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func extractLastFrameWebPScaled(path string, width int, quality int) ([]byte, error) {
|
||||
func extractLastFrameJPGScaled(path string, width int, quality int) ([]byte, error) {
|
||||
if width <= 0 {
|
||||
width = 320
|
||||
}
|
||||
@ -1270,6 +1251,15 @@ func extractLastFrameWebPScaled(path string, width int, quality int) ([]byte, er
|
||||
quality = 70
|
||||
}
|
||||
|
||||
qv := "5"
|
||||
if quality >= 80 {
|
||||
qv = "3"
|
||||
} else if quality >= 65 {
|
||||
qv = "5"
|
||||
} else {
|
||||
qv = "7"
|
||||
}
|
||||
|
||||
cmd := exec.Command(
|
||||
ffmpegPath,
|
||||
"-hide_banner", "-loglevel", "error",
|
||||
@ -1277,9 +1267,9 @@ func extractLastFrameWebPScaled(path string, width int, quality int) ([]byte, er
|
||||
"-i", path,
|
||||
"-frames:v", "1",
|
||||
"-vf", fmt.Sprintf("scale=%d:-2", width),
|
||||
"-quality", strconv.Itoa(quality),
|
||||
"-vcodec", "mjpeg",
|
||||
"-q:v", qv,
|
||||
"-f", "image2pipe",
|
||||
"-vcodec", "libwebp",
|
||||
"pipe:1",
|
||||
)
|
||||
|
||||
@ -1288,16 +1278,16 @@ func extractLastFrameWebPScaled(path string, width int, quality int) ([]byte, er
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("ffmpeg last-frame scaled webp: %w (%s)", err, strings.TrimSpace(stderr.String()))
|
||||
return nil, fmt.Errorf("ffmpeg last-frame scaled jpg: %w (%s)", err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
b := out.Bytes()
|
||||
if len(b) == 0 {
|
||||
return nil, fmt.Errorf("ffmpeg last-frame scaled webp: empty output")
|
||||
return nil, fmt.Errorf("ffmpeg last-frame scaled jpg: empty output")
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func extractFirstFrameWebPScaled(path string, width int, quality int) ([]byte, error) {
|
||||
func extractFirstFrameJPGScaled(path string, width int, quality int) ([]byte, error) {
|
||||
if width <= 0 {
|
||||
width = 320
|
||||
}
|
||||
@ -1312,9 +1302,9 @@ func extractFirstFrameWebPScaled(path string, width int, quality int) ([]byte, e
|
||||
"-i", path,
|
||||
"-frames:v", "1",
|
||||
"-vf", fmt.Sprintf("scale=%d:-2", width),
|
||||
"-quality", strconv.Itoa(quality),
|
||||
"-vcodec", "mjpeg",
|
||||
"-q:v", "5",
|
||||
"-f", "image2pipe",
|
||||
"-vcodec", "libwebp",
|
||||
"pipe:1",
|
||||
)
|
||||
|
||||
@ -1323,11 +1313,11 @@ func extractFirstFrameWebPScaled(path string, width int, quality int) ([]byte, e
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("ffmpeg first-frame scaled webp: %w (%s)", err, strings.TrimSpace(stderr.String()))
|
||||
return nil, fmt.Errorf("ffmpeg first-frame scaled jpg: %w (%s)", err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
b := out.Bytes()
|
||||
if len(b) == 0 {
|
||||
return nil, fmt.Errorf("ffmpeg first-frame scaled webp: empty output")
|
||||
return nil, fmt.Errorf("ffmpeg first-frame scaled jpg: empty output")
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
@ -1356,31 +1346,31 @@ func latestPreviewSegment(previewDir string) (string, error) {
|
||||
return filepath.Join(previewDir, best), nil
|
||||
}
|
||||
|
||||
func extractLastFrameFromPreviewDirThumbWebP(previewDir string) ([]byte, error) {
|
||||
func extractLastFrameFromPreviewDirThumbJPG(previewDir string) ([]byte, error) {
|
||||
seg, err := latestPreviewSegment(previewDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
img, err := extractLastFrameWebPScaled(seg, 320, 70)
|
||||
img, err := extractLastFrameJPGScaled(seg, 320, 70)
|
||||
if err == nil && len(img) > 0 {
|
||||
return img, nil
|
||||
}
|
||||
return extractFirstFrameWebPScaled(seg, 320, 70)
|
||||
return extractFirstFrameJPGScaled(seg, 320, 70)
|
||||
}
|
||||
|
||||
func extractLastFrameFromPreviewDirWebP(previewDir string) ([]byte, error) {
|
||||
func extractLastFrameFromPreviewDirJPG(previewDir string) ([]byte, error) {
|
||||
seg, err := latestPreviewSegment(previewDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
img, err := extractLastFrameWebP(seg)
|
||||
img, err := extractLastFrameJPG(seg)
|
||||
if err != nil {
|
||||
return extractFirstFrameWebPScaled(seg, 720, 75)
|
||||
return extractFirstFrameJPGScaled(seg, 720, 75)
|
||||
}
|
||||
return img, nil
|
||||
}
|
||||
|
||||
func serveLivePreviewWebPFile(w http.ResponseWriter, r *http.Request, path string) {
|
||||
func serveLivePreviewJPGFile(w http.ResponseWriter, r *http.Request, path string) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
@ -1394,12 +1384,12 @@ func serveLivePreviewWebPFile(w http.ResponseWriter, r *http.Request, path strin
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "image/webp")
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
http.ServeContent(w, r, "preview.webp", st.ModTime(), f)
|
||||
http.ServeContent(w, r, "preview.jpg", st.ModTime(), f)
|
||||
}
|
||||
|
||||
func servePreviewWebPFile(w http.ResponseWriter, r *http.Request, path string) {
|
||||
func servePreviewJPGFile(w http.ResponseWriter, r *http.Request, path string) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
@ -1413,35 +1403,35 @@ func servePreviewWebPFile(w http.ResponseWriter, r *http.Request, path string) {
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "image/webp")
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
w.Header().Set("Cache-Control", "public, max-age=600")
|
||||
http.ServeContent(w, r, filepath.Base(path), st.ModTime(), f)
|
||||
}
|
||||
|
||||
func servePreviewWebPBytes(w http.ResponseWriter, b []byte) {
|
||||
func servePreviewJPGBytes(w http.ResponseWriter, b []byte) {
|
||||
if len(b) == 0 {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/webp")
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
w.Header().Set("Cache-Control", "public, max-age=60")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(b)
|
||||
}
|
||||
|
||||
func serveLivePreviewWebPBytes(w http.ResponseWriter, b []byte) {
|
||||
func serveLivePreviewJPGBytes(w http.ResponseWriter, b []byte) {
|
||||
if len(b) == 0 {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/webp")
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(b)
|
||||
}
|
||||
|
||||
func servePreviewWebPAlias(w http.ResponseWriter, r *http.Request, id string) {
|
||||
func servePreviewJPGAlias(w http.ResponseWriter, r *http.Request, id string) {
|
||||
jobsMu.Lock()
|
||||
job := jobs[id]
|
||||
jobsMu.Unlock()
|
||||
@ -1449,12 +1439,12 @@ func servePreviewWebPAlias(w http.ResponseWriter, r *http.Request, id string) {
|
||||
if job != nil {
|
||||
assetID := assetIDForJob(job)
|
||||
if assetID != "" {
|
||||
if webpPath, err := generatedThumbWebPFile(assetID); err == nil {
|
||||
if st, err := os.Stat(webpPath); err == nil && !st.IsDir() && st.Size() > 0 {
|
||||
if jpgPath, err := generatedThumbJPGFile(assetID); err == nil {
|
||||
if st, err := os.Stat(jpgPath); err == nil && !st.IsDir() && st.Size() > 0 {
|
||||
if job.Status == JobRunning {
|
||||
serveLivePreviewWebPFile(w, r, webpPath)
|
||||
serveLivePreviewJPGFile(w, r, jpgPath)
|
||||
} else {
|
||||
servePreviewWebPFile(w, r, webpPath)
|
||||
servePreviewJPGFile(w, r, jpgPath)
|
||||
}
|
||||
return
|
||||
}
|
||||
@ -1463,10 +1453,10 @@ func servePreviewWebPAlias(w http.ResponseWriter, r *http.Request, id string) {
|
||||
|
||||
if job.Status == JobRunning {
|
||||
job.previewMu.Lock()
|
||||
cached := job.previewWebp
|
||||
cached := job.previewJPG
|
||||
job.previewMu.Unlock()
|
||||
if len(cached) > 0 {
|
||||
serveLivePreviewWebPBytes(w, cached)
|
||||
serveLivePreviewJPGBytes(w, cached)
|
||||
return
|
||||
}
|
||||
}
|
||||
@ -1480,9 +1470,9 @@ func servePreviewWebPAlias(w http.ResponseWriter, r *http.Request, id string) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if webpPath, err := generatedThumbWebPFile(assetID); err == nil {
|
||||
if st, err := os.Stat(webpPath); err == nil && !st.IsDir() && st.Size() > 0 {
|
||||
servePreviewWebPFile(w, r, webpPath)
|
||||
if jpgPath, err := generatedThumbJPGFile(assetID); err == nil {
|
||||
if st, err := os.Stat(jpgPath); err == nil && !st.IsDir() && st.Size() > 0 {
|
||||
servePreviewJPGFile(w, r, jpgPath)
|
||||
return
|
||||
}
|
||||
}
|
||||
@ -1513,9 +1503,9 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri
|
||||
if file := strings.TrimSpace(r.URL.Query().Get("file")); file != "" {
|
||||
low := strings.ToLower(strings.TrimSpace(file))
|
||||
|
||||
// ✅ preview.webp weiterhin hier behandeln
|
||||
if low == "preview.webp" {
|
||||
servePreviewWebPAlias(w, r, id)
|
||||
// ✅ preview.jpg weiterhin hier behandeln
|
||||
if low == "preview.jpg" {
|
||||
servePreviewJPGAlias(w, r, id)
|
||||
return
|
||||
}
|
||||
|
||||
@ -1524,7 +1514,7 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri
|
||||
return
|
||||
}
|
||||
|
||||
// WebP preview (running jobs have live thumb behavior)
|
||||
// JPG preview (running jobs have live thumb behavior)
|
||||
jobsMu.Lock()
|
||||
job, ok := jobs[id]
|
||||
jobsMu.Unlock()
|
||||
@ -1533,9 +1523,9 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri
|
||||
if job.Status == JobRunning {
|
||||
assetID := assetIDForJob(job)
|
||||
if assetID != "" {
|
||||
if webpPath, err := generatedThumbWebPFile(assetID); err == nil {
|
||||
if st, err := os.Stat(webpPath); err == nil && !st.IsDir() && st.Size() > 0 {
|
||||
serveLivePreviewWebPFile(w, r, webpPath)
|
||||
if jpgPath, err := generatedThumbJPGFile(assetID); err == nil {
|
||||
if st, err := os.Stat(jpgPath); err == nil && !st.IsDir() && st.Size() > 0 {
|
||||
serveLivePreviewJPGFile(w, r, jpgPath)
|
||||
return
|
||||
}
|
||||
}
|
||||
@ -1543,8 +1533,8 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri
|
||||
}
|
||||
|
||||
job.previewMu.Lock()
|
||||
cached := job.previewWebp
|
||||
cachedAt := job.previewWebpAt
|
||||
cached := job.previewJPG
|
||||
cachedAt := job.previewJPGAt
|
||||
fresh := len(cached) > 0 && !cachedAt.IsZero() && time.Since(cachedAt) < 8*time.Second
|
||||
|
||||
if !fresh && !job.previewGen {
|
||||
@ -1561,7 +1551,7 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri
|
||||
|
||||
previewDir := strings.TrimSpace(j.PreviewDir)
|
||||
if previewDir != "" {
|
||||
img, genErr = extractLastFrameFromPreviewDirWebP(previewDir)
|
||||
img, genErr = extractLastFrameFromPreviewDirJPG(previewDir)
|
||||
}
|
||||
|
||||
if genErr != nil || len(img) == 0 {
|
||||
@ -1574,9 +1564,9 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri
|
||||
}
|
||||
}
|
||||
if fi, err := os.Stat(outPath); err == nil && !fi.IsDir() && fi.Size() > 0 {
|
||||
img, genErr = extractLastFrameWebP(outPath)
|
||||
img, genErr = extractLastFrameJPG(outPath)
|
||||
if genErr != nil {
|
||||
img, _ = extractFirstFrameWebPScaled(outPath, 720, 75)
|
||||
img, _ = extractFirstFrameJPGScaled(outPath, 720, 75)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1584,8 +1574,8 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri
|
||||
|
||||
if len(img) > 0 {
|
||||
j.previewMu.Lock()
|
||||
j.previewWebp = img
|
||||
j.previewWebpAt = time.Now()
|
||||
j.previewJPG = img
|
||||
j.previewJPGAt = time.Now()
|
||||
j.previewMu.Unlock()
|
||||
}
|
||||
}(job)
|
||||
@ -1595,7 +1585,7 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri
|
||||
job.previewMu.Unlock()
|
||||
|
||||
if len(out) > 0 {
|
||||
serveLivePreviewWebPBytes(w, out)
|
||||
serveLivePreviewJPGBytes(w, out)
|
||||
return
|
||||
}
|
||||
|
||||
@ -1621,7 +1611,7 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri
|
||||
servePreviewForFinishedFile(w, r, id)
|
||||
}
|
||||
|
||||
func updateLiveThumbWebPOnce(ctx context.Context, job *RecordJob) {
|
||||
func updateLiveThumbJPGOnce(ctx context.Context, job *RecordJob) {
|
||||
jobsMu.Lock()
|
||||
status := job.Status
|
||||
previewDir := job.PreviewDir
|
||||
@ -1633,7 +1623,7 @@ func updateLiveThumbWebPOnce(ctx context.Context, job *RecordJob) {
|
||||
}
|
||||
|
||||
assetID := assetIDForJob(job)
|
||||
thumbPath, err := generatedThumbWebPFile(assetID)
|
||||
thumbPath, err := generatedThumbJPGFile(assetID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@ -1655,12 +1645,12 @@ func updateLiveThumbWebPOnce(ctx context.Context, job *RecordJob) {
|
||||
|
||||
var img []byte
|
||||
if previewDir != "" {
|
||||
if b, err := extractLastFrameFromPreviewDirThumbWebP(previewDir); err == nil && len(b) > 0 {
|
||||
if b, err := extractLastFrameFromPreviewDirThumbJPG(previewDir); err == nil && len(b) > 0 {
|
||||
img = b
|
||||
}
|
||||
}
|
||||
if len(img) == 0 && out != "" {
|
||||
if b, err := extractLastFrameWebPScaled(out, 320, 70); err == nil && len(b) > 0 {
|
||||
if b, err := extractLastFrameJPGScaled(out, 320, 70); err == nil && len(b) > 0 {
|
||||
img = b
|
||||
}
|
||||
}
|
||||
@ -1670,7 +1660,7 @@ func updateLiveThumbWebPOnce(ctx context.Context, job *RecordJob) {
|
||||
_ = atomicWriteFile(thumbPath, img)
|
||||
}
|
||||
|
||||
func startLiveThumbWebPLoop(ctx context.Context, job *RecordJob) {
|
||||
func startLiveThumbJPGLoop(ctx context.Context, job *RecordJob) {
|
||||
jobsMu.Lock()
|
||||
if job.LiveThumbStarted {
|
||||
jobsMu.Unlock()
|
||||
@ -1680,7 +1670,7 @@ func startLiveThumbWebPLoop(ctx context.Context, job *RecordJob) {
|
||||
jobsMu.Unlock()
|
||||
|
||||
go func() {
|
||||
updateLiveThumbWebPOnce(ctx, job)
|
||||
updateLiveThumbJPGOnce(ctx, job)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@ -1692,7 +1682,7 @@ func startLiveThumbWebPLoop(ctx context.Context, job *RecordJob) {
|
||||
if st != JobRunning {
|
||||
return
|
||||
}
|
||||
updateLiveThumbWebPOnce(ctx, job)
|
||||
updateLiveThumbJPGOnce(ctx, job)
|
||||
}
|
||||
}
|
||||
}()
|
||||
@ -1734,17 +1724,17 @@ func servePreviewForFinishedFile(w http.ResponseWriter, r *http.Request, id stri
|
||||
sec = 0
|
||||
}
|
||||
|
||||
img, err := extractFrameAtTimeWebP(outPath, sec)
|
||||
img, err := extractFrameAtTimeJPG(outPath, sec)
|
||||
if err == nil && len(img) > 0 {
|
||||
servePreviewWebPBytes(w, img)
|
||||
servePreviewJPGBytes(w, img)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
thumbPath := filepath.Join(assetDir, "preview.webp")
|
||||
thumbPath := filepath.Join(assetDir, "preview.jpg")
|
||||
if fi, err := os.Stat(thumbPath); err == nil && !fi.IsDir() && fi.Size() > 0 {
|
||||
servePreviewWebPFile(w, r, thumbPath)
|
||||
servePreviewJPGFile(w, r, thumbPath)
|
||||
return
|
||||
}
|
||||
|
||||
@ -1752,7 +1742,7 @@ func servePreviewForFinishedFile(w http.ResponseWriter, r *http.Request, id stri
|
||||
defer cancel()
|
||||
|
||||
// ✅ Immer letztes Frame bevorzugen
|
||||
img, err := extractLastFrameWebP(outPath)
|
||||
img, err := extractLastFrameJPG(outPath)
|
||||
|
||||
if err != nil || len(img) == 0 {
|
||||
// Fallback: kurz vor Ende, falls Duration verfügbar
|
||||
@ -1761,12 +1751,12 @@ func servePreviewForFinishedFile(w http.ResponseWriter, r *http.Request, id stri
|
||||
if t < 0 {
|
||||
t = 0
|
||||
}
|
||||
img, err = extractFrameAtTimeWebP(outPath, t)
|
||||
img, err = extractFrameAtTimeJPG(outPath, t)
|
||||
}
|
||||
|
||||
// Letzter Fallback: erstes Frame
|
||||
if err != nil || len(img) == 0 {
|
||||
img, err = extractFirstFrameWebPScaled(outPath, 720, 75)
|
||||
img, err = extractFirstFrameJPGScaled(outPath, 720, 75)
|
||||
if err != nil || len(img) == 0 {
|
||||
http.Error(w, "konnte preview nicht erzeugen", http.StatusInternalServerError)
|
||||
return
|
||||
@ -1775,7 +1765,7 @@ func servePreviewForFinishedFile(w http.ResponseWriter, r *http.Request, id stri
|
||||
}
|
||||
|
||||
_ = atomicWriteFile(thumbPath, img)
|
||||
servePreviewWebPBytes(w, img)
|
||||
servePreviewJPGBytes(w, img)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
@ -320,7 +320,7 @@ func previewSpriteTruthForID(id string) previewSpriteMetaResp {
|
||||
}
|
||||
|
||||
genDir := filepath.Dir(metaPath)
|
||||
spriteFile := filepath.Join(genDir, "preview-sprite.webp")
|
||||
spriteFile := filepath.Join(genDir, "preview-sprite.jpg")
|
||||
|
||||
fi, err := os.Stat(spriteFile)
|
||||
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
|
||||
|
||||
@ -230,7 +230,7 @@ func recordPreviewSprite(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
spritePath := filepath.Join(dir, "preview-sprite.webp")
|
||||
spritePath := filepath.Join(dir, "preview-sprite.jpg")
|
||||
|
||||
fi, err := os.Stat(spritePath)
|
||||
if err != nil || fi.IsDir() || fi.Size() <= 0 {
|
||||
@ -245,11 +245,11 @@ func recordPreviewSprite(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
w.Header().Set("Content-Type", "image/webp")
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
|
||||
http.ServeContent(w, r, "preview-sprite.webp", fi.ModTime(), f)
|
||||
http.ServeContent(w, r, "preview-sprite.jpg", fi.ModTime(), f)
|
||||
}
|
||||
|
||||
// ---------------- Start + run job ----------------
|
||||
@ -703,30 +703,39 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
||||
setPhase("analyze", 5)
|
||||
{
|
||||
actx, cancel := context.WithTimeout(ctx, 45*time.Second)
|
||||
defer cancel()
|
||||
|
||||
durationSec, _ := durationSecondsForAnalyze(actx, out)
|
||||
hits, aerr := analyzeVideoFromSprite(actx, out, "nsfw")
|
||||
if aerr != nil {
|
||||
fmt.Println("⚠️ postwork analyze:", aerr)
|
||||
id := assetIDFromVideoPath(out)
|
||||
if strings.TrimSpace(id) == "" {
|
||||
fmt.Println("⚠️ postwork analyze: keine asset id ableitbar")
|
||||
} else {
|
||||
setPhase("analyze", 65)
|
||||
ps := previewSpriteTruthForID(id)
|
||||
if !ps.Exists {
|
||||
fmt.Println("⚠️ postwork analyze: preview-sprite.jpg nicht gefunden")
|
||||
} else {
|
||||
durationSec, _ := durationSecondsForAnalyze(actx, out)
|
||||
hits, aerr := analyzeVideoFromSprite(actx, out, "nsfw")
|
||||
if aerr != nil {
|
||||
fmt.Println("⚠️ postwork analyze:", aerr)
|
||||
} else {
|
||||
setPhase("analyze", 65)
|
||||
|
||||
segments := buildSegmentsFromAnalyzeHits(hits, durationSec)
|
||||
segments := buildSegmentsFromAnalyzeHits(hits, durationSec)
|
||||
|
||||
ai := &aiAnalysisMeta{
|
||||
Goal: "nsfw",
|
||||
Mode: "sprite",
|
||||
Hits: hits,
|
||||
Segments: segments,
|
||||
AnalyzedAtUnix: time.Now().Unix(),
|
||||
}
|
||||
ai := &aiAnalysisMeta{
|
||||
Goal: "nsfw",
|
||||
Mode: "sprite",
|
||||
Hits: hits,
|
||||
Segments: segments,
|
||||
AnalyzedAtUnix: time.Now().Unix(),
|
||||
}
|
||||
|
||||
if werr := writeVideoAIForFile(actx, out, job.SourceURL, ai); werr != nil {
|
||||
fmt.Println("⚠️ writeVideoAIForFile:", werr)
|
||||
if werr := writeVideoAIForFile(actx, out, job.SourceURL, ai); werr != nil {
|
||||
fmt.Println("⚠️ writeVideoAIForFile:", werr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cancel()
|
||||
}
|
||||
setPhase("analyze", 100)
|
||||
|
||||
|
||||
@ -15,5 +15,5 @@
|
||||
"teaserPlayback": "hover",
|
||||
"teaserAudio": false,
|
||||
"enableNotifications": true,
|
||||
"encryptedCookies": "3EPvjFs7b4JIdKUT3G2fOZKc26YmYL283VVHmG+dCLAUe+xURUkM0rZMCrf8Ug7eyXZOreLItE09FSCZrA3afNgmHg5c648hhvYhkv/mW7J8ap4tMz1m8ahcvcfoLhrx5AqU4MWXnqz+VHHglqkfPn9aFcrgFnWbOPHJ1A3S77cs2gWR0/shqn3l8nk6HmIWqJ1TnAA6z2CYDngB27sv/NflLKoujezlWitEa8wEpEW8GDSEtPjpT7X9L24wP4TK/TnxZUovaRXDDbboebk2KeKP04C5tWhhpIfKl3/ipf9dPgHdV4jLheFyczMRZN5Z6yF5WRn3NgDbdCcoldRwqgTwv1NgLri8nJKp4SGmRpGFrbq6m7/26muyGbTzsU3tniae6iYHbYrPz0pMOBLcFPxnil4yT0Xgnph+P9EYYWJxtjUXi7nsiREjHBxqU/OSogavsOjlFqJgWBBCL705R2Fap0VjlgWtJEXKu+vAlexX873uoeFzFw9niwJlNRFKJtGMjJGYE5c="
|
||||
"encryptedCookies": ""
|
||||
}
|
||||
|
||||
@ -171,10 +171,10 @@ func loadSettings() {
|
||||
|
||||
// ffmpeg-Pfad anhand Settings/Env/PATH bestimmen
|
||||
ffmpegPath = detectFFmpegPath()
|
||||
fmt.Println("🔍 ffmpegPath:", ffmpegPath)
|
||||
//fmt.Println("🔍 ffmpegPath:", ffmpegPath)
|
||||
|
||||
ffprobePath = detectFFprobePath()
|
||||
fmt.Println("🔍 ffprobePath:", ffprobePath)
|
||||
//fmt.Println("🔍 ffprobePath:", ffprobePath)
|
||||
|
||||
}
|
||||
|
||||
|
||||
@ -376,7 +376,7 @@ export default function CategoriesTab() {
|
||||
}
|
||||
}
|
||||
|
||||
const thumb = `/generated/meta/${encodeURIComponent(id)}/preview.webp`
|
||||
const thumb = `/generated/meta/${encodeURIComponent(id)}/preview.jpg`
|
||||
const model = modelKeyFromFilename(pick)
|
||||
|
||||
await ensureCover(r.tag, thumb, model, true)
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// frontend\src\components\ui\CookieModal.tsx
|
||||
|
||||
'use client'
|
||||
|
||||
import { Dialog } from '@headlessui/react'
|
||||
@ -24,7 +26,6 @@ export default function CookieModal({
|
||||
const [cookies, setCookies] = useState<CookieEntry[]>([])
|
||||
const wasOpen = useRef(false)
|
||||
|
||||
// ✅ Beim Öffnen: Inputs resetten UND Cookies aus Props übernehmen
|
||||
useEffect(() => {
|
||||
if (open && !wasOpen.current) {
|
||||
setName('')
|
||||
@ -59,72 +60,167 @@ export default function CookieModal({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} className="relative z-50">
|
||||
<div className="fixed inset-0 bg-black/40" aria-hidden="true" />
|
||||
<div className="fixed inset-0 bg-black/40 backdrop-blur-[2px]" aria-hidden="true" />
|
||||
|
||||
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||
<Dialog.Panel className="w-full max-w-lg rounded-lg bg-white dark:bg-gray-800 p-6 shadow-xl dark:outline dark:-outline-offset-1 dark:outline-white/10">
|
||||
<Dialog.Title className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
Zusätzliche Cookies
|
||||
</Dialog.Title>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Name (z. B. cf_clearance)"
|
||||
className="col-span-1 truncate rounded-md px-3 py-2 text-sm bg-white text-gray-900 dark:bg-white/10 dark:text-white"
|
||||
/>
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="Wert"
|
||||
className="col-span-1 truncate sm:col-span-2 rounded-md px-3 py-2 text-sm bg-white text-gray-900 dark:bg-white/10 dark:text-white"
|
||||
/>
|
||||
<Dialog.Panel
|
||||
className="
|
||||
w-full max-w-2xl rounded-xl
|
||||
border border-gray-200/80 bg-white shadow-xl
|
||||
dark:border-white/10 dark:bg-gray-800 dark:outline dark:-outline-offset-1 dark:outline-white/10
|
||||
"
|
||||
>
|
||||
<div className="border-b border-gray-200/70 px-6 py-4 dark:border-white/10">
|
||||
<Dialog.Title className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
Zusätzliche Cookies
|
||||
</Dialog.Title>
|
||||
<p className="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
Füge zusätzliche Cookies hinzu oder aktualisiere bestehende Werte.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
<Button size="sm" variant="secondary" onClick={addCookie} disabled={!name.trim() || !value.trim()}>
|
||||
Hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-4 px-6 py-5">
|
||||
<div className="rounded-lg border border-gray-200/70 bg-gray-50/70 p-3 dark:border-white/10 dark:bg-white/5">
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
<div className="sm:col-span-1">
|
||||
<label className="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="z. B. cf_clearance"
|
||||
className="
|
||||
w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm
|
||||
placeholder:text-gray-400
|
||||
focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20
|
||||
dark:border-white/10 dark:bg-white/10 dark:text-white dark:placeholder:text-gray-400
|
||||
dark:focus:border-indigo-400 dark:focus:ring-indigo-400/20
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
{cookies.length === 0 ? (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Noch keine Cookies hinzugefügt.</div>
|
||||
) : (
|
||||
<table className="min-w-full text-sm border divide-y dark:divide-white/10">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700/50">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium">Name</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Wert</th>
|
||||
<th className="px-3 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y dark:divide-white/10">
|
||||
{cookies.map((c) => (
|
||||
<tr key={c.name}>
|
||||
<td className="px-3 py-2 font-mono">{c.name}</td>
|
||||
<td className="px-3 py-2 truncate max-w-[240px]">{c.value}</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<button
|
||||
onClick={() => removeCookie(c.name)}
|
||||
className="text-xs text-red-600 hover:underline dark:text-red-400"
|
||||
<div className="sm:col-span-2">
|
||||
<label className="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||
Wert
|
||||
</label>
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="Cookie-Wert"
|
||||
className="
|
||||
w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm
|
||||
placeholder:text-gray-400
|
||||
focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20
|
||||
dark:border-white/10 dark:bg-white/10 dark:text-white dark:placeholder:text-gray-400
|
||||
dark:focus:border-indigo-400 dark:focus:ring-indigo-400/20
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={addCookie}
|
||||
disabled={!name.trim() || !value.trim()}
|
||||
>
|
||||
Hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-200/70 bg-white/70 shadow-sm dark:border-white/10 dark:bg-white/5">
|
||||
<div className="border-b border-gray-200/70 px-4 py-3 dark:border-white/10">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Aktuelle Cookies
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-gray-600 dark:text-gray-300">
|
||||
{cookies.length} Eintrag{cookies.length === 1 ? '' : 'e'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cookies.length === 0 ? (
|
||||
<div className="px-4 py-6 text-sm text-gray-500 dark:text-gray-400">
|
||||
Noch keine Cookies hinzugefügt.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden">
|
||||
<table className="min-w-full table-fixed text-sm">
|
||||
<thead className="bg-gray-50 text-gray-700 dark:bg-white/5 dark:text-gray-200">
|
||||
<tr className="border-b border-gray-200/70 dark:border-white/10">
|
||||
<th className="w-[180px] px-4 py-2.5 text-left font-medium">Name</th>
|
||||
<th className="px-4 py-2.5 text-left font-medium">Wert</th>
|
||||
<th className="w-[110px] px-4 py-2.5 text-right font-medium">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody className="divide-y divide-gray-200/70 dark:divide-white/10">
|
||||
{cookies.map((c) => (
|
||||
<tr
|
||||
key={c.name}
|
||||
className="bg-white hover:bg-gray-50/70 dark:bg-transparent dark:hover:bg-white/5"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<td className="px-4 py-3 align-top">
|
||||
<div
|
||||
className="
|
||||
inline-flex max-w-full items-center rounded-md
|
||||
border border-gray-200 bg-gray-50 px-2 py-1
|
||||
font-mono text-xs text-gray-800
|
||||
dark:border-white/10 dark:bg-white/5 dark:text-gray-100
|
||||
"
|
||||
title={c.name}
|
||||
>
|
||||
<span className="truncate">{c.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="px-4 py-3 align-top">
|
||||
<div
|
||||
className="
|
||||
rounded-md border border-gray-200 bg-gray-50 px-3 py-2
|
||||
font-mono text-xs text-gray-700
|
||||
dark:border-white/10 dark:bg-black/10 dark:text-gray-200
|
||||
"
|
||||
title={c.value}
|
||||
>
|
||||
<span className="block truncate">{c.value}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="px-4 py-3 text-right align-top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeCookie(c.name)}
|
||||
className="
|
||||
inline-flex items-center rounded-md px-2 py-1 text-xs font-medium
|
||||
text-red-700 hover:bg-red-50
|
||||
dark:text-red-300 dark:hover:bg-red-500/10
|
||||
"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={onClose}>Abbrechen</Button>
|
||||
<Button variant="primary" onClick={applyAndClose}>Übernehmen</Button>
|
||||
<div className="flex justify-end gap-2 border-t border-gray-200/70 px-6 py-4 dark:border-white/10">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button variant="primary" onClick={applyAndClose}>
|
||||
Übernehmen
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -223,20 +223,20 @@ const absUrlMaybe = (u?: string | null): string => {
|
||||
return `/${s}`
|
||||
}
|
||||
|
||||
const jobThumbsWebpCandidates = (job: RecordJob): string[] => {
|
||||
const jobThumbsJPGCandidates = (job: RecordJob): string[] => {
|
||||
const j = job as any
|
||||
|
||||
const direct = [
|
||||
j.thumbsWebpUrl,
|
||||
j.thumbsJPGUrl,
|
||||
j.thumbsUrl,
|
||||
j.previewThumbsUrl,
|
||||
j.thumbnailSheetUrl,
|
||||
]
|
||||
|
||||
const base = [
|
||||
j.previewBaseUrl ? `${String(j.previewBaseUrl).replace(/\/+$/, '')}/preview.webp` : '',
|
||||
j.assetBaseUrl ? `${String(j.assetBaseUrl).replace(/\/+$/, '')}/preview.webp` : '',
|
||||
j.thumbsBaseUrl ? `${String(j.thumbsBaseUrl).replace(/\/+$/, '')}/preview.webp` : '',
|
||||
j.previewBaseUrl ? `${String(j.previewBaseUrl).replace(/\/+$/, '')}/preview.jpg` : '',
|
||||
j.assetBaseUrl ? `${String(j.assetBaseUrl).replace(/\/+$/, '')}/preview.jpg` : '',
|
||||
j.thumbsBaseUrl ? `${String(j.thumbsBaseUrl).replace(/\/+$/, '')}/preview.jpg` : '',
|
||||
]
|
||||
|
||||
return [...direct, ...base]
|
||||
@ -656,7 +656,7 @@ function DownloadsCardRow({
|
||||
fastRetryMs={1000}
|
||||
fastRetryMax={25}
|
||||
fastRetryWindowMs={60_000}
|
||||
thumbsCandidates={jobThumbsWebpCandidates(j)}
|
||||
thumbsCandidates={jobThumbsJPGCandidates(j)}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
@ -1297,7 +1297,7 @@ export default function Downloads({
|
||||
fastRetryMs={1000}
|
||||
fastRetryMax={25}
|
||||
fastRetryWindowMs={60_000}
|
||||
thumbsCandidates={jobThumbsWebpCandidates(j)}
|
||||
thumbsCandidates={jobThumbsJPGCandidates(j)}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -587,7 +587,7 @@ export default function FinishedDownloadsGalleryView({
|
||||
</div>
|
||||
|
||||
{/* Footer / Meta */}
|
||||
<div className="relative min-h-[118px] px-4 py-3 rounded-b-lg border-t border-black/5 dark:border-white/10 bg-white dark:bg-gray-900">
|
||||
<div className="relative flex min-h-[118px] flex-col px-4 py-3 rounded-b-lg border-t border-black/5 dark:border-white/10 bg-white dark:bg-gray-900">
|
||||
{/* ✅ stashapp-like: Dateiname zuerst */}
|
||||
<div className="min-w-0">
|
||||
<div className="mt-0.5 flex items-start gap-2 min-w-0">
|
||||
@ -641,7 +641,7 @@ export default function FinishedDownloadsGalleryView({
|
||||
|
||||
{/* Actions (wie CardView: im Footer statt im Video) */}
|
||||
<div
|
||||
className="mt-2"
|
||||
className="mt-2 shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
|
||||
@ -342,7 +342,7 @@ export default function FinishedDownloadsTableView({
|
||||
</div>
|
||||
|
||||
{tags.length > 0 ? (
|
||||
<div className="mt-1" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
|
||||
<div className="mt-1 py-0.5 overflow-visible" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
|
||||
<TagOverflowRow
|
||||
rowKey={keyFor(j)}
|
||||
tags={tags}
|
||||
@ -552,7 +552,7 @@ export default function FinishedDownloadsTableView({
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative overflow-x-auto rounded-2xl border border-gray-200/80 bg-white/80 p-2 shadow-sm dark:border-white/10 dark:bg-transparent dark:p-0">
|
||||
<Table
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
@ -569,7 +569,7 @@ export default function FinishedDownloadsTableView({
|
||||
/>
|
||||
|
||||
{isLoading && rows.length === 0 ? (
|
||||
<div className="absolute inset-0 z-20 grid place-items-center rounded-lg bg-white/50 backdrop-blur-[2px] dark:bg-gray-950/40">
|
||||
<div className="absolute inset-0 z-20 grid place-items-center rounded-2xl bg-white/50 backdrop-blur-[2px] dark:bg-gray-950/40">
|
||||
<div className="flex items-center gap-3 rounded-lg border border-gray-200 bg-white/80 px-3 py-2 shadow-sm dark:border-white/10 dark:bg-gray-900/70">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-gray-300 border-t-transparent dark:border-white/20 dark:border-t-transparent" />
|
||||
<div className="text-sm font-medium text-gray-800 dark:text-gray-100">Lade…</div>
|
||||
|
||||
@ -1160,17 +1160,7 @@ export default function ModelDetails({
|
||||
/>
|
||||
|
||||
{/* Pills */}
|
||||
<div className="absolute left-3 top-3 flex flex-wrap items-center gap-2">
|
||||
{showPill ? (
|
||||
<span
|
||||
className={pill(
|
||||
'bg-emerald-500/10 text-emerald-900 ring-emerald-200 backdrop-blur dark:text-emerald-200 dark:ring-emerald-400/20'
|
||||
)}
|
||||
>
|
||||
{showPill}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<div className="absolute left-3 top-3 flex flex-wrap items-center gap-2">
|
||||
{effectivePresenceLabel ? (
|
||||
<span
|
||||
className={pill(
|
||||
@ -1225,9 +1215,11 @@ export default function ModelDetails({
|
||||
handleToggleWatchModel()
|
||||
}}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-full p-1.5 ring-1 ring-inset backdrop-blur',
|
||||
'cursor-pointer hover:scale-[1.03] active:scale-[0.98] transition',
|
||||
model?.watching ? 'bg-sky-500/25 ring-sky-200/30' : 'bg-black/20 ring-white/15'
|
||||
'inline-flex items-center justify-center rounded-full p-1.5 ring-1 backdrop-blur shadow-sm',
|
||||
'cursor-pointer transition hover:scale-[1.03] active:scale-[0.98]',
|
||||
model?.watching
|
||||
? 'bg-sky-100/95 text-sky-700 ring-sky-200 hover:bg-sky-200/95 dark:bg-sky-500/25 dark:text-sky-200 dark:ring-sky-200/30 dark:hover:bg-sky-500/30'
|
||||
: 'bg-white/90 text-gray-700 ring-gray-200 hover:bg-white dark:bg-black/20 dark:text-white dark:ring-white/15 dark:hover:bg-black/30'
|
||||
)}
|
||||
title={model?.watching ? 'Watch entfernen' : 'Auf Watch setzen'}
|
||||
aria-pressed={Boolean(model?.watching)}
|
||||
@ -1238,14 +1230,14 @@ export default function ModelDetails({
|
||||
className={cn(
|
||||
'absolute inset-0 size-4 transition-all duration-200 ease-out motion-reduce:transition-none',
|
||||
model?.watching ? 'opacity-0 scale-75 rotate-12' : 'opacity-100 scale-100 rotate-0',
|
||||
'text-white/70'
|
||||
'text-gray-600 dark:text-white/70'
|
||||
)}
|
||||
/>
|
||||
<EyeSolidIcon
|
||||
className={cn(
|
||||
'absolute inset-0 size-4 transition-all duration-200 ease-out motion-reduce:transition-none',
|
||||
model?.watching ? 'opacity-100 scale-110 rotate-0' : 'opacity-0 scale-75 -rotate-12',
|
||||
'text-sky-200'
|
||||
'text-sky-600 dark:text-sky-200'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
@ -1260,9 +1252,11 @@ export default function ModelDetails({
|
||||
handleToggleFavoriteModel()
|
||||
}}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-full p-1.5 ring-1 ring-inset backdrop-blur',
|
||||
'cursor-pointer hover:scale-[1.03] active:scale-[0.98] transition',
|
||||
model?.favorite ? 'bg-amber-500/25 ring-amber-200/30' : 'bg-black/20 ring-white/15'
|
||||
'inline-flex items-center justify-center rounded-full p-1.5 ring-1 backdrop-blur shadow-sm',
|
||||
'cursor-pointer transition hover:scale-[1.03] active:scale-[0.98]',
|
||||
model?.favorite
|
||||
? 'bg-amber-100/95 text-amber-700 ring-amber-200 hover:bg-amber-200/95 dark:bg-amber-500/25 dark:text-amber-200 dark:ring-amber-200/30 dark:hover:bg-amber-500/30'
|
||||
: 'bg-white/90 text-gray-700 ring-gray-200 hover:bg-white dark:bg-black/20 dark:text-white dark:ring-white/15 dark:hover:bg-black/30'
|
||||
)}
|
||||
title={model?.favorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
|
||||
aria-pressed={Boolean(model?.favorite)}
|
||||
@ -1273,14 +1267,14 @@ export default function ModelDetails({
|
||||
className={cn(
|
||||
'absolute inset-0 size-4 transition-all duration-200 ease-out motion-reduce:transition-none',
|
||||
model?.favorite ? 'opacity-0 scale-75 rotate-12' : 'opacity-100 scale-100 rotate-0',
|
||||
'text-white/70'
|
||||
'text-gray-600 dark:text-white/70'
|
||||
)}
|
||||
/>
|
||||
<StarSolidIcon
|
||||
className={cn(
|
||||
'absolute inset-0 size-4 transition-all duration-200 ease-out motion-reduce:transition-none',
|
||||
model?.favorite ? 'opacity-100 scale-110 rotate-0' : 'opacity-0 scale-75 -rotate-12',
|
||||
'text-amber-200'
|
||||
'text-amber-500 dark:text-amber-200'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
@ -1295,9 +1289,11 @@ export default function ModelDetails({
|
||||
handleToggleLikeModel()
|
||||
}}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-full p-1.5 ring-1 ring-inset backdrop-blur',
|
||||
'cursor-pointer hover:scale-[1.03] active:scale-[0.98] transition',
|
||||
model?.liked ? 'bg-rose-500/25 ring-rose-200/30' : 'bg-black/20 ring-white/15'
|
||||
'inline-flex items-center justify-center rounded-full p-1.5 ring-1 backdrop-blur shadow-sm',
|
||||
'cursor-pointer transition hover:scale-[1.03] active:scale-[0.98]',
|
||||
model?.liked
|
||||
? 'bg-rose-100/95 text-rose-700 ring-rose-200 hover:bg-rose-200/95 dark:bg-rose-500/25 dark:text-rose-200 dark:ring-rose-200/30 dark:hover:bg-rose-500/30'
|
||||
: 'bg-white/90 text-gray-700 ring-gray-200 hover:bg-white dark:bg-black/20 dark:text-white dark:ring-white/15 dark:hover:bg-black/30'
|
||||
)}
|
||||
title={model?.liked ? 'Like entfernen' : 'Liken'}
|
||||
aria-pressed={model?.liked === true}
|
||||
@ -1308,14 +1304,14 @@ export default function ModelDetails({
|
||||
className={cn(
|
||||
'absolute inset-0 size-4 transition-all duration-200 ease-out motion-reduce:transition-none',
|
||||
model?.liked ? 'opacity-0 scale-75 rotate-12' : 'opacity-100 scale-100 rotate-0',
|
||||
'text-white/70'
|
||||
'text-gray-600 dark:text-white/70'
|
||||
)}
|
||||
/>
|
||||
<HeartSolidIcon
|
||||
className={cn(
|
||||
'absolute inset-0 size-4 transition-all duration-200 ease-out motion-reduce:transition-none',
|
||||
model?.liked ? 'opacity-100 scale-110 rotate-0' : 'opacity-0 scale-75 -rotate-12',
|
||||
'text-rose-200'
|
||||
'text-rose-500 dark:text-rose-200'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
|
||||
@ -27,7 +27,7 @@ type Props = {
|
||||
fastRetryMax?: number
|
||||
fastRetryWindowMs?: number
|
||||
|
||||
thumbsWebpUrl?: string | null
|
||||
thumbsJPGUrl?: string | null
|
||||
thumbsCandidates?: Array<string | null | undefined>
|
||||
}
|
||||
|
||||
@ -44,7 +44,7 @@ export default function ModelPreview({
|
||||
fastRetryMs,
|
||||
fastRetryMax,
|
||||
fastRetryWindowMs,
|
||||
thumbsWebpUrl,
|
||||
thumbsJPGUrl,
|
||||
thumbsCandidates,
|
||||
}: Props) {
|
||||
const blurCls = blur ? 'blur-md' : ''
|
||||
@ -92,7 +92,7 @@ export default function ModelPreview({
|
||||
|
||||
const thumbsCandidatesKey = useMemo(() => {
|
||||
const list = [
|
||||
thumbsWebpUrl,
|
||||
thumbsJPGUrl,
|
||||
...(Array.isArray(thumbsCandidates) ? thumbsCandidates : []),
|
||||
]
|
||||
.map(normalizeUrl)
|
||||
@ -100,7 +100,7 @@ export default function ModelPreview({
|
||||
|
||||
// Reihenfolge behalten, nur dedupe
|
||||
return Array.from(new Set(list)).join('|')
|
||||
}, [thumbsWebpUrl, thumbsCandidates])
|
||||
}, [thumbsJPGUrl, thumbsCandidates])
|
||||
|
||||
// ✅ visibilitychange -> nur REF updaten
|
||||
useEffect(() => {
|
||||
@ -383,7 +383,7 @@ export default function ModelPreview({
|
||||
else setApiImgError(false)
|
||||
}}
|
||||
onError={() => {
|
||||
// 1) Wenn direkte preview.webp fehlschlägt -> auf API-Fallback umschalten
|
||||
// 1) Wenn direkte preview.jpg fehlschlägt -> auf API-Fallback umschalten
|
||||
if (useDirectThumb) {
|
||||
setDirectImgError(true)
|
||||
return
|
||||
|
||||
@ -1451,8 +1451,8 @@ export default function ModelsTab() {
|
||||
className={clsx(
|
||||
'h-8 min-w-0 px-0 shadow-none',
|
||||
watch
|
||||
? 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-500/20 dark:text-indigo-200 dark:hover:bg-indigo-500/30'
|
||||
: 'bg-white text-gray-700 ring-1 ring-gray-200 hover:bg-gray-100 dark:bg-white/5 dark:text-slate-200 dark:ring-0 dark:hover:bg-white/10'
|
||||
? 'bg-indigo-50 text-indigo-700 ring-1 ring-indigo-200 shadow-xs hover:bg-indigo-100 dark:bg-indigo-500/20 dark:text-indigo-200 dark:ring-0 dark:shadow-none dark:hover:bg-indigo-500/30'
|
||||
: 'bg-white text-gray-700 ring-1 ring-gray-200 shadow-none hover:bg-gray-100 dark:bg-white/5 dark:text-slate-200 dark:ring-0 dark:hover:bg-white/10'
|
||||
)}
|
||||
title={watch ? 'Nicht mehr beobachten' : 'Beobachten'}
|
||||
onClick={(e) => {
|
||||
@ -1473,8 +1473,8 @@ export default function ModelsTab() {
|
||||
className={clsx(
|
||||
'h-8 min-w-0 px-0 shadow-none',
|
||||
fav
|
||||
? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-500/20 dark:text-amber-200 dark:hover:bg-amber-500/30'
|
||||
: 'bg-white text-gray-700 ring-1 ring-gray-200 hover:bg-gray-100 dark:bg-white/5 dark:text-slate-200 dark:ring-0 dark:hover:bg-white/10'
|
||||
? 'bg-amber-50 text-amber-800 ring-1 ring-amber-200 shadow-xs hover:bg-amber-100 dark:bg-amber-500/20 dark:text-amber-200 dark:ring-0 dark:shadow-none dark:hover:bg-amber-500/30'
|
||||
: 'bg-white text-gray-700 ring-1 ring-gray-200 shadow-none hover:bg-gray-100 dark:bg-white/5 dark:text-slate-200 dark:ring-0 dark:hover:bg-white/10'
|
||||
)}
|
||||
title={fav ? 'Favorit entfernen' : 'Als Favorit markieren'}
|
||||
onClick={(e) => {
|
||||
@ -1496,8 +1496,8 @@ export default function ModelsTab() {
|
||||
className={clsx(
|
||||
'h-8 min-w-0 px-0 shadow-none',
|
||||
liked
|
||||
? 'bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-500/20 dark:text-rose-200 dark:hover:bg-rose-500/30'
|
||||
: 'bg-white text-gray-700 ring-1 ring-gray-200 hover:bg-gray-100 dark:bg-white/5 dark:text-slate-200 dark:ring-0 dark:hover:bg-white/10'
|
||||
? 'bg-rose-50 text-rose-700 ring-1 ring-rose-200 shadow-xs hover:bg-rose-100 dark:bg-rose-500/20 dark:text-rose-200 dark:ring-0 dark:shadow-none dark:hover:bg-rose-500/30'
|
||||
: 'bg-white text-gray-700 ring-1 ring-gray-200 shadow-none hover:bg-gray-100 dark:bg-white/5 dark:text-slate-200 dark:ring-0 dark:hover:bg-white/10'
|
||||
)}
|
||||
title={liked ? 'Gefällt mir entfernen' : 'Gefällt mir'}
|
||||
onClick={(e) => {
|
||||
@ -1546,8 +1546,8 @@ export default function ModelsTab() {
|
||||
? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity'
|
||||
: 'opacity-100',
|
||||
watch
|
||||
? 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-500/20 dark:text-indigo-200 dark:hover:bg-indigo-500/30'
|
||||
: 'bg-white text-gray-700 ring-1 ring-gray-200 hover:bg-gray-100 dark:bg-white/5 dark:text-slate-200 dark:ring-0 dark:hover:bg-white/10'
|
||||
? 'bg-indigo-50 text-indigo-700 ring-1 ring-indigo-200 shadow-xs hover:bg-indigo-100 dark:bg-indigo-500/20 dark:text-indigo-200 dark:ring-0 dark:shadow-none dark:hover:bg-indigo-500/30'
|
||||
: 'bg-white text-gray-700 ring-1 ring-gray-200 shadow-none hover:bg-gray-100 dark:bg-white/5 dark:text-slate-200 dark:ring-0 dark:hover:bg-white/10'
|
||||
)}
|
||||
title={watch ? 'Nicht mehr beobachten' : 'Beobachten'}
|
||||
onClick={(e) => {
|
||||
@ -1571,8 +1571,8 @@ export default function ModelsTab() {
|
||||
? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity'
|
||||
: 'opacity-100',
|
||||
fav
|
||||
? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-500/20 dark:text-amber-200 dark:hover:bg-amber-500/30'
|
||||
: 'bg-white text-gray-700 ring-1 ring-gray-200 hover:bg-gray-100 dark:bg-white/5 dark:text-slate-200 dark:ring-0 dark:hover:bg-white/10'
|
||||
? 'bg-amber-50 text-amber-800 ring-1 ring-amber-200 shadow-xs hover:bg-amber-100 dark:bg-amber-500/20 dark:text-amber-200 dark:ring-0 dark:shadow-none dark:hover:bg-amber-500/30'
|
||||
: 'bg-white text-gray-700 ring-1 ring-gray-200 shadow-none hover:bg-gray-100 dark:bg-white/5 dark:text-slate-200 dark:ring-0 dark:hover:bg-white/10'
|
||||
)}
|
||||
title={fav ? 'Favorit entfernen' : 'Als Favorit markieren'}
|
||||
onClick={(e) => {
|
||||
@ -1597,8 +1597,8 @@ export default function ModelsTab() {
|
||||
? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity'
|
||||
: 'opacity-100',
|
||||
liked
|
||||
? 'bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-500/20 dark:text-rose-200 dark:hover:bg-rose-500/30'
|
||||
: 'bg-white text-gray-700 ring-1 ring-gray-200 hover:bg-gray-100 dark:bg-white/5 dark:text-slate-200 dark:ring-0 dark:hover:bg-white/10'
|
||||
? 'bg-rose-50 text-rose-700 ring-1 ring-rose-200 shadow-xs hover:bg-rose-100 dark:bg-rose-500/20 dark:text-rose-200 dark:ring-0 dark:shadow-none dark:hover:bg-rose-500/30'
|
||||
: 'bg-white text-gray-700 ring-1 ring-gray-200 shadow-none hover:bg-gray-100 dark:bg-white/5 dark:text-slate-200 dark:ring-0 dark:hover:bg-white/10'
|
||||
)}
|
||||
title={liked ? 'Gefällt mir entfernen' : 'Gefällt mir'}
|
||||
onClick={(e) => {
|
||||
|
||||
@ -132,7 +132,7 @@ function PageButton({
|
||||
roundedCls,
|
||||
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
|
||||
active
|
||||
? 'z-10 bg-indigo-600 text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:focus-visible:outline-indigo-500'
|
||||
? 'z-10 !bg-indigo-600 !text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:focus-visible:outline-indigo-500'
|
||||
: 'text-gray-900 inset-ring inset-ring-gray-300 hover:bg-gray-50 dark:text-gray-200 dark:inset-ring-gray-700 dark:hover:bg-white/5'
|
||||
)}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
|
||||
@ -416,7 +416,7 @@ export default function Player({
|
||||
|
||||
// Vorschaubild oben
|
||||
const previewA = React.useMemo(
|
||||
() => apiUrl(`/api/preview?id=${encodeURIComponent(previewId)}&file=preview.webp`),
|
||||
() => apiUrl(`/api/preview?id=${encodeURIComponent(previewId)}&file=preview.jpg`),
|
||||
[previewId]
|
||||
)
|
||||
|
||||
|
||||
@ -534,35 +534,6 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang
|
||||
|
||||
{/* Rechts: Alerts + Button */}
|
||||
<div className="flex items-start gap-2">
|
||||
{/* Alerts links neben Button */}
|
||||
{saveUiState !== 'success' ? (
|
||||
<div className="hidden sm:flex min-w-0 max-w-[520px] items-stretch">
|
||||
{err ? (
|
||||
<div
|
||||
className="
|
||||
inline-flex items-center
|
||||
px-3 py-[7px] text-sm
|
||||
rounded-md border border-red-200 bg-red-50 text-red-700
|
||||
dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200
|
||||
"
|
||||
>
|
||||
{err}
|
||||
</div>
|
||||
) : msg ? (
|
||||
<div
|
||||
className="
|
||||
inline-flex items-center
|
||||
px-3 py-[7px] text-sm
|
||||
rounded-md border border-green-200 bg-green-50 text-green-700
|
||||
dark:border-green-500/30 dark:bg-green-500/10 dark:text-green-200
|
||||
"
|
||||
>
|
||||
{msg}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
color={saveButton.color}
|
||||
@ -704,7 +675,7 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang
|
||||
</div>
|
||||
|
||||
{/* Datenbank */}
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-950/40">
|
||||
<div className="mb-3 rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-950/40">
|
||||
<div className="mb-3">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Datenbank</div>
|
||||
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||||
|
||||
@ -247,7 +247,7 @@ export default function Tabs({
|
||||
aria-hidden="true"
|
||||
className={clsx(
|
||||
'size-4 shrink-0 transition-transform',
|
||||
tab.spinIcon && 'animate-spin',
|
||||
tab.spinIcon && 'animate-spin [animation-duration:2s]',
|
||||
selected
|
||||
? 'text-indigo-600 dark:text-indigo-400'
|
||||
: 'text-gray-400 group-hover:text-gray-500 dark:text-gray-500 dark:group-hover:text-gray-300'
|
||||
|
||||
@ -42,20 +42,25 @@ export default function TagBadge({
|
||||
|
||||
// Styling: Basis wie in ModelsTab
|
||||
const base = clsx(
|
||||
'inline-flex shrink-0 items-center truncate rounded-md px-2 py-0.5 text-xs',
|
||||
'inline-flex shrink-0 items-center truncate rounded-md px-2 py-0.5 text-xs font-medium',
|
||||
maxWidthClassName,
|
||||
'bg-sky-50 text-sky-700 dark:bg-sky-500/10 dark:text-sky-200'
|
||||
'ring-1 shadow-xs',
|
||||
active
|
||||
? 'bg-sky-100 text-sky-800 ring-sky-200 dark:bg-sky-400/20 dark:text-sky-100 dark:ring-sky-400/20 dark:shadow-none'
|
||||
: 'bg-white text-sky-700 ring-sky-200 dark:bg-sky-500/10 dark:text-sky-200 dark:ring-sky-400/20 dark:shadow-none'
|
||||
)
|
||||
|
||||
const activeCls = active
|
||||
? 'bg-sky-100 text-sky-800 dark:bg-sky-400/20 dark:text-sky-100'
|
||||
: ''
|
||||
|
||||
const clickableCls = onClick
|
||||
? 'cursor-pointer hover:bg-sky-100 dark:hover:bg-sky-400/20 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500'
|
||||
? clsx(
|
||||
'cursor-pointer transition-colors',
|
||||
active
|
||||
? 'hover:bg-sky-200 dark:hover:bg-sky-400/25'
|
||||
: 'hover:bg-sky-50 dark:hover:bg-sky-400/20',
|
||||
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500'
|
||||
)
|
||||
: ''
|
||||
|
||||
const cls = clsx(base, activeCls, clickableCls, className)
|
||||
const cls = clsx(base, clickableCls, className)
|
||||
|
||||
const stop = (e: React.SyntheticEvent) => e.stopPropagation()
|
||||
|
||||
|
||||
@ -39,6 +39,14 @@ export default function TagOverflowRow({
|
||||
// ✅ worst-case +X measurement
|
||||
const plusMeasureRef = React.useRef<HTMLButtonElement | null>(null)
|
||||
|
||||
const hostRef = React.useRef<HTMLDivElement | null>(null)
|
||||
const [overlayStyle, setOverlayStyle] = React.useState<React.CSSProperties>({
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 0,
|
||||
})
|
||||
|
||||
React.useEffect(() => setOpen(false), [rowKey])
|
||||
|
||||
React.useEffect(() => {
|
||||
@ -118,6 +126,40 @@ export default function TagOverflowRow({
|
||||
setVisibleCount(Math.max(0, Math.min(count, totalTags)))
|
||||
}, [sortedTags, cap, gapPx])
|
||||
|
||||
const recalcOverlay = React.useCallback(() => {
|
||||
const host = hostRef.current
|
||||
if (!host) return
|
||||
|
||||
const parent = host.offsetParent as HTMLElement | null
|
||||
if (!parent) return
|
||||
|
||||
setOverlayStyle({
|
||||
top: -host.offsetTop,
|
||||
left: -host.offsetLeft,
|
||||
width: parent.clientWidth,
|
||||
height: parent.clientHeight,
|
||||
})
|
||||
}, [])
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
recalcOverlay()
|
||||
|
||||
const host = hostRef.current
|
||||
const parent = host?.offsetParent as HTMLElement | null
|
||||
if (!parent) return
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
recalcOverlay()
|
||||
})
|
||||
|
||||
ro.observe(parent)
|
||||
if (host) ro.observe(host)
|
||||
|
||||
return () => ro.disconnect()
|
||||
}, [open, recalcOverlay])
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
recalc()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@ -141,18 +183,18 @@ export default function TagOverflowRow({
|
||||
const restAll = sortedTags.length - visibleTags.length
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={hostRef} className="relative h-full min-h-[1.75rem]">
|
||||
{/* collapsed row (in footer) */}
|
||||
{!open ? (
|
||||
<div
|
||||
ref={rowWrapRef}
|
||||
className={['mt-2 h-6 flex items-center gap-1.5', className].filter(Boolean).join(' ')}
|
||||
className={['min-h-[1.75rem] h-full flex items-start gap-1.5 overflow-visible', className].filter(Boolean).join(' ')}
|
||||
onClick={stop}
|
||||
onMouseDown={stop}
|
||||
onPointerDown={stop}
|
||||
>
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<div className="flex flex-nowrap items-center gap-1.5">
|
||||
<div className="flex flex-nowrap items-center gap-1.5 overflow-visible p-0.5">
|
||||
{visibleTags.length > 0 ? (
|
||||
visibleTags.map((t) => (
|
||||
<TagBadge
|
||||
@ -174,7 +216,7 @@ export default function TagOverflowRow({
|
||||
type="button"
|
||||
className={[
|
||||
// TagBadge-like sizing + shape
|
||||
'inline-flex shrink-0 items-center truncate rounded-md px-2 py-0.5 text-xs',
|
||||
'inline-flex min-h-[1.375rem] shrink-0 items-center rounded-md px-2 py-0.5 text-xs font-medium leading-none',
|
||||
// TagBadge-like focus behavior
|
||||
'cursor-pointer focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500',
|
||||
// neutral colors (damit es sich als “Control” abhebt)
|
||||
@ -199,11 +241,13 @@ export default function TagOverflowRow({
|
||||
{/* overlay that covers the whole footer host */}
|
||||
{open ? (
|
||||
<div
|
||||
style={overlayStyle}
|
||||
className={[
|
||||
'absolute inset-0 z-30',
|
||||
'bg-white/60 dark:bg-gray-950',
|
||||
'px-3 py-3', // etwas weniger padding -> mehr Platz
|
||||
'absolute z-30',
|
||||
'border border-gray-200 bg-white shadow-sm',
|
||||
'dark:border-white/10 dark:bg-gray-950',
|
||||
'pointer-events-auto',
|
||||
'flex flex-col',
|
||||
].join(' ')}
|
||||
onClick={stop}
|
||||
onMouseDown={stop}
|
||||
@ -211,7 +255,6 @@ export default function TagOverflowRow({
|
||||
role="dialog"
|
||||
aria-label="Tags"
|
||||
>
|
||||
{/* Close nur als Icon oben rechts */}
|
||||
<button
|
||||
type="button"
|
||||
className="
|
||||
@ -227,9 +270,14 @@ export default function TagOverflowRow({
|
||||
✕
|
||||
</button>
|
||||
|
||||
{/* volle Fläche für Tags */}
|
||||
<div className="h-full overflow-auto pr-1">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<div className="min-h-0 flex-1 overflow-auto px-3 pb-3 pr-2 pt-3">
|
||||
<div className="mb-2 pr-10">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Alle Tags
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-start gap-1.5 overflow-visible p-0.5">
|
||||
{sortedTags.map((t) => (
|
||||
<TagBadge
|
||||
key={t}
|
||||
@ -261,11 +309,11 @@ export default function TagOverflowRow({
|
||||
<button
|
||||
ref={plusMeasureRef}
|
||||
type="button"
|
||||
className="inline-flex items-center rounded-md bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-200"
|
||||
className="inline-flex min-h-[1.375rem] items-center rounded-md bg-gray-100 px-2 py-0.5 text-xs font-medium leading-none text-gray-700 ring-1 ring-inset ring-gray-200"
|
||||
>
|
||||
+99
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -257,7 +257,7 @@ function previewIdFromJob(job: RecordJob | null): string {
|
||||
function previewStillSrcFromJob(job: RecordJob | null): string {
|
||||
const id = previewIdFromJob(job)
|
||||
if (!id) return ''
|
||||
return `/api/preview?id=${encodeURIComponent(id)}&file=preview.webp`
|
||||
return `/api/preview?id=${encodeURIComponent(id)}&file=preview.jpg`
|
||||
}
|
||||
|
||||
function previewSpritesSrcFromJob(job: RecordJob | null): string {
|
||||
@ -1042,7 +1042,7 @@ export default function VideoSplitModal({
|
||||
}}
|
||||
title="Klicken zum Springen"
|
||||
>
|
||||
{/* preview-sprites.webp als Hintergrund */}
|
||||
{/* preview-sprites.jpg als Hintergrund */}
|
||||
{spriteMeta?.exists && spriteMeta.path && timelinePreviewTiles.length > 0 ? (
|
||||
<div
|
||||
className="absolute inset-0 grid gap-0 bg-black/20"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user