Compare commits
No commits in common. "a2891a2cf5d60ea2fd2a3d9eada8421b0713a1c7" and "e8bd9e9d680adeca04ab43a068cdc80e3c0eb387" have entirely different histories.
a2891a2cf5
...
e8bd9e9d68
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -41,19 +41,6 @@ func u64ToI64(x uint64) int64 {
|
|||||||
return int64(x)
|
return int64(x)
|
||||||
}
|
}
|
||||||
|
|
||||||
func isFFmpegInputInvalidError(err error) bool {
|
|
||||||
if err == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
s := strings.ToLower(err.Error())
|
|
||||||
|
|
||||||
// typische ffmpeg/mp4 Fehler bei unvollständigen Dateien
|
|
||||||
return strings.Contains(s, "moov atom not found") ||
|
|
||||||
strings.Contains(s, "invalid data found when processing input") ||
|
|
||||||
strings.Contains(s, "error opening input file") ||
|
|
||||||
strings.Contains(s, "error opening input")
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------
|
// -------------------------
|
||||||
// Asset layout helpers
|
// Asset layout helpers
|
||||||
// -------------------------
|
// -------------------------
|
||||||
@ -189,7 +176,6 @@ func ensureAssetsForVideoWithProgressCtx(ctx context.Context, videoPath string,
|
|||||||
// Core: generiert thumbs/preview/sprite/meta und sagt zurück was passiert ist.
|
// Core: generiert thumbs/preview/sprite/meta und sagt zurück was passiert ist.
|
||||||
func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceURL string, onRatio func(r float64)) (EnsureAssetsResult, error) {
|
func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceURL string, onRatio func(r float64)) (EnsureAssetsResult, error) {
|
||||||
var out EnsureAssetsResult
|
var out EnsureAssetsResult
|
||||||
var sourceInputInvalid bool
|
|
||||||
|
|
||||||
videoPath = strings.TrimSpace(videoPath)
|
videoPath = strings.TrimSpace(videoPath)
|
||||||
if videoPath == "" {
|
if videoPath == "" {
|
||||||
@ -201,12 +187,6 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔒 Schutz gegen Race: sehr frische Dateien sind evtl. noch nicht finalisiert/kopiert
|
|
||||||
// (typisch: moov atom fehlt noch)
|
|
||||||
if time.Since(fi.ModTime()) < 10*time.Second {
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
id := assetIDFromVideoPath(videoPath)
|
id := assetIDFromVideoPath(videoPath)
|
||||||
if id == "" {
|
if id == "" {
|
||||||
return out, nil
|
return out, nil
|
||||||
@ -346,7 +326,7 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
|
|||||||
|
|
||||||
progress(thumbsW + 0.05)
|
progress(thumbsW + 0.05)
|
||||||
|
|
||||||
if err := generateTeaserClipsMP4WithProgress(genCtx, videoPath, previewPath, 0.75, 12, func(r float64) {
|
if err := generateTeaserClipsMP4WithProgress(genCtx, videoPath, previewPath, 1.0, 18, func(r float64) {
|
||||||
if r < 0 {
|
if r < 0 {
|
||||||
r = 0
|
r = 0
|
||||||
}
|
}
|
||||||
@ -355,12 +335,6 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
|
|||||||
}
|
}
|
||||||
progress(thumbsW + r*previewW)
|
progress(thumbsW + r*previewW)
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
if isFFmpegInputInvalidError(err) {
|
|
||||||
sourceInputInvalid = true
|
|
||||||
fmt.Printf("⚠️ preview clips skipped (invalid/incomplete input): %s\n", videoPath)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("⚠️ preview clips:", err)
|
fmt.Println("⚠️ preview clips:", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -427,10 +401,6 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
func() {
|
func() {
|
||||||
if sourceInputInvalid {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// nur sinnvoll wenn wir Dauer kennen
|
// nur sinnvoll wenn wir Dauer kennen
|
||||||
if !(meta.durSec > 0) {
|
if !(meta.durSec > 0) {
|
||||||
return
|
return
|
||||||
|
|||||||
@ -90,15 +90,8 @@ func generatePreviewSpriteWebP(
|
|||||||
return fmt.Errorf("mkdir sprite dir: %w", err)
|
return fmt.Errorf("mkdir sprite dir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Temp-Datei im gleichen Verzeichnis für atomaren Replace.
|
// Temp-Datei im gleichen Verzeichnis für atomaren Replace
|
||||||
// Wichtig: ffmpeg erkennt das Output-Format über die Endung.
|
tmpPath := outPath + ".tmp"
|
||||||
// Deshalb muss .webp am Ende stehen (nicht "...webp.tmp").
|
|
||||||
ext := filepath.Ext(outPath)
|
|
||||||
if ext == "" {
|
|
||||||
ext = ".webp"
|
|
||||||
}
|
|
||||||
base := strings.TrimSuffix(outPath, ext)
|
|
||||||
tmpPath := base + ".tmp" + ext // z.B. preview-sprite.tmp.webp
|
|
||||||
|
|
||||||
// fps=1/stepSec nimmt alle stepSec Sekunden einen Frame
|
// fps=1/stepSec nimmt alle stepSec Sekunden einen Frame
|
||||||
// scale+pad erzwingt feste Zellgröße (wichtig für korrektes background-positioning im Frontend)
|
// scale+pad erzwingt feste Zellgröße (wichtig für korrektes background-positioning im Frontend)
|
||||||
@ -127,7 +120,6 @@ func generatePreviewSpriteWebP(
|
|||||||
"-lossless", "0",
|
"-lossless", "0",
|
||||||
"-compression_level", "6",
|
"-compression_level", "6",
|
||||||
"-q:v", "80",
|
"-q:v", "80",
|
||||||
"-f", "webp",
|
|
||||||
tmpPath,
|
tmpPath,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
6
backend/package-lock.json
generated
6
backend/package-lock.json
generated
@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "backend",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {}
|
|
||||||
}
|
|
||||||
@ -1,5 +1,3 @@
|
|||||||
// backend\preview_teaser.go
|
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@ -16,8 +14,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Minimale Segmentdauer, damit ffmpeg nicht mit zu kurzen Schnipseln zickt.
|
// Minimale Segmentdauer, damit ffmpeg nicht mit zu kurzen Schnipseln zickt.
|
||||||
const minSegmentDuration = 0.75 // Sekunden
|
const minSegmentDuration = 0.50 // Sekunden
|
||||||
const defaultTeaserSegments = 12
|
|
||||||
|
|
||||||
type TeaserPreviewOptions struct {
|
type TeaserPreviewOptions struct {
|
||||||
Segments int
|
Segments int
|
||||||
@ -174,7 +171,7 @@ func computeTeaserStarts(dur float64, opts TeaserPreviewOptions) (starts []float
|
|||||||
opts.SegmentDuration = 1
|
opts.SegmentDuration = 1
|
||||||
}
|
}
|
||||||
if opts.Segments <= 0 {
|
if opts.Segments <= 0 {
|
||||||
opts.Segments = defaultTeaserSegments
|
opts.Segments = 18
|
||||||
}
|
}
|
||||||
segDur = opts.SegmentDuration
|
segDur = opts.SegmentDuration
|
||||||
if segDur < minSegmentDuration {
|
if segDur < minSegmentDuration {
|
||||||
@ -230,7 +227,7 @@ func generateTeaserPreviewMP4WithProgress(
|
|||||||
opts.SegmentDuration = 1
|
opts.SegmentDuration = 1
|
||||||
}
|
}
|
||||||
if opts.Segments <= 0 {
|
if opts.Segments <= 0 {
|
||||||
opts.Segments = defaultTeaserSegments
|
opts.Segments = 18
|
||||||
}
|
}
|
||||||
if opts.Width <= 0 {
|
if opts.Width <= 0 {
|
||||||
opts.Width = 640
|
opts.Width = 640
|
||||||
1
backend/web/dist/assets/index-CIN0UidG.css
vendored
1
backend/web/dist/assets/index-CIN0UidG.css
vendored
File diff suppressed because one or more lines are too long
14
frontend/package-lock.json
generated
14
frontend/package-lock.json
generated
@ -64,6 +64,7 @@
|
|||||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
@ -1823,6 +1824,7 @@
|
|||||||
"integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==",
|
"integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
@ -1833,6 +1835,7 @@
|
|||||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@ -1892,6 +1895,7 @@
|
|||||||
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
|
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.50.0",
|
"@typescript-eslint/scope-manager": "8.50.0",
|
||||||
"@typescript-eslint/types": "8.50.0",
|
"@typescript-eslint/types": "8.50.0",
|
||||||
@ -2200,6 +2204,7 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@ -2317,6 +2322,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@ -2573,6 +2579,7 @@
|
|||||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@ -3621,6 +3628,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@ -3713,6 +3721,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@ -3722,6 +3731,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@ -3949,6 +3959,7 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@ -4043,6 +4054,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/video.js/-/video.js-8.23.4.tgz",
|
"resolved": "https://registry.npmjs.org/video.js/-/video.js-8.23.4.tgz",
|
||||||
"integrity": "sha512-qI0VTlYmKzEqRsz1Nppdfcaww4RSxZAq77z2oNSl3cNg2h6do5C8Ffl0KqWQ1OpD8desWXsCrde7tKJ9gGTEyQ==",
|
"integrity": "sha512-qI0VTlYmKzEqRsz1Nppdfcaww4RSxZAq77z2oNSl3cNg2h6do5C8Ffl0KqWQ1OpD8desWXsCrde7tKJ9gGTEyQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.12.5",
|
"@babel/runtime": "^7.12.5",
|
||||||
"@videojs/http-streaming": "^3.17.2",
|
"@videojs/http-streaming": "^3.17.2",
|
||||||
@ -4094,6 +4106,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
|
||||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@ -4215,6 +4228,7 @@
|
|||||||
"integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==",
|
"integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1023,16 +1023,7 @@ export default function FinishedDownloads({
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const clearRenamePair = useCallback((a: string, b: string) => {
|
|
||||||
if (!a && !b) return
|
|
||||||
setRenamedFiles((prev) => {
|
|
||||||
const next: Record<string, string> = { ...prev }
|
|
||||||
for (const [k, v] of Object.entries(next)) {
|
|
||||||
if (k === a || k === b || v === a || v === b) delete next[k]
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const undoLastAction = useCallback(async () => {
|
const undoLastAction = useCallback(async () => {
|
||||||
if (!lastAction || undoing) return
|
if (!lastAction || undoing) return
|
||||||
@ -1215,7 +1206,7 @@ export default function FinishedDownloads({
|
|||||||
queueRefill()
|
queueRefill()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
// ❌ Rollback, weil Optimistik schon angewendet wurde
|
// ❌ Rollback, weil Optimistik schon angewendet wurde
|
||||||
clearRenamePair(oldFile, optimisticNew)
|
applyRename(optimisticNew, oldFile)
|
||||||
|
|
||||||
// und Undo-Action löschen (sonst zeigt Undo auf etwas, das nie passiert ist)
|
// und Undo-Action löschen (sonst zeigt Undo auf etwas, das nie passiert ist)
|
||||||
setLastAction(null)
|
setLastAction(null)
|
||||||
@ -1223,7 +1214,7 @@ export default function FinishedDownloads({
|
|||||||
notify.error('HOT umbenennen fehlgeschlagen', String(e?.message || e))
|
notify.error('HOT umbenennen fehlgeschlagen', String(e?.message || e))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[notify, applyRename, clearRenamePair, releasePlayingFile, onToggleHot, queueRefill, sortMode]
|
[notify, applyRename, releasePlayingFile, onToggleHot, queueRefill, sortMode]
|
||||||
)
|
)
|
||||||
|
|
||||||
const applyRenamedOutput = useCallback(
|
const applyRenamedOutput = useCallback(
|
||||||
|
|||||||
@ -13,7 +13,6 @@ import {
|
|||||||
} from '@heroicons/react/24/solid'
|
} from '@heroicons/react/24/solid'
|
||||||
import RecordJobActions from './RecordJobActions'
|
import RecordJobActions from './RecordJobActions'
|
||||||
import TagOverflowRow from './TagOverflowRow'
|
import TagOverflowRow from './TagOverflowRow'
|
||||||
import PreviewScrubber from './PreviewScrubber'
|
|
||||||
import { isHotName, stripHotPrefix } from './hotName'
|
import { isHotName, stripHotPrefix } from './hotName'
|
||||||
import { formatResolution } from './formatters'
|
import { formatResolution } from './formatters'
|
||||||
|
|
||||||
@ -64,15 +63,7 @@ type Props = {
|
|||||||
|
|
||||||
releasePlayingFile: (file: string, opts?: { close?: boolean }) => Promise<void>
|
releasePlayingFile: (file: string, opts?: { close?: boolean }) => Promise<void>
|
||||||
|
|
||||||
modelsByKey: Record<string, {
|
modelsByKey: Record<string, { favorite?: boolean; liked?: boolean | null; watching?: boolean | null; tags?: string }>
|
||||||
favorite?: boolean
|
|
||||||
liked?: boolean | null
|
|
||||||
watching?: boolean | null
|
|
||||||
tags?: string
|
|
||||||
// ✅ wie GalleryView: optionale Scrubber-Meta-Fallbacks
|
|
||||||
previewScrubberPath?: string
|
|
||||||
previewScrubberCount?: number
|
|
||||||
}>
|
|
||||||
activeTagSet: Set<string>
|
activeTagSet: Set<string>
|
||||||
onToggleTagFilter: (tag: string) => void
|
onToggleTagFilter: (tag: string) => void
|
||||||
|
|
||||||
@ -101,58 +92,6 @@ const parseTags = (raw?: string): string[] => {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
function firstNonEmptyString(...values: unknown[]): string | undefined {
|
|
||||||
for (const v of values) {
|
|
||||||
if (typeof v === 'string') {
|
|
||||||
const s = v.trim()
|
|
||||||
if (s) return s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDurationSeconds(value: unknown): number | undefined {
|
|
||||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
|
|
||||||
// ms -> s Heuristik wie in GalleryView / FinishedVideoPreview
|
|
||||||
return value > 24 * 60 * 60 ? value / 1000 : value
|
|
||||||
}
|
|
||||||
|
|
||||||
function clamp(n: number, min: number, max: number) {
|
|
||||||
return Math.max(min, Math.min(max, n))
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_SPRITE_STEP_SECONDS = 5
|
|
||||||
|
|
||||||
function chooseSpriteGrid(count: number): [number, number] {
|
|
||||||
if (count <= 1) return [1, 1]
|
|
||||||
|
|
||||||
const targetRatio = 16 / 9
|
|
||||||
let bestCols = 1
|
|
||||||
let bestRows = count
|
|
||||||
let bestWaste = Number.POSITIVE_INFINITY
|
|
||||||
let bestRatioScore = Number.POSITIVE_INFINITY
|
|
||||||
|
|
||||||
for (let c = 1; c <= count; c++) {
|
|
||||||
const r = Math.max(1, Math.ceil(count / c))
|
|
||||||
const waste = c * r - count
|
|
||||||
const ratio = c / r
|
|
||||||
const ratioScore = Math.abs(ratio - targetRatio)
|
|
||||||
|
|
||||||
if (
|
|
||||||
waste < bestWaste ||
|
|
||||||
(waste === bestWaste && ratioScore < bestRatioScore) ||
|
|
||||||
(waste === bestWaste && ratioScore === bestRatioScore && r < bestRows)
|
|
||||||
) {
|
|
||||||
bestWaste = waste
|
|
||||||
bestRatioScore = ratioScore
|
|
||||||
bestCols = c
|
|
||||||
bestRows = r
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [bestCols, bestRows]
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function FinishedDownloadsCardsView({
|
export default function FinishedDownloadsCardsView({
|
||||||
rows,
|
rows,
|
||||||
isSmall,
|
isSmall,
|
||||||
@ -161,7 +100,6 @@ export default function FinishedDownloadsCardsView({
|
|||||||
teaserAudio,
|
teaserAudio,
|
||||||
hoverTeaserKey,
|
hoverTeaserKey,
|
||||||
blurPreviews,
|
blurPreviews,
|
||||||
durations, // ✅ fehlte
|
|
||||||
teaserKey,
|
teaserKey,
|
||||||
inlinePlay,
|
inlinePlay,
|
||||||
deletingKeys,
|
deletingKeys,
|
||||||
@ -185,7 +123,6 @@ export default function FinishedDownloadsCardsView({
|
|||||||
startInline,
|
startInline,
|
||||||
tryAutoplayInline,
|
tryAutoplayInline,
|
||||||
registerTeaserHost,
|
registerTeaserHost,
|
||||||
handleDuration,
|
|
||||||
|
|
||||||
deleteVideo,
|
deleteVideo,
|
||||||
keepVideo,
|
keepVideo,
|
||||||
@ -199,102 +136,19 @@ export default function FinishedDownloadsCardsView({
|
|||||||
onToggleLike,
|
onToggleLike,
|
||||||
onToggleWatch,
|
onToggleWatch,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
// ✅ Auflösung als {w,h} aus meta.json bevorzugen
|
||||||
const parseMeta = React.useCallback((j: RecordJob): any | null => {
|
|
||||||
const raw: any = (j as any)?.meta
|
|
||||||
if (!raw) return null
|
|
||||||
if (typeof raw === 'string') {
|
|
||||||
try {
|
|
||||||
return JSON.parse(raw)
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return raw
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const resolutionObjOf = React.useCallback((j: RecordJob): { w: number; h: number } | null => {
|
const resolutionObjOf = React.useCallback((j: RecordJob): { w: number; h: number } | null => {
|
||||||
const meta = parseMeta(j)
|
|
||||||
|
|
||||||
const w =
|
const w =
|
||||||
(typeof meta?.videoWidth === 'number' && Number.isFinite(meta.videoWidth) ? meta.videoWidth : 0) ||
|
(typeof j.meta?.videoWidth === 'number' && Number.isFinite(j.meta.videoWidth) ? j.meta.videoWidth : 0) ||
|
||||||
(typeof j.videoWidth === 'number' && Number.isFinite(j.videoWidth) ? j.videoWidth : 0)
|
(typeof j.videoWidth === 'number' && Number.isFinite(j.videoWidth) ? j.videoWidth : 0)
|
||||||
|
|
||||||
const h =
|
const h =
|
||||||
(typeof meta?.videoHeight === 'number' && Number.isFinite(meta.videoHeight) ? meta.videoHeight : 0) ||
|
(typeof j.meta?.videoHeight === 'number' && Number.isFinite(j.meta.videoHeight) ? j.meta.videoHeight : 0) ||
|
||||||
(typeof j.videoHeight === 'number' && Number.isFinite(j.videoHeight) ? j.videoHeight : 0)
|
(typeof j.videoHeight === 'number' && Number.isFinite(j.videoHeight) ? j.videoHeight : 0)
|
||||||
|
|
||||||
if (w > 0 && h > 0) return { w, h }
|
if (w > 0 && h > 0) return { w, h }
|
||||||
return null
|
return null
|
||||||
}, [parseMeta])
|
|
||||||
|
|
||||||
const previewScrubberInfoOf = React.useCallback(
|
|
||||||
(j: RecordJob): { count: number; stepSeconds: number } | null => {
|
|
||||||
const meta = parseMeta(j)
|
|
||||||
|
|
||||||
let ps: any =
|
|
||||||
meta?.previewSprite ??
|
|
||||||
meta?.preview?.sprite ??
|
|
||||||
meta?.sprite ??
|
|
||||||
(j as any)?.previewSprite ??
|
|
||||||
(j as any)?.preview?.sprite ??
|
|
||||||
null
|
|
||||||
|
|
||||||
if (typeof ps === 'string') {
|
|
||||||
try {
|
|
||||||
ps = JSON.parse(ps)
|
|
||||||
} catch {
|
|
||||||
ps = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const countRaw = Number(ps?.count ?? ps?.frames ?? ps?.imageCount)
|
|
||||||
const stepRaw = Number(ps?.stepSeconds ?? ps?.step ?? ps?.intervalSeconds)
|
|
||||||
|
|
||||||
if (Number.isFinite(countRaw) && countRaw >= 2) {
|
|
||||||
return {
|
|
||||||
count: Math.max(2, Math.floor(countRaw)),
|
|
||||||
stepSeconds: Number.isFinite(stepRaw) && stepRaw > 0 ? stepRaw : 5,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: aus Dauer ableiten
|
|
||||||
const k = keyFor(j)
|
|
||||||
const dur =
|
|
||||||
durations[k] ??
|
|
||||||
(typeof (j as any)?.durationSeconds === 'number' ? (j as any).durationSeconds : undefined)
|
|
||||||
|
|
||||||
if (typeof dur === 'number' && Number.isFinite(dur) && dur > 1) {
|
|
||||||
const stepSeconds = 5
|
|
||||||
const count = Math.max(2, Math.min(240, Math.floor(dur / stepSeconds) + 1))
|
|
||||||
return { count, stepSeconds }
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
},
|
|
||||||
[parseMeta, keyFor, durations]
|
|
||||||
)
|
|
||||||
|
|
||||||
const [scrubActiveByKey, setScrubActiveByKey] = React.useState<Record<string, number | undefined>>({})
|
|
||||||
|
|
||||||
const setScrubActiveIndex = React.useCallback((key: string, index: number | undefined) => {
|
|
||||||
setScrubActiveByKey((prev) => {
|
|
||||||
if (index === undefined) {
|
|
||||||
if (!(key in prev)) return prev
|
|
||||||
const next = { ...prev }
|
|
||||||
delete next[key]
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
if (prev[key] === index) return prev
|
|
||||||
return { ...prev, [key]: index }
|
|
||||||
})
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const clearScrubActiveIndex = React.useCallback((key: string) => {
|
|
||||||
setScrubActiveIndex(key, undefined)
|
|
||||||
}, [setScrubActiveIndex])
|
|
||||||
|
|
||||||
const renderCardItem = (
|
const renderCardItem = (
|
||||||
j: RecordJob,
|
j: RecordJob,
|
||||||
opts?: {
|
opts?: {
|
||||||
@ -337,129 +191,9 @@ export default function FinishedDownloadsCardsView({
|
|||||||
|
|
||||||
const model = modelNameFromOutput(j.output)
|
const model = modelNameFromOutput(j.output)
|
||||||
const fileRaw = baseName(j.output || '')
|
const fileRaw = baseName(j.output || '')
|
||||||
const previewId = stripHotPrefix(fileRaw.replace(/\.[^.]+$/, '')).trim()
|
|
||||||
const modelKey = lower(model)
|
|
||||||
const flags = modelsByKey[modelKey]
|
|
||||||
|
|
||||||
const meta = parseMeta(j)
|
|
||||||
const spriteInfo = previewScrubberInfoOf(j)
|
|
||||||
const scrubActiveIndex = scrubActiveByKey[k]
|
|
||||||
|
|
||||||
// ✅ Sprite-Quelle wie in GalleryView (1 Request, danach nur CSS background-position)
|
|
||||||
const spritePathRaw = firstNonEmptyString(
|
|
||||||
meta?.previewSprite?.path,
|
|
||||||
(meta as any)?.previewSpritePath,
|
|
||||||
flags?.previewScrubberPath,
|
|
||||||
previewId ? `/api/preview-sprite/${encodeURIComponent(previewId)}` : undefined
|
|
||||||
)
|
|
||||||
const spritePath = spritePathRaw ? spritePathRaw.replace(/\/+$/, '') : undefined
|
|
||||||
|
|
||||||
const spriteStepSecondsRaw =
|
|
||||||
meta?.previewSprite?.stepSeconds ?? (meta as any)?.previewSpriteStepSeconds
|
|
||||||
|
|
||||||
const spriteStepSeconds =
|
|
||||||
typeof spriteStepSecondsRaw === 'number' &&
|
|
||||||
Number.isFinite(spriteStepSecondsRaw) &&
|
|
||||||
spriteStepSecondsRaw > 0
|
|
||||||
? spriteStepSecondsRaw
|
|
||||||
: (spriteInfo?.stepSeconds && spriteInfo.stepSeconds > 0
|
|
||||||
? spriteInfo.stepSeconds
|
|
||||||
: DEFAULT_SPRITE_STEP_SECONDS)
|
|
||||||
|
|
||||||
// Dauer fallback (für count-Inferenz)
|
|
||||||
const durationForSprite =
|
|
||||||
normalizeDurationSeconds(meta?.durationSeconds) ??
|
|
||||||
normalizeDurationSeconds((j as any)?.durationSeconds) ??
|
|
||||||
normalizeDurationSeconds(durations[k])
|
|
||||||
|
|
||||||
const inferredSpriteCountFromDuration =
|
|
||||||
typeof durationForSprite === 'number' && durationForSprite > 0
|
|
||||||
? Math.max(1, Math.min(200, Math.floor(durationForSprite / spriteStepSeconds) + 1))
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const spriteCountRaw =
|
|
||||||
meta?.previewSprite?.count ??
|
|
||||||
(meta as any)?.previewSpriteCount ??
|
|
||||||
flags?.previewScrubberCount ??
|
|
||||||
inferredSpriteCountFromDuration
|
|
||||||
|
|
||||||
const spriteColsRaw = meta?.previewSprite?.cols ?? (meta as any)?.previewSpriteCols
|
|
||||||
const spriteRowsRaw = meta?.previewSprite?.rows ?? (meta as any)?.previewSpriteRows
|
|
||||||
|
|
||||||
const spriteCount =
|
|
||||||
typeof spriteCountRaw === 'number' && Number.isFinite(spriteCountRaw)
|
|
||||||
? Math.max(0, Math.floor(spriteCountRaw))
|
|
||||||
: 0
|
|
||||||
|
|
||||||
const [inferredCols, inferredRows] =
|
|
||||||
spriteCount > 1 ? chooseSpriteGrid(spriteCount) : [0, 0]
|
|
||||||
|
|
||||||
// ✅ explizite Werte robust lesen (auch wenn mal String kommt)
|
|
||||||
const explicitSpriteCols = Number(spriteColsRaw)
|
|
||||||
const explicitSpriteRows = Number(spriteRowsRaw)
|
|
||||||
|
|
||||||
// ✅ Nur übernehmen, wenn plausibel.
|
|
||||||
// Verhindert den Fall cols=1/rows=1 bei count>1 (zeigt sonst ganze Sprite-Map)
|
|
||||||
const explicitGridLooksValid =
|
|
||||||
Number.isFinite(explicitSpriteCols) &&
|
|
||||||
Number.isFinite(explicitSpriteRows) &&
|
|
||||||
explicitSpriteCols > 0 &&
|
|
||||||
explicitSpriteRows > 0 &&
|
|
||||||
!(spriteCount > 1 && explicitSpriteCols === 1 && explicitSpriteRows === 1) &&
|
|
||||||
(spriteCount <= 1 || explicitSpriteCols * explicitSpriteRows >= spriteCount)
|
|
||||||
|
|
||||||
const spriteCols = explicitGridLooksValid ? Math.floor(explicitSpriteCols) : inferredCols
|
|
||||||
const spriteRows = explicitGridLooksValid ? Math.floor(explicitSpriteRows) : inferredRows
|
|
||||||
|
|
||||||
const spriteVersion =
|
|
||||||
(typeof meta?.updatedAtUnix === 'number' && Number.isFinite(meta.updatedAtUnix)
|
|
||||||
? meta.updatedAtUnix
|
|
||||||
: undefined) ??
|
|
||||||
(typeof (meta as any)?.fileModUnix === 'number' && Number.isFinite((meta as any).fileModUnix)
|
|
||||||
? (meta as any).fileModUnix
|
|
||||||
: undefined) ??
|
|
||||||
(assetNonce ?? 0)
|
|
||||||
|
|
||||||
const spriteUrl =
|
|
||||||
spritePath && spriteVersion
|
|
||||||
? `${spritePath}?v=${encodeURIComponent(String(spriteVersion))}`
|
|
||||||
: spritePath || undefined
|
|
||||||
|
|
||||||
const hasScrubberUi =
|
|
||||||
Boolean(spriteUrl) &&
|
|
||||||
spriteCount > 1
|
|
||||||
|
|
||||||
const hasSpriteScrubber =
|
|
||||||
hasScrubberUi &&
|
|
||||||
spriteCols > 0 &&
|
|
||||||
spriteRows > 0
|
|
||||||
|
|
||||||
const scrubberCount = hasScrubberUi ? spriteCount : 0
|
|
||||||
const scrubberStepSeconds = hasScrubberUi ? spriteStepSeconds : 0
|
|
||||||
|
|
||||||
const spriteFrameStyle: React.CSSProperties | undefined =
|
|
||||||
hasSpriteScrubber && typeof scrubActiveIndex === 'number'
|
|
||||||
? (() => {
|
|
||||||
const idx = clamp(scrubActiveIndex, 0, Math.max(0, spriteCount - 1))
|
|
||||||
const col = idx % spriteCols
|
|
||||||
const row = Math.floor(idx / spriteCols)
|
|
||||||
|
|
||||||
const posX = spriteCols <= 1 ? 0 : (col / (spriteCols - 1)) * 100
|
|
||||||
const posY = spriteRows <= 1 ? 0 : (row / (spriteRows - 1)) * 100
|
|
||||||
|
|
||||||
return {
|
|
||||||
backgroundImage: `url("${spriteUrl}")`,
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
backgroundSize: `${spriteCols * 100}% ${spriteRows * 100}%`,
|
|
||||||
backgroundPosition: `${posX}% ${posY}%`,
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const showScrubberSpriteInThumb = Boolean(spriteFrameStyle)
|
|
||||||
const hideTeaserUnderOverlay = showScrubberSpriteInThumb
|
|
||||||
|
|
||||||
const isHot = isHotName(fileRaw)
|
const isHot = isHotName(fileRaw)
|
||||||
|
|
||||||
|
const flags = modelsByKey[lower(model)]
|
||||||
const isFav = Boolean(flags?.favorite)
|
const isFav = Boolean(flags?.favorite)
|
||||||
const isLiked = flags?.liked === true
|
const isLiked = flags?.liked === true
|
||||||
const isWatching = Boolean(flags?.watching)
|
const isWatching = Boolean(flags?.watching)
|
||||||
@ -496,10 +230,7 @@ export default function FinishedDownloadsCardsView({
|
|||||||
className={shellCls}
|
className={shellCls}
|
||||||
onClick={isSmall ? undefined : () => openPlayer(j)}
|
onClick={isSmall ? undefined : () => openPlayer(j)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
|
||||||
e.preventDefault()
|
|
||||||
onOpenPlayer(j)
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Card shell keeps backgrounds consistent */}
|
{/* Card shell keeps backgrounds consistent */}
|
||||||
@ -516,10 +247,9 @@ export default function FinishedDownloadsCardsView({
|
|||||||
onMouseEnter={
|
onMouseEnter={
|
||||||
isSmall || opts?.disablePreviewHover ? undefined : () => onHoverPreviewKeyChange?.(k)
|
isSmall || opts?.disablePreviewHover ? undefined : () => onHoverPreviewKeyChange?.(k)
|
||||||
}
|
}
|
||||||
onMouseLeave={() => {
|
onMouseLeave={
|
||||||
if (!isSmall && !opts?.disablePreviewHover) onHoverPreviewKeyChange?.(null)
|
isSmall || opts?.disablePreviewHover ? undefined : () => onHoverPreviewKeyChange?.(null)
|
||||||
clearScrubActiveIndex(k)
|
}
|
||||||
}}
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
@ -528,13 +258,12 @@ export default function FinishedDownloadsCardsView({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* media */}
|
{/* media */}
|
||||||
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
|
<div className="absolute inset-0">
|
||||||
{/* Meta-Overlay im Video nur anzeigen, wenn KEIN Inline-Player aktiv ist */}
|
{/* Meta-Overlay im Video nur anzeigen, wenn KEIN Inline-Player aktiv ist */}
|
||||||
{!inlineActive ? (
|
{!inlineActive ? (
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
'pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 ' +
|
'pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 ' +
|
||||||
'group-hover:opacity-0 group-focus-within:opacity-0 ' +
|
|
||||||
(inlineActive ? 'opacity-0' : 'opacity-100')
|
(inlineActive ? 'opacity-0' : 'opacity-100')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@ -559,18 +288,13 @@ export default function FinishedDownloadsCardsView({
|
|||||||
|
|
||||||
<FinishedVideoPreview
|
<FinishedVideoPreview
|
||||||
job={j}
|
job={j}
|
||||||
getFileName={(p) => stripHotPrefix(baseName(p))}
|
getFileName={baseName}
|
||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
variant="fill"
|
|
||||||
durationSeconds={durations[k] ?? (j as any)?.durationSeconds}
|
|
||||||
onDuration={handleDuration}
|
|
||||||
showPopover={false}
|
showPopover={false}
|
||||||
blur={inlineActive ? false : Boolean(blurPreviews)}
|
blur={inlineActive ? false : Boolean(blurPreviews)}
|
||||||
animated={hideTeaserUnderOverlay ? false : allowTeaserAnimation}
|
animated={allowTeaserAnimation}
|
||||||
animatedMode="teaser"
|
animatedMode="teaser"
|
||||||
animatedTrigger="always"
|
animatedTrigger="always"
|
||||||
clipSeconds={1}
|
|
||||||
thumbSamples={18}
|
|
||||||
inlineVideo={!opts?.disableInline && inlineActive ? 'always' : false}
|
inlineVideo={!opts?.disableInline && inlineActive ? 'always' : false}
|
||||||
inlineNonce={inlineNonce}
|
inlineNonce={inlineNonce}
|
||||||
inlineControls={inlineActive}
|
inlineControls={inlineActive}
|
||||||
@ -578,46 +302,13 @@ export default function FinishedDownloadsCardsView({
|
|||||||
muted={previewMuted}
|
muted={previewMuted}
|
||||||
popoverMuted={previewMuted}
|
popoverMuted={previewMuted}
|
||||||
assetNonce={assetNonce ?? 0}
|
assetNonce={assetNonce ?? 0}
|
||||||
alwaysLoadStill={forceLoadStill}
|
alwaysLoadStill={forceLoadStill || true}
|
||||||
teaserPreloadEnabled={opts?.mobileStackTopOnlyVideo ? true : !isSmall}
|
teaserPreloadEnabled={
|
||||||
|
// ✅ im Mobile-Stack nur für Top-Card Teaser preloaden
|
||||||
|
opts?.mobileStackTopOnlyVideo ? true : !isSmall
|
||||||
|
}
|
||||||
teaserPreloadRootMargin={isSmall ? '900px 0px' : '700px 0px'}
|
teaserPreloadRootMargin={isSmall ? '900px 0px' : '700px 0px'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ✅ Sprite einmal vorladen, damit der erste Scrub-Move sofort sichtbar ist */}
|
|
||||||
{hasSpriteScrubber && spriteUrl ? (
|
|
||||||
<img
|
|
||||||
src={spriteUrl}
|
|
||||||
alt=""
|
|
||||||
className="hidden"
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* ✅ Scrub-Frame Overlay via Sprite (kein Request pro Move) */}
|
|
||||||
{hasSpriteScrubber && spriteFrameStyle ? (
|
|
||||||
<div className="absolute inset-0 z-[5]" aria-hidden="true">
|
|
||||||
<div className="h-full w-full" style={spriteFrameStyle} />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* ✅ stashapp-artiger Hover-Scrubber (wie GalleryView) */}
|
|
||||||
{!opts?.isDecorative && scrubberCount > 1 ? (
|
|
||||||
<div
|
|
||||||
className="absolute inset-x-0 bottom-0 z-30 pointer-events-none opacity-100 transition-opacity duration-150"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<PreviewScrubber
|
|
||||||
className="pointer-events-auto px-1"
|
|
||||||
imageCount={scrubberCount}
|
|
||||||
activeIndex={scrubActiveIndex}
|
|
||||||
onActiveIndexChange={(idx) => setScrubActiveIndex(k, idx)}
|
|
||||||
stepSeconds={scrubberStepSeconds}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import RecordJobActions from './RecordJobActions'
|
|||||||
import TagOverflowRow from './TagOverflowRow'
|
import TagOverflowRow from './TagOverflowRow'
|
||||||
import { isHotName, stripHotPrefix } from './hotName'
|
import { isHotName, stripHotPrefix } from './hotName'
|
||||||
import { formatResolution } from './formatters'
|
import { formatResolution } from './formatters'
|
||||||
import PreviewScrubber from './PreviewScrubber'
|
import GalleryPreviewScrubber from './GalleryPreviewScrubber'
|
||||||
|
|
||||||
type ModelFlags = {
|
type ModelFlags = {
|
||||||
favorite?: boolean
|
favorite?: boolean
|
||||||
@ -107,36 +107,6 @@ function clamp(n: number, min: number, max: number) {
|
|||||||
|
|
||||||
const DEFAULT_SPRITE_STEP_SECONDS = 5
|
const DEFAULT_SPRITE_STEP_SECONDS = 5
|
||||||
|
|
||||||
function chooseSpriteGrid(count: number): [number, number] {
|
|
||||||
if (count <= 1) return [1, 1]
|
|
||||||
|
|
||||||
const targetRatio = 16 / 9
|
|
||||||
let bestCols = 1
|
|
||||||
let bestRows = count
|
|
||||||
let bestWaste = Number.POSITIVE_INFINITY
|
|
||||||
let bestRatioScore = Number.POSITIVE_INFINITY
|
|
||||||
|
|
||||||
for (let c = 1; c <= count; c++) {
|
|
||||||
const r = Math.max(1, Math.ceil(count / c))
|
|
||||||
const waste = c * r - count
|
|
||||||
const ratio = c / r
|
|
||||||
const ratioScore = Math.abs(ratio - targetRatio)
|
|
||||||
|
|
||||||
if (
|
|
||||||
waste < bestWaste ||
|
|
||||||
(waste === bestWaste && ratioScore < bestRatioScore) ||
|
|
||||||
(waste === bestWaste && ratioScore === bestRatioScore && r < bestRows)
|
|
||||||
) {
|
|
||||||
bestWaste = waste
|
|
||||||
bestRatioScore = ratioScore
|
|
||||||
bestCols = c
|
|
||||||
bestRows = r
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [bestCols, bestRows]
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function FinishedDownloadsGalleryView({
|
export default function FinishedDownloadsGalleryView({
|
||||||
rows,
|
rows,
|
||||||
isLoading,
|
isLoading,
|
||||||
@ -307,14 +277,32 @@ export default function FinishedDownloadsGalleryView({
|
|||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
const spritePathRaw = firstNonEmptyString(
|
const spritePathRaw = firstNonEmptyString(
|
||||||
meta?.previewSprite?.path,
|
meta?.previewSprite?.path,
|
||||||
|
// optional weitere Fallback-Felder, falls du sie so speicherst:
|
||||||
(meta as any)?.previewSpritePath,
|
(meta as any)?.previewSpritePath,
|
||||||
flags?.previewScrubberPath,
|
// optional API-Fallback-Path (nur sinnvoll, wenn Backend existiert)
|
||||||
previewId ? `/api/preview-sprite/${encodeURIComponent(previewId)}` : undefined
|
previewId ? `/api/preview-sprite/${encodeURIComponent(previewId)}` : undefined
|
||||||
)
|
)
|
||||||
const spritePath = spritePathRaw ? spritePathRaw.replace(/\/+$/, '') : undefined
|
const spritePath = spritePathRaw ? spritePathRaw.replace(/\/+$/, '') : undefined
|
||||||
|
|
||||||
const spriteStepSecondsRaw =
|
const spriteCountRaw = meta?.previewSprite?.count ?? (meta as any)?.previewSpriteCount
|
||||||
meta?.previewSprite?.stepSeconds ?? (meta as any)?.previewSpriteStepSeconds
|
const spriteColsRaw = meta?.previewSprite?.cols ?? (meta as any)?.previewSpriteCols
|
||||||
|
const spriteRowsRaw = meta?.previewSprite?.rows ?? (meta as any)?.previewSpriteRows
|
||||||
|
const spriteStepSecondsRaw = meta?.previewSprite?.stepSeconds ?? (meta as any)?.previewSpriteStepSeconds
|
||||||
|
|
||||||
|
const spriteCount =
|
||||||
|
typeof spriteCountRaw === 'number' && Number.isFinite(spriteCountRaw)
|
||||||
|
? Math.max(0, Math.floor(spriteCountRaw))
|
||||||
|
: 0
|
||||||
|
|
||||||
|
const spriteCols =
|
||||||
|
typeof spriteColsRaw === 'number' && Number.isFinite(spriteColsRaw)
|
||||||
|
? Math.max(0, Math.floor(spriteColsRaw))
|
||||||
|
: 0
|
||||||
|
|
||||||
|
const spriteRows =
|
||||||
|
typeof spriteRowsRaw === 'number' && Number.isFinite(spriteRowsRaw)
|
||||||
|
? Math.max(0, Math.floor(spriteRowsRaw))
|
||||||
|
: 0
|
||||||
|
|
||||||
const spriteStepSeconds =
|
const spriteStepSeconds =
|
||||||
typeof spriteStepSecondsRaw === 'number' &&
|
typeof spriteStepSecondsRaw === 'number' &&
|
||||||
@ -323,46 +311,6 @@ export default function FinishedDownloadsGalleryView({
|
|||||||
? spriteStepSecondsRaw
|
? spriteStepSecondsRaw
|
||||||
: DEFAULT_SPRITE_STEP_SECONDS
|
: DEFAULT_SPRITE_STEP_SECONDS
|
||||||
|
|
||||||
// ✅ Fallback-Dauer (meta -> job -> durations cache)
|
|
||||||
const durationForSprite =
|
|
||||||
normalizeDurationSeconds(meta?.durationSeconds) ??
|
|
||||||
normalizeDurationSeconds((j as any)?.durationSeconds) ??
|
|
||||||
normalizeDurationSeconds(durations[k])
|
|
||||||
|
|
||||||
// ✅ Count aus Meta/Flags ODER aus Dauer ableiten
|
|
||||||
const inferredSpriteCountFromDuration =
|
|
||||||
typeof durationForSprite === 'number' && durationForSprite > 0
|
|
||||||
? Math.max(1, Math.min(200, Math.floor(durationForSprite / spriteStepSeconds) + 1))
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const spriteCountRaw =
|
|
||||||
meta?.previewSprite?.count ??
|
|
||||||
(meta as any)?.previewSpriteCount ??
|
|
||||||
flags?.previewScrubberCount ??
|
|
||||||
inferredSpriteCountFromDuration
|
|
||||||
|
|
||||||
const spriteColsRaw = meta?.previewSprite?.cols ?? (meta as any)?.previewSpriteCols
|
|
||||||
const spriteRowsRaw = meta?.previewSprite?.rows ?? (meta as any)?.previewSpriteRows
|
|
||||||
|
|
||||||
const spriteCount =
|
|
||||||
typeof spriteCountRaw === 'number' && Number.isFinite(spriteCountRaw)
|
|
||||||
? Math.max(0, Math.floor(spriteCountRaw))
|
|
||||||
: 0
|
|
||||||
|
|
||||||
// ✅ Wenn cols/rows fehlen, aus Count ableiten (wie Backend)
|
|
||||||
const [inferredCols, inferredRows] =
|
|
||||||
spriteCount > 1 ? chooseSpriteGrid(spriteCount) : [0, 0]
|
|
||||||
|
|
||||||
const spriteCols =
|
|
||||||
typeof spriteColsRaw === 'number' && Number.isFinite(spriteColsRaw)
|
|
||||||
? Math.max(0, Math.floor(spriteColsRaw))
|
|
||||||
: inferredCols
|
|
||||||
|
|
||||||
const spriteRows =
|
|
||||||
typeof spriteRowsRaw === 'number' && Number.isFinite(spriteRowsRaw)
|
|
||||||
? Math.max(0, Math.floor(spriteRowsRaw))
|
|
||||||
: inferredRows
|
|
||||||
|
|
||||||
// Optionaler Cache-Buster (wenn du sowas in meta hast)
|
// Optionaler Cache-Buster (wenn du sowas in meta hast)
|
||||||
const spriteVersion =
|
const spriteVersion =
|
||||||
(typeof meta?.updatedAtUnix === 'number' && Number.isFinite(meta.updatedAtUnix)
|
(typeof meta?.updatedAtUnix === 'number' && Number.isFinite(meta.updatedAtUnix)
|
||||||
@ -378,19 +326,16 @@ export default function FinishedDownloadsGalleryView({
|
|||||||
? `${spritePath}?v=${encodeURIComponent(String(spriteVersion))}`
|
? `${spritePath}?v=${encodeURIComponent(String(spriteVersion))}`
|
||||||
: spritePath || undefined
|
: spritePath || undefined
|
||||||
|
|
||||||
const hasScrubberUi =
|
|
||||||
Boolean(spriteUrl) &&
|
|
||||||
spriteCount > 1
|
|
||||||
|
|
||||||
const hasSpriteScrubber =
|
const hasSpriteScrubber =
|
||||||
hasScrubberUi &&
|
Boolean(spriteUrl) &&
|
||||||
|
spriteCount > 1 &&
|
||||||
spriteCols > 0 &&
|
spriteCols > 0 &&
|
||||||
spriteRows > 0
|
spriteRows > 0
|
||||||
|
|
||||||
// Finales Scrubber-Setup
|
// Finales Scrubber-Setup (NUR Sprite)
|
||||||
const scrubberCount = hasScrubberUi ? spriteCount : 0
|
const scrubberCount = hasSpriteScrubber ? spriteCount : 0
|
||||||
const scrubberStepSeconds = hasScrubberUi ? spriteStepSeconds : 0
|
const scrubberStepSeconds = hasSpriteScrubber ? spriteStepSeconds : 0
|
||||||
const hasScrubber = hasScrubberUi
|
const hasScrubber = hasSpriteScrubber
|
||||||
|
|
||||||
const activeScrubIndex = scrubIndexByKey[k]
|
const activeScrubIndex = scrubIndexByKey[k]
|
||||||
|
|
||||||
@ -526,8 +471,8 @@ export default function FinishedDownloadsGalleryView({
|
|||||||
|
|
||||||
{/* ✅ stashapp-artiger Hover-Scrubber (UI-only) */}
|
{/* ✅ stashapp-artiger Hover-Scrubber (UI-only) */}
|
||||||
{hasScrubber ? (
|
{hasScrubber ? (
|
||||||
<div className="absolute inset-x-0 bottom-0 z-30 pointer-events-none opacity-100 transition-opacity duration-150">
|
<div className="absolute inset-x-0 bottom-0 z-30 pointer-events-none opacity-0 transition-opacity duration-150 group-hover:opacity-100 group-focus-within:opacity-100">
|
||||||
<PreviewScrubber
|
<GalleryPreviewScrubber
|
||||||
className="pointer-events-auto px-1"
|
className="pointer-events-auto px-1"
|
||||||
imageCount={scrubberCount}
|
imageCount={scrubberCount}
|
||||||
activeIndex={activeScrubIndex}
|
activeIndex={activeScrubIndex}
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import type { RecordJob } from '../../types'
|
|||||||
import FinishedVideoPreview from './FinishedVideoPreview'
|
import FinishedVideoPreview from './FinishedVideoPreview'
|
||||||
import RecordJobActions from './RecordJobActions'
|
import RecordJobActions from './RecordJobActions'
|
||||||
import TagOverflowRow from './TagOverflowRow'
|
import TagOverflowRow from './TagOverflowRow'
|
||||||
import PreviewScrubber from './PreviewScrubber'
|
|
||||||
import { isHotName, stripHotPrefix } from './hotName'
|
import { isHotName, stripHotPrefix } from './hotName'
|
||||||
import { formatResolution } from './formatters'
|
import { formatResolution } from './formatters'
|
||||||
|
|
||||||
@ -159,54 +158,6 @@ export default function FinishedDownloadsTableView({
|
|||||||
return out
|
return out
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const parseMeta = React.useCallback((j: RecordJob): any | null => {
|
|
||||||
const raw: any = (j as any)?.meta
|
|
||||||
if (!raw) return null
|
|
||||||
if (typeof raw === 'string') {
|
|
||||||
try {
|
|
||||||
return JSON.parse(raw)
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return raw
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const previewSpriteInfoOf = React.useCallback(
|
|
||||||
(j: RecordJob): { count: number; stepSeconds: number } | null => {
|
|
||||||
const meta = parseMeta(j)
|
|
||||||
let ps: any = meta?.previewSprite ?? meta?.preview?.sprite ?? null
|
|
||||||
|
|
||||||
if (typeof ps === 'string') {
|
|
||||||
try {
|
|
||||||
ps = JSON.parse(ps)
|
|
||||||
} catch {
|
|
||||||
ps = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const count = Number(ps?.count)
|
|
||||||
const stepSeconds = Number(ps?.stepSeconds)
|
|
||||||
|
|
||||||
if (!Number.isFinite(count) || count < 2) return null
|
|
||||||
|
|
||||||
return {
|
|
||||||
count: Math.max(2, Math.floor(count)),
|
|
||||||
stepSeconds: Number.isFinite(stepSeconds) && stepSeconds > 0 ? stepSeconds : 5,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[parseMeta]
|
|
||||||
)
|
|
||||||
|
|
||||||
const [scrubActiveByKey, setScrubActiveByKey] = React.useState<Record<string, number | undefined>>({})
|
|
||||||
|
|
||||||
const setScrubActiveIndex = React.useCallback((key: string, index: number | undefined) => {
|
|
||||||
setScrubActiveByKey((prev) => {
|
|
||||||
if (prev[key] === index) return prev
|
|
||||||
return { ...prev, [key]: index }
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const columns = React.useMemo<Column<RecordJob>[]>(() => {
|
const columns = React.useMemo<Column<RecordJob>[]>(() => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@ -217,26 +168,11 @@ export default function FinishedDownloadsTableView({
|
|||||||
const k = keyFor(j)
|
const k = keyFor(j)
|
||||||
const allowSound = Boolean(teaserAudio) && hoverTeaserKey === k
|
const allowSound = Boolean(teaserAudio) && hoverTeaserKey === k
|
||||||
const previewMuted = !allowSound
|
const previewMuted = !allowSound
|
||||||
const spriteInfo = previewSpriteInfoOf(j)
|
|
||||||
const scrubActiveIndex = scrubActiveByKey[k]
|
|
||||||
|
|
||||||
const fileRaw = baseName(j.output || '')
|
|
||||||
const previewId = stripHotPrefix(fileRaw.replace(/\.[^.]+$/, '')).trim()
|
|
||||||
|
|
||||||
const scrubTimeSec =
|
|
||||||
spriteInfo && typeof scrubActiveIndex === 'number'
|
|
||||||
? Math.max(0, scrubActiveIndex * spriteInfo.stepSeconds)
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const scrubFrameSrc =
|
|
||||||
previewId && typeof scrubTimeSec === 'number'
|
|
||||||
? `/api/preview?id=${encodeURIComponent(previewId)}&t=${scrubTimeSec.toFixed(3)}&v=${assetNonce ?? 0}`
|
|
||||||
: ''
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={registerTeaserHost(k)}
|
ref={registerTeaserHost(k)}
|
||||||
className="group relative py-1"
|
className="py-1"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
@ -244,7 +180,6 @@ export default function FinishedDownloadsTableView({
|
|||||||
}}
|
}}
|
||||||
onMouseLeave={() => {
|
onMouseLeave={() => {
|
||||||
if (canHover) setHoverTeaserKey(null)
|
if (canHover) setHoverTeaserKey(null)
|
||||||
setScrubActiveIndex(k, undefined)
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FinishedVideoPreview
|
<FinishedVideoPreview
|
||||||
@ -263,36 +198,6 @@ export default function FinishedDownloadsTableView({
|
|||||||
animatedTrigger="always"
|
animatedTrigger="always"
|
||||||
assetNonce={assetNonce}
|
assetNonce={assetNonce}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Scrub-Frame Overlay */}
|
|
||||||
{spriteInfo && typeof scrubActiveIndex === 'number' && scrubFrameSrc ? (
|
|
||||||
<img
|
|
||||||
src={scrubFrameSrc}
|
|
||||||
alt=""
|
|
||||||
aria-hidden="true"
|
|
||||||
className="pointer-events-none absolute left-0 top-1 z-[15] h-16 w-28 rounded-md object-cover ring-1 ring-black/5 dark:ring-white/10"
|
|
||||||
draggable={false}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Scrubber (Mobile sichtbar, Desktop nur Hover) */}
|
|
||||||
{spriteInfo ? (
|
|
||||||
<div
|
|
||||||
className="absolute inset-x-0 bottom-0 z-[20] px-0.5"
|
|
||||||
onPointerUp={() => setScrubActiveIndex(k, undefined)}
|
|
||||||
onPointerCancel={() => setScrubActiveIndex(k, undefined)}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<PreviewScrubber
|
|
||||||
imageCount={spriteInfo.count}
|
|
||||||
activeIndex={scrubActiveIndex}
|
|
||||||
onActiveIndexChange={(idx) => setScrubActiveIndex(k, idx)}
|
|
||||||
stepSeconds={spriteInfo.stepSeconds}
|
|
||||||
className="opacity-100 pointer-events-auto md:opacity-0 md:pointer-events-none md:group-hover:opacity-100 md:group-focus-within:opacity-100 md:group-hover:pointer-events-auto md:group-focus-within:pointer-events-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@ -480,9 +385,6 @@ export default function FinishedDownloadsTableView({
|
|||||||
deletingKeys,
|
deletingKeys,
|
||||||
keepingKeys,
|
keepingKeys,
|
||||||
removingKeys,
|
removingKeys,
|
||||||
previewSpriteInfoOf,
|
|
||||||
scrubActiveByKey,
|
|
||||||
setScrubActiveIndex,
|
|
||||||
onToggleWatch,
|
onToggleWatch,
|
||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
onToggleLike,
|
onToggleLike,
|
||||||
|
|||||||
@ -23,7 +23,7 @@ export type FinishedVideoPreviewProps = {
|
|||||||
onDuration?: (job: RecordJob, seconds: number) => void
|
onDuration?: (job: RecordJob, seconds: number) => void
|
||||||
onResolution?: (job: RecordJob, w: number, h: number) => void
|
onResolution?: (job: RecordJob, w: number, h: number) => void
|
||||||
|
|
||||||
/** animated="true": frames = wechselnde Bilder, clips = 0.75s-Teaser-Clips (z.B. 12), teaser = vorgerendertes MP4 */
|
/** animated="true": frames = wechselnde Bilder, clips = 1s-Teaser-Clips, teaser = vorgerendertes MP4 */
|
||||||
animated?: boolean
|
animated?: boolean
|
||||||
animatedMode?: AnimatedMode
|
animatedMode?: AnimatedMode
|
||||||
animatedTrigger?: AnimatedTrigger
|
animatedTrigger?: AnimatedTrigger
|
||||||
@ -36,7 +36,6 @@ export type FinishedVideoPreviewProps = {
|
|||||||
|
|
||||||
/** nur für clips */
|
/** nur für clips */
|
||||||
clipSeconds?: number
|
clipSeconds?: number
|
||||||
clipCount?: number
|
|
||||||
|
|
||||||
/** neu: thumb = w-20 h-16, fill = w-full h-full */
|
/** neu: thumb = w-20 h-16, fill = w-full h-full */
|
||||||
variant?: Variant
|
variant?: Variant
|
||||||
@ -94,8 +93,7 @@ export default function FinishedVideoPreview({
|
|||||||
thumbSpread,
|
thumbSpread,
|
||||||
thumbSamples,
|
thumbSamples,
|
||||||
|
|
||||||
clipSeconds = 0.75,
|
clipSeconds = 1,
|
||||||
clipCount = 12,
|
|
||||||
|
|
||||||
variant = 'thumb',
|
variant = 'thumb',
|
||||||
className,
|
className,
|
||||||
@ -353,7 +351,7 @@ export default function FinishedVideoPreview({
|
|||||||
const readProgressStepped = (
|
const readProgressStepped = (
|
||||||
vv: HTMLVideoElement | null,
|
vv: HTMLVideoElement | null,
|
||||||
totalSeconds: number | undefined, // bleibt drin (nur für clamp/teaser-end)
|
totalSeconds: number | undefined, // bleibt drin (nur für clamp/teaser-end)
|
||||||
stepSec = clipSeconds,
|
stepSec = 1,
|
||||||
forceTeaserMap = false
|
forceTeaserMap = false
|
||||||
): { ratio: number; globalSec: number; vvDur: number } => {
|
): { ratio: number; globalSec: number; vvDur: number } => {
|
||||||
if (!vv) return { ratio: 0, globalSec: 0, vvDur: 0 }
|
if (!vv) return { ratio: 0, globalSec: 0, vvDur: 0 }
|
||||||
@ -749,7 +747,7 @@ export default function FinishedVideoPreview({
|
|||||||
|
|
||||||
const dur = effectiveDurationSec!
|
const dur = effectiveDurationSec!
|
||||||
const clipLen = Math.max(0.25, clipSeconds)
|
const clipLen = Math.max(0.25, clipSeconds)
|
||||||
const count = Math.max(8, Math.min(clipCount ?? thumbSamples ?? 12, Math.floor(dur)))
|
const count = Math.max(8, Math.min(thumbSamples ?? 18, Math.floor(dur)))
|
||||||
|
|
||||||
const span = Math.max(0.1, dur - clipLen)
|
const span = Math.max(0.1, dur - clipLen)
|
||||||
const base = Math.min(0.25, span * 0.02)
|
const base = Math.min(0.25, span * 0.02)
|
||||||
@ -760,7 +758,7 @@ export default function FinishedVideoPreview({
|
|||||||
times.push(Math.min(dur - 0.05, Math.max(0.05, t)))
|
times.push(Math.min(dur - 0.05, Math.max(0.05, t)))
|
||||||
}
|
}
|
||||||
return times
|
return times
|
||||||
}, [animated, animatedMode, hasDuration, effectiveDurationSec, thumbSamples, clipSeconds, clipCount])
|
}, [animated, animatedMode, hasDuration, effectiveDurationSec, thumbSamples, clipSeconds])
|
||||||
|
|
||||||
const clipTimesKey = useMemo(() => clipTimes.map((t) => t.toFixed(2)).join(','), [clipTimes])
|
const clipTimesKey = useMemo(() => clipTimes.map((t) => t.toFixed(2)).join(','), [clipTimes])
|
||||||
|
|
||||||
@ -808,7 +806,7 @@ export default function FinishedVideoPreview({
|
|||||||
if (!vv.isConnected) return
|
if (!vv.isConnected) return
|
||||||
|
|
||||||
const forceMap = progressKind === 'teaser' && Array.isArray(previewClipMap) && previewClipMap.length > 0
|
const forceMap = progressKind === 'teaser' && Array.isArray(previewClipMap) && previewClipMap.length > 0
|
||||||
const p = readProgressStepped(vv, progressTotalSeconds, clipSeconds, forceMap)
|
const p = readProgressStepped(vv, progressTotalSeconds, 1, forceMap)
|
||||||
|
|
||||||
setPlayRatio(p.ratio)
|
setPlayRatio(p.ratio)
|
||||||
setPlayGlobalSec(p.globalSec)
|
setPlayGlobalSec(p.globalSec)
|
||||||
@ -842,7 +840,7 @@ export default function FinishedVideoPreview({
|
|||||||
applyInlineVideoPolicy(inlineRef.current, { muted })
|
applyInlineVideoPolicy(inlineRef.current, { muted })
|
||||||
}, [showingInlineVideo, muted])
|
}, [showingInlineVideo, muted])
|
||||||
|
|
||||||
// Legacy: "clips" spielt 0.75s Segmente aus dem Vollvideo per seek
|
// Legacy: "clips" spielt 1s Segmente aus dem Vollvideo per seek
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const vv = clipsRef.current
|
const vv = clipsRef.current
|
||||||
if (!vv) return
|
if (!vv) return
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// frontend\src\components\ui\PreviewScrubber.tsx
|
// frontend\src\components\ui\GalleryPreviewScrubber.tsx
|
||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
@ -24,7 +24,7 @@ function formatClock(totalSeconds: number) {
|
|||||||
return `${m}:${String(sec).padStart(2, '0')}`
|
return `${m}:${String(sec).padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PreviewScrubber({
|
export default function GalleryPreviewScrubber({
|
||||||
imageCount,
|
imageCount,
|
||||||
activeIndex,
|
activeIndex,
|
||||||
onActiveIndexChange,
|
onActiveIndexChange,
|
||||||
@ -34,6 +34,7 @@ export default function PreviewScrubber({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const rootRef = React.useRef<HTMLDivElement | null>(null)
|
const rootRef = React.useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
// rAF-Throttle für Pointer-Move (reduziert Re-Renders)
|
||||||
const rafRef = React.useRef<number | null>(null)
|
const rafRef = React.useRef<number | null>(null)
|
||||||
const pendingIndexRef = React.useRef<number | undefined>(undefined)
|
const pendingIndexRef = React.useRef<number | undefined>(undefined)
|
||||||
|
|
||||||
@ -133,26 +134,14 @@ export default function PreviewScrubber({
|
|||||||
|
|
||||||
const showLabel = typeof activeIndex === 'number'
|
const showLabel = typeof activeIndex === 'number'
|
||||||
|
|
||||||
const labelLeftStyle =
|
|
||||||
typeof markerLeftPct === 'number'
|
|
||||||
? {
|
|
||||||
left: `clamp(24px, ${markerLeftPct}%, calc(100% - 24px))`,
|
|
||||||
transform: 'translateX(-50%)',
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={rootRef}
|
ref={rootRef}
|
||||||
className={[
|
className={[
|
||||||
|
// große Hit-Area, sichtbarer Balken ist unten drin
|
||||||
'relative h-7 w-full select-none touch-none cursor-col-resize',
|
'relative h-7 w-full select-none touch-none cursor-col-resize',
|
||||||
// standardmäßig versteckt, nur bei Hover/Focus auf dem Parent-Video sichtbar
|
|
||||||
'opacity-0 transition-opacity duration-150',
|
|
||||||
'pointer-events-none',
|
|
||||||
'group-hover:opacity-100 group-focus-within:opacity-100',
|
|
||||||
'group-hover:pointer-events-auto group-focus-within:pointer-events-auto',
|
|
||||||
className,
|
className,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')}
|
.join(' ')}
|
||||||
onPointerMove={handlePointerMove}
|
onPointerMove={handlePointerMove}
|
||||||
@ -168,28 +157,29 @@ export default function PreviewScrubber({
|
|||||||
aria-valuemax={imageCount}
|
aria-valuemax={imageCount}
|
||||||
aria-valuenow={typeof activeIndex === 'number' ? activeIndex + 1 : undefined}
|
aria-valuenow={typeof activeIndex === 'number' ? activeIndex + 1 : undefined}
|
||||||
>
|
>
|
||||||
<div className="pointer-events-none absolute inset-x-1 bottom-[3px] h-3 rounded-sm bg-white/35 ring-1 ring-white/40 backdrop-blur-[1px]">
|
{/* sichtbarer Scrubbing-Bereich (durchgehend weiß) */}
|
||||||
|
<div className="pointer-events-none absolute inset-x-1 bottom-[3px] h-3 rounded-sm bg-white/35 ring-1 ring-white/40 backdrop-blur-[1px]">
|
||||||
|
{/* aktiver Marker */}
|
||||||
{typeof markerLeftPct === 'number' ? (
|
{typeof markerLeftPct === 'number' ? (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-y-0 w-[2px] bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.35)]"
|
className="absolute inset-y-0 w-[2px] bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.35)]"
|
||||||
style={{
|
style={{
|
||||||
left: `${markerLeftPct}%`,
|
left: `${markerLeftPct}%`,
|
||||||
transform: 'translateX(-50%)',
|
transform: 'translateX(-50%)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Zeitlabel (unten rechts / dezent) */}
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
'pointer-events-none absolute bottom-[17px] z-10',
|
'pointer-events-none absolute right-1 bottom-1',
|
||||||
'rounded bg-black/70 px-1.5 py-0.5',
|
'text-[11px] leading-none text-white',
|
||||||
'text-[11px] leading-none text-white whitespace-nowrap',
|
|
||||||
'transition-opacity duration-100',
|
'transition-opacity duration-100',
|
||||||
showLabel ? 'opacity-100' : 'opacity-0',
|
showLabel ? 'opacity-100' : 'opacity-0',
|
||||||
'[text-shadow:_0_1px_2px_rgba(0,0,0,0.9)]',
|
'[text-shadow:_0_1px_2px_rgba(0,0,0,0.9)]',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
style={labelLeftStyle}
|
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
Loading…
x
Reference in New Issue
Block a user