added rating
This commit is contained in:
parent
14412eab4c
commit
edf4894790
@ -3,14 +3,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/draw"
|
||||
"image/jpeg"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
@ -43,9 +40,23 @@ type analyzeVideoResp struct {
|
||||
Goal string `json:"goal,omitempty"`
|
||||
Hits []analyzeHit `json:"hits"`
|
||||
Segments []aiSegmentMeta `json:"segments,omitempty"`
|
||||
Rating *aiRatingMeta `json:"rating,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type aiRatingMeta struct {
|
||||
Score float64 `json:"score"`
|
||||
Stars int `json:"stars"`
|
||||
Segments int `json:"segments"`
|
||||
SegmentsPerMinute float64 `json:"segmentsPerMinute"`
|
||||
FlaggedSeconds float64 `json:"flaggedSeconds"`
|
||||
WeightedFlaggedSeconds float64 `json:"weightedFlaggedSeconds"`
|
||||
CoverageRatio float64 `json:"coverageRatio"`
|
||||
WeightedCoverageRatio float64 `json:"weightedCoverageRatio"`
|
||||
LongestSegmentSeconds float64 `json:"longestSegmentSeconds"`
|
||||
AvgConfidence float64 `json:"avgConfidence"`
|
||||
}
|
||||
|
||||
type spriteFrameCandidate struct {
|
||||
Index int
|
||||
Time float64
|
||||
@ -136,23 +147,10 @@ func extractSpriteFrames(spritePath string, ps previewSpriteMetaFileInfo) ([]ima
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func encodeImageJPEGBase64(img image.Image) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(buf.Bytes()), nil
|
||||
}
|
||||
|
||||
func classifyFrameNSFW(ctx context.Context, img image.Image) (*NsfwImageResponse, error) {
|
||||
_ = ctx
|
||||
|
||||
b64, err := encodeImageJPEGBase64(img)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
results, err := detectNSFWFromBase64(b64)
|
||||
results, err := detectNSFWFromImage(img)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -176,10 +174,10 @@ func nsfwLabelPriority(label string) int {
|
||||
case "anus_exposed":
|
||||
return 800
|
||||
|
||||
case "male_genitalia_exposed":
|
||||
case "buttocks_exposed":
|
||||
return 700
|
||||
|
||||
case "buttocks_exposed":
|
||||
case "male_genitalia_exposed":
|
||||
return 600
|
||||
|
||||
case
|
||||
@ -339,7 +337,7 @@ func recordAnalyzeVideo(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second)
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var hits []analyzeHit
|
||||
@ -365,11 +363,17 @@ func recordAnalyzeVideo(w http.ResponseWriter, r *http.Request) {
|
||||
durationSec, _ := durationSecondsForAnalyze(ctx, outPath)
|
||||
segments := buildSegmentsFromAnalyzeHits(hits, durationSec)
|
||||
|
||||
var rating *aiRatingMeta
|
||||
if req.Goal == "nsfw" {
|
||||
rating = computeNSFWRating(segments, durationSec)
|
||||
}
|
||||
|
||||
ai := &aiAnalysisMeta{
|
||||
Goal: req.Goal,
|
||||
Mode: req.Mode,
|
||||
Hits: hits,
|
||||
Segments: segments,
|
||||
Rating: rating,
|
||||
AnalyzedAtUnix: time.Now().Unix(),
|
||||
}
|
||||
|
||||
@ -383,6 +387,7 @@ func recordAnalyzeVideo(w http.ResponseWriter, r *http.Request) {
|
||||
Goal: req.Goal,
|
||||
Hits: hits,
|
||||
Segments: segments,
|
||||
Rating: rating,
|
||||
})
|
||||
}
|
||||
|
||||
@ -651,6 +656,121 @@ func mergeAnalyzeHits(in []analyzeHit) []analyzeHit {
|
||||
return out
|
||||
}
|
||||
|
||||
func nsfwSegmentSeverityWeight(label string) float64 {
|
||||
switch strings.ToLower(strings.TrimSpace(label)) {
|
||||
case "female_genitalia_exposed":
|
||||
return 1.00
|
||||
case "anus_exposed":
|
||||
return 0.95
|
||||
case "female_breast_exposed":
|
||||
return 0.85
|
||||
case "male_genitalia_exposed":
|
||||
return 0.80
|
||||
case "buttocks_exposed":
|
||||
return 0.65
|
||||
default:
|
||||
return 0.50
|
||||
}
|
||||
}
|
||||
|
||||
func starsFromNSFWScore(score float64) int {
|
||||
switch {
|
||||
case score < 5:
|
||||
return 1
|
||||
case score < 15:
|
||||
return 2
|
||||
case score < 35:
|
||||
return 3
|
||||
case score < 60:
|
||||
return 4
|
||||
default:
|
||||
return 5
|
||||
}
|
||||
}
|
||||
|
||||
func computeNSFWRating(segments []aiSegmentMeta, durationSec float64) *aiRatingMeta {
|
||||
r := &aiRatingMeta{
|
||||
Score: 0,
|
||||
Stars: 1,
|
||||
}
|
||||
|
||||
if durationSec <= 0 || len(segments) == 0 {
|
||||
return r
|
||||
}
|
||||
|
||||
videoMinutes := math.Max(durationSec/60.0, 0.25)
|
||||
|
||||
var totalFlagged float64
|
||||
var totalWeighted float64
|
||||
var longest float64
|
||||
var confSum float64
|
||||
var n int
|
||||
|
||||
for _, s := range segments {
|
||||
segDur := s.DurationSeconds
|
||||
if segDur <= 0 {
|
||||
segDur = s.EndSeconds - s.StartSeconds
|
||||
}
|
||||
if segDur <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
sev := nsfwSegmentSeverityWeight(s.Label)
|
||||
conf := clamp01(s.Score)
|
||||
|
||||
// Confidence beeinflusst, dominiert aber nicht.
|
||||
weight := sev * (0.65 + 0.35*conf)
|
||||
|
||||
totalFlagged += segDur
|
||||
totalWeighted += segDur * weight
|
||||
confSum += conf
|
||||
n++
|
||||
|
||||
if segDur > longest {
|
||||
longest = segDur
|
||||
}
|
||||
}
|
||||
|
||||
if n == 0 {
|
||||
return r
|
||||
}
|
||||
|
||||
coverageRatio := clamp01(totalFlagged / durationSec)
|
||||
weightedCoverageRatio := clamp01(totalWeighted / durationSec)
|
||||
segmentsPerMinute := float64(n) / videoMinutes
|
||||
avgConfidence := confSum / float64(n)
|
||||
|
||||
// Normalisierung / Sättigung:
|
||||
// - 20% gewichtete Abdeckung => voll
|
||||
// - 4 Segmente pro Minute => voll
|
||||
// - 20s längstes Segment => voll
|
||||
coverageNorm := clamp01(weightedCoverageRatio / 0.20)
|
||||
frequencyNorm := clamp01(segmentsPerMinute / 4.0)
|
||||
longestNorm := clamp01(longest / 20.0)
|
||||
confNorm := clamp01((avgConfidence - 0.35) / 0.45)
|
||||
|
||||
raw := 0.55*coverageNorm +
|
||||
0.20*frequencyNorm +
|
||||
0.15*longestNorm +
|
||||
0.10*confNorm
|
||||
|
||||
score := math.Round(clamp01(raw)*1000) / 10 // 0..100 mit 1 Nachkommastelle
|
||||
stars := starsFromNSFWScore(score)
|
||||
|
||||
r.Score = score
|
||||
r.Stars = stars
|
||||
r.Segments = n
|
||||
r.SegmentsPerMinute = math.Round(segmentsPerMinute*100) / 100
|
||||
r.FlaggedSeconds = math.Round(totalFlagged*100) / 100
|
||||
r.WeightedFlaggedSeconds = math.Round(totalWeighted*100) / 100
|
||||
r.CoverageRatio = math.Round(coverageRatio*10000) / 10000
|
||||
r.WeightedCoverageRatio = math.Round(weightedCoverageRatio*10000) / 10000
|
||||
r.LongestSegmentSeconds = math.Round(longest*100) / 100
|
||||
r.AvgConfidence = math.Round(avgConfidence*1000) / 1000
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func buildSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegmentMeta {
|
||||
if len(hits) == 0 || duration <= 0 {
|
||||
return []aiSegmentMeta{}
|
||||
|
||||
@ -811,7 +811,7 @@ func ensureAnalyzeForVideoCtx(
|
||||
goal = "nsfw"
|
||||
}
|
||||
|
||||
actx, cancel := context.WithTimeout(ctx, 45*time.Second)
|
||||
actx, cancel := context.WithTimeout(ctx, 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
durationSec, _ := durationSecondsForAnalyze(actx, videoPath)
|
||||
@ -822,11 +822,17 @@ func ensureAnalyzeForVideoCtx(
|
||||
|
||||
segments := buildSegmentsFromAnalyzeHits(hits, durationSec)
|
||||
|
||||
var rating *aiRatingMeta
|
||||
if goal == "nsfw" {
|
||||
rating = computeNSFWRating(segments, durationSec)
|
||||
}
|
||||
|
||||
ai := &aiAnalysisMeta{
|
||||
Goal: goal,
|
||||
Mode: "sprite",
|
||||
Hits: hits,
|
||||
Segments: segments,
|
||||
Rating: rating,
|
||||
AnalyzedAtUnix: time.Now().Unix(),
|
||||
}
|
||||
|
||||
@ -1416,11 +1422,17 @@ func prepareVideoForSplit(ctx context.Context, videoPath, sourceURL, goal string
|
||||
|
||||
segments := buildSegmentsFromAnalyzeHits(hits, durationSec)
|
||||
|
||||
var rating *aiRatingMeta
|
||||
if goal == "nsfw" {
|
||||
rating = computeNSFWRating(segments, durationSec)
|
||||
}
|
||||
|
||||
ai := &aiAnalysisMeta{
|
||||
Goal: goal,
|
||||
Mode: "sprite",
|
||||
Hits: hits,
|
||||
Segments: segments,
|
||||
Rating: rating,
|
||||
AnalyzedAtUnix: time.Now().Unix(),
|
||||
}
|
||||
|
||||
|
||||
@ -14,11 +14,11 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
previewSpriteCols = 10
|
||||
previewSpriteRows = 8
|
||||
previewSpriteCols = 20
|
||||
previewSpriteRows = 12
|
||||
previewSpriteFrameCount = previewSpriteCols * previewSpriteRows
|
||||
previewSpriteCellW = 160
|
||||
previewSpriteCellH = 90
|
||||
previewSpriteCellW = 320
|
||||
previewSpriteCellH = 180
|
||||
)
|
||||
|
||||
func fixedPreviewSpriteLayout() (cols, rows, count, cellW, cellH int) {
|
||||
@ -30,7 +30,7 @@ func previewSpriteStepSeconds(durationSec float64) float64 {
|
||||
return 5
|
||||
}
|
||||
|
||||
// 80 Zellen = 79 Abstände.
|
||||
// 120 Zellen = 119 Abstände.
|
||||
// Wir ziehen ein kleines Stück vom Ende ab, damit ffmpeg
|
||||
// nicht "hinter" dem letzten gültigen Frame sampelt.
|
||||
usableDur := durationSec - 0.10
|
||||
@ -135,7 +135,7 @@ func generatePreviewSpriteJPG(
|
||||
"-frames:v", "1",
|
||||
"-update", "1",
|
||||
"-c:v", "mjpeg",
|
||||
"-q:v", "4",
|
||||
"-q:v", "3",
|
||||
tmpPath,
|
||||
)
|
||||
|
||||
|
||||
@ -1,114 +1,90 @@
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"modelKey": "xdaisyx",
|
||||
"url": "https://www.myfreecams.com/#xDaisyx",
|
||||
"modelKey": "jam_cream",
|
||||
"url": "https://www.myfreecams.com/#Jam_cream",
|
||||
"mode": "probe_retry",
|
||||
"nextProbeAtMs": 1777292591836,
|
||||
"nextProbeAtMs": 1777366547688,
|
||||
"currentShow": "offline",
|
||||
"source": "watched"
|
||||
},
|
||||
{
|
||||
"modelKey": "wekittycluby",
|
||||
"url": "https://chaturbate.com/wekittycluby/",
|
||||
"modelKey": "sharitaklemme",
|
||||
"url": "https://chaturbate.com/sharitaklemme/",
|
||||
"mode": "wait_public",
|
||||
"currentShow": "private",
|
||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/wekittycluby.jpg",
|
||||
"source": "watched"
|
||||
},
|
||||
{
|
||||
"modelKey": "la__melanie",
|
||||
"url": "https://chaturbate.com/la__melanie/",
|
||||
"mode": "wait_public",
|
||||
"currentShow": "private",
|
||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/la__melanie.jpg",
|
||||
"source": "watched"
|
||||
},
|
||||
{
|
||||
"modelKey": "adrean_sofi",
|
||||
"url": "https://chaturbate.com/adrean_sofi/",
|
||||
"mode": "wait_public",
|
||||
"currentShow": "hidden",
|
||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/adrean_sofi.jpg",
|
||||
"source": "watched"
|
||||
},
|
||||
{
|
||||
"modelKey": "extasy_maya",
|
||||
"url": "https://chaturbate.com/extasy_maya/",
|
||||
"mode": "wait_public",
|
||||
"currentShow": "hidden",
|
||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/extasy_maya.jpg",
|
||||
"source": "watched"
|
||||
},
|
||||
{
|
||||
"modelKey": "rita__shy",
|
||||
"url": "https://chaturbate.com/rita__shy/",
|
||||
"mode": "wait_public",
|
||||
"currentShow": "private",
|
||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/rita__shy.jpg",
|
||||
"source": "watched"
|
||||
},
|
||||
{
|
||||
"modelKey": "shachatte",
|
||||
"url": "https://chaturbate.com/shachatte/",
|
||||
"mode": "wait_public",
|
||||
"currentShow": "private",
|
||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/shachatte.jpg",
|
||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/sharitaklemme.jpg",
|
||||
"source": "watched"
|
||||
},
|
||||
{
|
||||
"modelKey": "carolecronan",
|
||||
"url": "https://chaturbate.com/carolecronan/",
|
||||
"mode": "wait_public",
|
||||
"currentShow": "away",
|
||||
"currentShow": "private",
|
||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/carolecronan.jpg",
|
||||
"source": "watched"
|
||||
},
|
||||
{
|
||||
"modelKey": "lora_lifelover",
|
||||
"url": "https://chaturbate.com/lora_lifelover/",
|
||||
"modelKey": "dawnaullah",
|
||||
"url": "https://chaturbate.com/dawnaullah/",
|
||||
"mode": "wait_public",
|
||||
"currentShow": "private",
|
||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/lora_lifelover.jpg",
|
||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/dawnaullah.jpg",
|
||||
"source": "watched"
|
||||
},
|
||||
{
|
||||
"modelKey": "phantomlace",
|
||||
"url": "https://chaturbate.com/phantomlace/",
|
||||
"modelKey": "sexdrugg666",
|
||||
"url": "https://chaturbate.com/sexdrugg666/",
|
||||
"mode": "wait_public",
|
||||
"currentShow": "private",
|
||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/phantomlace.jpg",
|
||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/sexdrugg666.jpg",
|
||||
"source": "watched"
|
||||
},
|
||||
{
|
||||
"modelKey": "aurora_the_ballerina",
|
||||
"url": "https://chaturbate.com/aurora_the_ballerina/",
|
||||
"modelKey": "yuri_kai",
|
||||
"url": "https://chaturbate.com/yuri_kai/",
|
||||
"mode": "wait_public",
|
||||
"currentShow": "private",
|
||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/aurora_the_ballerina.jpg",
|
||||
"currentShow": "away",
|
||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/yuri_kai.jpg",
|
||||
"source": "watched"
|
||||
},
|
||||
{
|
||||
"modelKey": "anabel054",
|
||||
"url": "https://chaturbate.com/anabel054/",
|
||||
"modelKey": "kaliana_ggh",
|
||||
"url": "https://chaturbate.com/kaliana_ggh/",
|
||||
"mode": "wait_public",
|
||||
"currentShow": "private",
|
||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/anabel054.jpg",
|
||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/kaliana_ggh.jpg",
|
||||
"source": "watched"
|
||||
},
|
||||
{
|
||||
"modelKey": "phqqipi",
|
||||
"url": "https://chaturbate.com/phqqipi/",
|
||||
"modelKey": "vivian_qwerty",
|
||||
"url": "https://chaturbate.com/vivian_qwerty/",
|
||||
"mode": "wait_public",
|
||||
"currentShow": "private",
|
||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/vivian_qwerty.jpg",
|
||||
"source": "watched"
|
||||
},
|
||||
{
|
||||
"modelKey": "my_mia_",
|
||||
"url": "https://chaturbate.com/my_mia_/",
|
||||
"mode": "wait_public",
|
||||
"currentShow": "away",
|
||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/my_mia_.jpg",
|
||||
"source": "watched"
|
||||
},
|
||||
{
|
||||
"modelKey": "kusssecka",
|
||||
"url": "https://chaturbate.com/kusssecka/",
|
||||
"mode": "wait_public",
|
||||
"currentShow": "hidden",
|
||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/phqqipi.jpg",
|
||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/kusssecka.jpg",
|
||||
"source": "watched"
|
||||
},
|
||||
{
|
||||
"modelKey": "evewilder",
|
||||
"url": "https://chaturbate.com/evewilder/",
|
||||
"modelKey": "rafaelaloghry",
|
||||
"url": "https://chaturbate.com/rafaelaloghry/",
|
||||
"mode": "probe_retry",
|
||||
"nextProbeAtMs": 1777292606854,
|
||||
"nextProbeAtMs": 1777366568700,
|
||||
"currentShow": "unknown",
|
||||
"source": "watched"
|
||||
}
|
||||
|
||||
@ -1207,6 +1207,16 @@ func uniqueDestPath(dstDir, file string) (string, error) {
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
func clamp01(v float64) float64 {
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
if v > 1 {
|
||||
return 1
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func idFromVideoPath(videoPath string) string {
|
||||
return canonicalAssetIDFromName(videoPath)
|
||||
}
|
||||
|
||||
@ -115,6 +115,7 @@ type aiAnalysisMeta struct {
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Hits []analyzeHit `json:"hits,omitempty"`
|
||||
Segments []aiSegmentMeta `json:"segments,omitempty"`
|
||||
Rating *aiRatingMeta `json:"rating,omitempty"`
|
||||
AnalyzedAtUnix int64 `json:"analyzedAtUnix,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@ -154,34 +154,7 @@ func initNSFWDetector() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func closeNSFWDetector() error {
|
||||
globalNSFW.mu.Lock()
|
||||
defer globalNSFW.mu.Unlock()
|
||||
|
||||
if !globalNSFW.initialized {
|
||||
return nil
|
||||
}
|
||||
|
||||
if globalNSFW.session != nil {
|
||||
globalNSFW.session.Destroy()
|
||||
globalNSFW.session = nil
|
||||
}
|
||||
if globalNSFW.outputTensor != nil {
|
||||
globalNSFW.outputTensor.Destroy()
|
||||
globalNSFW.outputTensor = nil
|
||||
}
|
||||
if globalNSFW.inputTensor != nil {
|
||||
globalNSFW.inputTensor.Destroy()
|
||||
globalNSFW.inputTensor = nil
|
||||
}
|
||||
|
||||
ort.DestroyEnvironment()
|
||||
globalNSFW.initialized = false
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func detectNSFWFromBase64(imageB64 string) ([]NsfwFrameResult, error) {
|
||||
func detectNSFWFromImage(img image.Image) ([]NsfwFrameResult, error) {
|
||||
globalNSFW.mu.Lock()
|
||||
defer globalNSFW.mu.Unlock()
|
||||
|
||||
@ -189,11 +162,6 @@ func detectNSFWFromBase64(imageB64 string) ([]NsfwFrameResult, error) {
|
||||
return nil, fmt.Errorf("nsfw detector nicht initialisiert")
|
||||
}
|
||||
|
||||
img, err := decodeBase64Image(imageB64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fillInputTensor(globalNSFW.inputTensor.GetData(), img)
|
||||
|
||||
if err := globalNSFW.session.Run(); err != nil {
|
||||
@ -231,6 +199,41 @@ func detectNSFWFromBase64(imageB64 string) ([]NsfwFrameResult, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func closeNSFWDetector() error {
|
||||
globalNSFW.mu.Lock()
|
||||
defer globalNSFW.mu.Unlock()
|
||||
|
||||
if !globalNSFW.initialized {
|
||||
return nil
|
||||
}
|
||||
|
||||
if globalNSFW.session != nil {
|
||||
globalNSFW.session.Destroy()
|
||||
globalNSFW.session = nil
|
||||
}
|
||||
if globalNSFW.outputTensor != nil {
|
||||
globalNSFW.outputTensor.Destroy()
|
||||
globalNSFW.outputTensor = nil
|
||||
}
|
||||
if globalNSFW.inputTensor != nil {
|
||||
globalNSFW.inputTensor.Destroy()
|
||||
globalNSFW.inputTensor = nil
|
||||
}
|
||||
|
||||
ort.DestroyEnvironment()
|
||||
globalNSFW.initialized = false
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func detectNSFWFromBase64(imageB64 string) ([]NsfwFrameResult, error) {
|
||||
img, err := decodeBase64Image(imageB64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return detectNSFWFromImage(img)
|
||||
}
|
||||
|
||||
func decodeBase64Image(imageB64 string) (image.Image, error) {
|
||||
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(imageB64))
|
||||
if err != nil {
|
||||
|
||||
@ -1877,6 +1877,55 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) {
|
||||
return items, sortedIdx
|
||||
}
|
||||
|
||||
func applyGeneratedMetaToRecordJobMeta(j *RecordJob) {
|
||||
if j == nil {
|
||||
return
|
||||
}
|
||||
|
||||
outPath := strings.TrimSpace(j.Output)
|
||||
if outPath == "" {
|
||||
return
|
||||
}
|
||||
|
||||
fi, err := os.Stat(outPath)
|
||||
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
id := canonicalAssetIDFromName(outPath)
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return
|
||||
}
|
||||
|
||||
metaPath, err := generatedMetaFile(id)
|
||||
if err != nil || strings.TrimSpace(metaPath) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
meta, ok := readVideoMetaIfValid(metaPath, fi)
|
||||
if !ok || meta == nil {
|
||||
return
|
||||
}
|
||||
|
||||
rv := reflect.ValueOf(j)
|
||||
if rv.Kind() != reflect.Pointer || rv.IsNil() {
|
||||
return
|
||||
}
|
||||
|
||||
sv := rv.Elem()
|
||||
if !sv.IsValid() || sv.Kind() != reflect.Struct {
|
||||
return
|
||||
}
|
||||
|
||||
fv := sv.FieldByName("Meta")
|
||||
if !fv.IsValid() || !fv.CanSet() {
|
||||
return
|
||||
}
|
||||
|
||||
setStructFieldJSONMap(fv, metaMapFromAny(meta))
|
||||
}
|
||||
|
||||
// ---------------- Done meta + list ----------------
|
||||
|
||||
func activePostworkBasenameSet() map[string]struct{} {
|
||||
@ -2319,10 +2368,11 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
c := *base
|
||||
|
||||
// Sprite-Infos aus Dateisystem/meta ergänzen
|
||||
applyPreviewSpriteTruthToRecordJobMeta(&c)
|
||||
// vollständiges generated meta.json laden (inkl. analysis.ai.rating)
|
||||
applyGeneratedMetaToRecordJobMeta(&c)
|
||||
|
||||
// WICHTIG: completed-Flags für Meta/Thumb/Teaser/Sprites/Analyze setzen
|
||||
// danach gezielte Wahrheiten/Fallbacks drüberlegen
|
||||
applyPreviewSpriteTruthToRecordJobMeta(&c)
|
||||
applyFinishedPhaseTruthToRecordJobMeta(&c)
|
||||
|
||||
out = append(out, &c)
|
||||
@ -2435,18 +2485,15 @@ func releaseFileForMutation(file string) {
|
||||
return
|
||||
}
|
||||
|
||||
assetsReleased := requestAssetsTaskSkip(file)
|
||||
|
||||
// Enrich für genau diese Datei stoppen / aus Queue nehmen
|
||||
cancelDeferredEnrichForFile(file)
|
||||
|
||||
// WICHTIG:
|
||||
// releaseFileTasks(file) darf NICHT mehr den globalen generate-assets Task abbrechen.
|
||||
// Falls das dort aktuell passiert: rausnehmen.
|
||||
// releaseFileTasks(file) macht jetzt das gezielte Freigeben/Abbrechen
|
||||
// für genau diese Datei, inkl. generate-assets Skip pro Datei.
|
||||
resp := releaseFileTasks(file)
|
||||
|
||||
// Kurz warten, damit Handles der betroffenen Datei schließen
|
||||
if assetsReleased || resp.ReleasedAny {
|
||||
if resp.ReleasedAny {
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
@ -149,9 +149,9 @@ func main() {
|
||||
defer cancel()
|
||||
|
||||
if _, err := refreshChaturbateSnapshotNow(ctx); err != nil {
|
||||
fmt.Println("⚠️ [chaturbate] startup snapshot failed:", err)
|
||||
fmt.Println("⚠️ Chaturbate API failed:", err)
|
||||
} else {
|
||||
fmt.Println("✅ [chaturbate] startup snapshot loaded")
|
||||
fmt.Println("✅ Chaturbate API geladen")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@ -355,16 +355,6 @@ func splitTimeoutForSegments(count int) time.Duration {
|
||||
return d
|
||||
}
|
||||
|
||||
func clamp01(v float64) float64 {
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
if v > 1 {
|
||||
return 1
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func writeSplitProgressEvent(w http.ResponseWriter, flusher http.Flusher, ev splitProgressEvent) error {
|
||||
b, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
|
||||
@ -76,6 +76,7 @@ type taskStateEvent struct {
|
||||
Type string `json:"type"` // "task_state"
|
||||
GenerateAssetsRunning bool `json:"generateAssetsRunning"`
|
||||
RegenerateAssetsRunning bool `json:"regenerateAssetsRunning"`
|
||||
CheckVideosRunning bool `json:"checkVideosRunning"`
|
||||
CleanupRunning bool `json:"cleanupRunning"`
|
||||
AnyRunning bool `json:"anyRunning"`
|
||||
TS int64 `json:"ts"`
|
||||
@ -117,17 +118,56 @@ func publishFinishedPostworkStateForJob(
|
||||
})
|
||||
}
|
||||
|
||||
func anyGenerateAssetsTaskRunning() bool {
|
||||
for _, st := range snapshotAssetsJobs() {
|
||||
if st.Queued || st.Running {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func anyRegenerateAssetsTaskRunning() bool {
|
||||
for _, job := range getRegenerateAssetsJobsSnapshot() {
|
||||
state := strings.TrimSpace(strings.ToLower(anyToString(job["state"])))
|
||||
if state == "queued" || state == "running" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func anyCheckVideosTaskRunning() bool {
|
||||
for _, st := range snapshotCheckVideosJobs() {
|
||||
if st.Queued || st.Running {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func anyCleanupTaskRunning() bool {
|
||||
for _, st := range snapshotCleanupJobs() {
|
||||
if st.Queued || st.Running {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func publishTaskState() {
|
||||
assets := getGenerateAssetsTaskStatus()
|
||||
regenerate := snapshotRegenerateAssetsTaskState()
|
||||
cleanup := snapshotCleanupTaskState()
|
||||
assetsRunning := anyGenerateAssetsTaskRunning()
|
||||
regenerateRunning := anyRegenerateAssetsTaskRunning()
|
||||
checkVideosRunning := anyCheckVideosTaskRunning()
|
||||
cleanupRunning := anyCleanupTaskRunning()
|
||||
|
||||
ev := taskStateEvent{
|
||||
Type: "task_state",
|
||||
GenerateAssetsRunning: assets.Running,
|
||||
RegenerateAssetsRunning: regenerate.Running,
|
||||
CleanupRunning: cleanup.Running,
|
||||
AnyRunning: assets.Running || regenerate.Running || cleanup.Running,
|
||||
GenerateAssetsRunning: assetsRunning,
|
||||
RegenerateAssetsRunning: regenerateRunning,
|
||||
CheckVideosRunning: checkVideosRunning,
|
||||
CleanupRunning: cleanupRunning,
|
||||
AnyRunning: assetsRunning || regenerateRunning || checkVideosRunning || cleanupRunning,
|
||||
TS: time.Now().UnixMilli(),
|
||||
}
|
||||
|
||||
@ -524,11 +564,7 @@ func jobsSnapshotJSON() []byte {
|
||||
}
|
||||
|
||||
func assetsSnapshotJSON() []byte {
|
||||
assetsTaskMu.Lock()
|
||||
st := assetsTaskState
|
||||
assetsTaskMu.Unlock()
|
||||
|
||||
b, _ := json.Marshal(st)
|
||||
b, _ := json.Marshal(snapshotAssetsJobs())
|
||||
return b
|
||||
}
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@ -19,6 +20,7 @@ import (
|
||||
// ---------------------------
|
||||
|
||||
type AssetsTaskState struct {
|
||||
ID string `json:"id"`
|
||||
Queued bool `json:"queued"`
|
||||
Running bool `json:"running"`
|
||||
QueuedAt time.Time `json:"queuedAt"`
|
||||
@ -45,12 +47,24 @@ type assetsPhaseSelection struct {
|
||||
Analyze bool
|
||||
}
|
||||
|
||||
var assetsTaskMu sync.Mutex
|
||||
var assetsTaskState AssetsTaskState
|
||||
var assetsTaskCancel context.CancelFunc
|
||||
type generateAssetsRequest struct {
|
||||
Meta *bool `json:"meta,omitempty"`
|
||||
Thumb *bool `json:"thumb,omitempty"`
|
||||
Teaser *bool `json:"teaser,omitempty"`
|
||||
Sprites *bool `json:"sprites,omitempty"`
|
||||
Analyze *bool `json:"analyze,omitempty"`
|
||||
}
|
||||
|
||||
var assetsTaskFileCancels = map[string]context.CancelFunc{}
|
||||
var assetsTaskSkipFiles = map[string]struct{}{}
|
||||
type assetsTaskJob struct {
|
||||
State AssetsTaskState
|
||||
Cancel context.CancelFunc
|
||||
FileCancels map[string]context.CancelFunc
|
||||
SkipFiles map[string]struct{}
|
||||
Selection assetsPhaseSelection
|
||||
}
|
||||
|
||||
var assetsJobsMu sync.Mutex
|
||||
var assetsJobs = map[string]*assetsTaskJob{}
|
||||
|
||||
func selectedAssetsPhasesFromSettings() assetsPhaseSelection {
|
||||
s := getSettings()
|
||||
@ -63,199 +77,258 @@ func selectedAssetsPhasesFromSettings() assetsPhaseSelection {
|
||||
}
|
||||
}
|
||||
|
||||
func assetsTaskFileKey(file string) string {
|
||||
return strings.ToLower(strings.TrimSpace(filepath.Base(file)))
|
||||
}
|
||||
func selectedAssetsPhasesFromRequest(req *generateAssetsRequest) assetsPhaseSelection {
|
||||
sel := selectedAssetsPhasesFromSettings()
|
||||
|
||||
func resetAssetsTaskFileControlLocked() {
|
||||
assetsTaskFileCancels = map[string]context.CancelFunc{}
|
||||
assetsTaskSkipFiles = map[string]struct{}{}
|
||||
}
|
||||
|
||||
func registerAssetsTaskFileCancel(file string, cancel context.CancelFunc) {
|
||||
key := assetsTaskFileKey(file)
|
||||
if key == "" {
|
||||
return
|
||||
if req == nil {
|
||||
return sel
|
||||
}
|
||||
|
||||
assetsTaskMu.Lock()
|
||||
assetsTaskFileCancels[key] = cancel
|
||||
assetsTaskMu.Unlock()
|
||||
}
|
||||
|
||||
func clearAssetsTaskFileControl(file string) {
|
||||
key := assetsTaskFileKey(file)
|
||||
if key == "" {
|
||||
return
|
||||
if req.Meta != nil {
|
||||
sel.Meta = *req.Meta
|
||||
}
|
||||
if req.Thumb != nil {
|
||||
sel.Thumb = *req.Thumb
|
||||
}
|
||||
if req.Teaser != nil {
|
||||
sel.Teaser = *req.Teaser
|
||||
}
|
||||
if req.Sprites != nil {
|
||||
sel.Sprites = *req.Sprites
|
||||
}
|
||||
if req.Analyze != nil {
|
||||
sel.Analyze = *req.Analyze
|
||||
}
|
||||
|
||||
assetsTaskMu.Lock()
|
||||
delete(assetsTaskFileCancels, key)
|
||||
delete(assetsTaskSkipFiles, key)
|
||||
assetsTaskMu.Unlock()
|
||||
}
|
||||
|
||||
func requestAssetsTaskSkip(file string) bool {
|
||||
key := assetsTaskFileKey(file)
|
||||
if key == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
var cancel context.CancelFunc
|
||||
|
||||
assetsTaskMu.Lock()
|
||||
assetsTaskSkipFiles[key] = struct{}{}
|
||||
cancel = assetsTaskFileCancels[key]
|
||||
assetsTaskMu.Unlock()
|
||||
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func isAssetsTaskSkipRequested(file string) bool {
|
||||
key := assetsTaskFileKey(file)
|
||||
if key == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
assetsTaskMu.Lock()
|
||||
_, ok := assetsTaskSkipFiles[key]
|
||||
assetsTaskMu.Unlock()
|
||||
return ok
|
||||
return sel
|
||||
}
|
||||
|
||||
func (p assetsPhaseSelection) Any() bool {
|
||||
return p.Meta || p.Thumb || p.Teaser || p.Sprites || p.Analyze
|
||||
}
|
||||
|
||||
// updateAssetsState mutiert den State atomar und triggert danach SSE notify.
|
||||
// notifyAssetsChanged() muss außerhalb des Locks passieren.
|
||||
func updateAssetsState(fn func(st *AssetsTaskState)) AssetsTaskState {
|
||||
assetsTaskMu.Lock()
|
||||
fn(&assetsTaskState)
|
||||
st := assetsTaskState
|
||||
assetsTaskMu.Unlock()
|
||||
func assetsTaskFileKey(file string) string {
|
||||
return strings.ToLower(strings.TrimSpace(filepath.Base(file)))
|
||||
}
|
||||
|
||||
func snapshotAssetsJobs() []AssetsTaskState {
|
||||
assetsJobsMu.Lock()
|
||||
defer assetsJobsMu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
out := make([]AssetsTaskState, 0, len(assetsJobs))
|
||||
|
||||
for id, job := range assetsJobs {
|
||||
if job == nil {
|
||||
delete(assetsJobs, id)
|
||||
continue
|
||||
}
|
||||
|
||||
st := job.State
|
||||
|
||||
if !st.Queued && !st.Running && st.FinishedAt != nil && now.Sub(*st.FinishedAt) > 4*time.Second {
|
||||
delete(assetsJobs, id)
|
||||
continue
|
||||
}
|
||||
|
||||
out = append(out, st)
|
||||
}
|
||||
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
ai := out[i].QueuedAt
|
||||
aj := out[j].QueuedAt
|
||||
|
||||
if ai.IsZero() {
|
||||
ai = out[i].StartedAt
|
||||
}
|
||||
if aj.IsZero() {
|
||||
aj = out[j].StartedAt
|
||||
}
|
||||
|
||||
return ai.Before(aj)
|
||||
})
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func updateAssetsJobState(jobID string, fn func(st *AssetsTaskState)) (AssetsTaskState, bool) {
|
||||
assetsJobsMu.Lock()
|
||||
job := assetsJobs[jobID]
|
||||
if job == nil {
|
||||
assetsJobsMu.Unlock()
|
||||
return AssetsTaskState{}, false
|
||||
}
|
||||
|
||||
fn(&job.State)
|
||||
st := job.State
|
||||
assetsJobsMu.Unlock()
|
||||
|
||||
notifyAssetsChanged()
|
||||
publishTaskState()
|
||||
return st
|
||||
return st, true
|
||||
}
|
||||
|
||||
func snapshotAssetsState() AssetsTaskState {
|
||||
assetsTaskMu.Lock()
|
||||
st := assetsTaskState
|
||||
assetsTaskMu.Unlock()
|
||||
return st
|
||||
func removeAssetsJob(jobID string) {
|
||||
assetsJobsMu.Lock()
|
||||
delete(assetsJobs, jobID)
|
||||
assetsJobsMu.Unlock()
|
||||
|
||||
notifyAssetsChanged()
|
||||
publishTaskState()
|
||||
}
|
||||
|
||||
func getGenerateAssetsTaskStatus() AssetsTaskState {
|
||||
return snapshotAssetsState()
|
||||
func registerAssetsJobFileCancel(jobID, file string, cancel context.CancelFunc) {
|
||||
key := assetsTaskFileKey(file)
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
|
||||
assetsJobsMu.Lock()
|
||||
job := assetsJobs[jobID]
|
||||
if job != nil {
|
||||
if job.FileCancels == nil {
|
||||
job.FileCancels = map[string]context.CancelFunc{}
|
||||
}
|
||||
job.FileCancels[key] = cancel
|
||||
}
|
||||
assetsJobsMu.Unlock()
|
||||
}
|
||||
|
||||
func clearAssetsJobFileControl(jobID, file string) {
|
||||
key := assetsTaskFileKey(file)
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
|
||||
assetsJobsMu.Lock()
|
||||
job := assetsJobs[jobID]
|
||||
if job != nil {
|
||||
delete(job.FileCancels, key)
|
||||
delete(job.SkipFiles, key)
|
||||
}
|
||||
assetsJobsMu.Unlock()
|
||||
}
|
||||
|
||||
func requestAssetsJobSkip(jobID, file string) bool {
|
||||
key := assetsTaskFileKey(file)
|
||||
if key == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
var (
|
||||
cancel context.CancelFunc
|
||||
marked bool
|
||||
)
|
||||
|
||||
assetsJobsMu.Lock()
|
||||
job := assetsJobs[jobID]
|
||||
if job != nil {
|
||||
if job.SkipFiles == nil {
|
||||
job.SkipFiles = map[string]struct{}{}
|
||||
}
|
||||
job.SkipFiles[key] = struct{}{}
|
||||
cancel = job.FileCancels[key]
|
||||
marked = true
|
||||
}
|
||||
assetsJobsMu.Unlock()
|
||||
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
|
||||
return marked
|
||||
}
|
||||
|
||||
func isAssetsJobSkipRequested(jobID, file string) bool {
|
||||
key := assetsTaskFileKey(file)
|
||||
if key == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
assetsJobsMu.Lock()
|
||||
job := assetsJobs[jobID]
|
||||
if job == nil {
|
||||
assetsJobsMu.Unlock()
|
||||
return false
|
||||
}
|
||||
|
||||
_, ok := job.SkipFiles[key]
|
||||
assetsJobsMu.Unlock()
|
||||
return ok
|
||||
}
|
||||
|
||||
func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
// GET bleibt als Fallback/Debug möglich (UI nutzt SSE)
|
||||
st := snapshotAssetsState()
|
||||
writeJSON(w, http.StatusOK, st)
|
||||
writeJSON(w, http.StatusOK, snapshotAssetsJobs())
|
||||
return
|
||||
|
||||
case http.MethodPost:
|
||||
assetsTaskMu.Lock()
|
||||
if assetsTaskState.Running || assetsTaskState.Queued {
|
||||
st := assetsTaskState
|
||||
assetsTaskMu.Unlock()
|
||||
writeJSON(w, http.StatusOK, st)
|
||||
return
|
||||
var req generateAssetsRequest
|
||||
if r.Body != nil && r.ContentLength != 0 {
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
}
|
||||
sel := selectedAssetsPhasesFromRequest(&req)
|
||||
|
||||
jobID := newTaskID("generate-assets")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
job := &assetsTaskJob{
|
||||
State: AssetsTaskState{
|
||||
ID: jobID,
|
||||
Queued: true,
|
||||
Running: false,
|
||||
QueuedAt: time.Now(),
|
||||
StartedAt: time.Time{},
|
||||
FinishedAt: nil,
|
||||
Total: 0,
|
||||
Done: 0,
|
||||
GeneratedThumbs: 0,
|
||||
GeneratedPreviews: 0,
|
||||
Skipped: 0,
|
||||
Error: "",
|
||||
CurrentFile: "",
|
||||
CurrentQueue: "",
|
||||
CurrentPhase: "",
|
||||
CurrentLabel: "",
|
||||
Text: "Wartet…",
|
||||
},
|
||||
Cancel: cancel,
|
||||
FileCancels: map[string]context.CancelFunc{},
|
||||
SkipFiles: map[string]struct{}{},
|
||||
Selection: sel,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
assetsTaskCancel = cancel
|
||||
assetsTaskState = AssetsTaskState{
|
||||
Queued: true,
|
||||
Running: false,
|
||||
QueuedAt: time.Now(),
|
||||
StartedAt: time.Time{},
|
||||
FinishedAt: nil,
|
||||
Total: 0,
|
||||
Done: 0,
|
||||
GeneratedThumbs: 0,
|
||||
GeneratedPreviews: 0,
|
||||
Skipped: 0,
|
||||
Error: "",
|
||||
CurrentFile: "",
|
||||
CurrentQueue: "",
|
||||
CurrentPhase: "",
|
||||
CurrentLabel: "",
|
||||
Text: "Wartet…",
|
||||
}
|
||||
resetAssetsTaskFileControlLocked()
|
||||
st := assetsTaskState
|
||||
assetsTaskMu.Unlock()
|
||||
assetsJobsMu.Lock()
|
||||
assetsJobs[jobID] = job
|
||||
assetsJobsMu.Unlock()
|
||||
|
||||
notifyAssetsChanged()
|
||||
publishTaskState()
|
||||
|
||||
go func() {
|
||||
if err := acquireExclusiveTask(ctx); err != nil {
|
||||
now := time.Now()
|
||||
updateAssetsState(func(st *AssetsTaskState) {
|
||||
st.Queued = false
|
||||
st.Running = false
|
||||
st.FinishedAt = &now
|
||||
if errors.Is(err, context.Canceled) {
|
||||
st.Error = "Abgebrochen"
|
||||
} else {
|
||||
st.Error = err.Error()
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
defer releaseExclusiveTask()
|
||||
go runGenerateMissingAssetsJob(jobID, ctx)
|
||||
|
||||
updateAssetsState(func(st *AssetsTaskState) {
|
||||
st.Queued = false
|
||||
st.Running = true
|
||||
st.StartedAt = time.Now()
|
||||
st.Text = ""
|
||||
})
|
||||
|
||||
runGenerateMissingAssets(ctx)
|
||||
}()
|
||||
|
||||
writeJSON(w, http.StatusOK, st)
|
||||
writeJSON(w, http.StatusOK, job.State)
|
||||
return
|
||||
|
||||
case http.MethodDelete:
|
||||
assetsTaskMu.Lock()
|
||||
cancel := assetsTaskCancel
|
||||
running := assetsTaskState.Running
|
||||
queued := assetsTaskState.Queued
|
||||
assetsTaskMu.Unlock()
|
||||
id := strings.TrimSpace(r.URL.Query().Get("id"))
|
||||
if id == "" {
|
||||
http.Error(w, "id fehlt", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if (!running && !queued) || cancel == nil {
|
||||
assetsJobsMu.Lock()
|
||||
job := assetsJobs[id]
|
||||
assetsJobsMu.Unlock()
|
||||
|
||||
if job == nil || job.Cancel == nil {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
// canceln: Worker merkt das beim nächsten ctx.Err() und beendet sauber
|
||||
cancel()
|
||||
|
||||
// UI sofort informieren (ohne Running künstlich auf false zu setzen —
|
||||
// das macht der Worker zuverlässig im finishWithErr(context.Canceled))
|
||||
st := updateAssetsState(func(st *AssetsTaskState) {
|
||||
if st.Running || st.Queued {
|
||||
st.Queued = false
|
||||
st.Running = false
|
||||
st.Error = "Abgebrochen"
|
||||
}
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusOK, st)
|
||||
job.Cancel()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
|
||||
default:
|
||||
@ -397,18 +470,30 @@ func assetsTaskTruthForVideo(id string, videoPath string) finishedPhaseTruth {
|
||||
return truth
|
||||
}
|
||||
|
||||
func runGenerateMissingAssets(ctx context.Context) {
|
||||
func runGenerateMissingAssetsJob(jobID string, ctx context.Context) {
|
||||
assetsJobsMu.Lock()
|
||||
job := assetsJobs[jobID]
|
||||
if job == nil {
|
||||
assetsJobsMu.Unlock()
|
||||
return
|
||||
}
|
||||
sel := job.Selection
|
||||
assetsJobsMu.Unlock()
|
||||
|
||||
// Worker-Ende: CancelFunc zurücksetzen (pro Run)
|
||||
defer func() {
|
||||
assetsTaskMu.Lock()
|
||||
assetsTaskCancel = nil
|
||||
assetsTaskMu.Unlock()
|
||||
assetsJobsMu.Lock()
|
||||
job := assetsJobs[jobID]
|
||||
if job != nil {
|
||||
job.Cancel = nil
|
||||
}
|
||||
assetsJobsMu.Unlock()
|
||||
}()
|
||||
|
||||
finishWithErr := func(err error) {
|
||||
now := time.Now()
|
||||
|
||||
updateAssetsState(func(st *AssetsTaskState) {
|
||||
updateAssetsJobState(jobID, func(st *AssetsTaskState) {
|
||||
st.Queued = false
|
||||
st.Running = false
|
||||
st.FinishedAt = &now
|
||||
@ -429,19 +514,42 @@ func runGenerateMissingAssets(ctx context.Context) {
|
||||
}
|
||||
})
|
||||
|
||||
assetsTaskMu.Lock()
|
||||
resetAssetsTaskFileControlLocked()
|
||||
assetsTaskMu.Unlock()
|
||||
assetsJobsMu.Lock()
|
||||
job := assetsJobs[jobID]
|
||||
if job != nil {
|
||||
job.FileCancels = map[string]context.CancelFunc{}
|
||||
job.SkipFiles = map[string]struct{}{}
|
||||
}
|
||||
assetsJobsMu.Unlock()
|
||||
|
||||
time.AfterFunc(4*time.Second, func() {
|
||||
removeAssetsJob(jobID)
|
||||
})
|
||||
}
|
||||
|
||||
s := getSettings()
|
||||
sel := selectedAssetsPhasesFromSettings()
|
||||
// Wichtig:
|
||||
// Die Phasen kommen jetzt bereits von außen (Request-Overrides oder gespeicherte Settings)
|
||||
if !sel.Any() {
|
||||
finishWithErr(nil)
|
||||
return
|
||||
}
|
||||
|
||||
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
||||
if err := acquireExclusiveTask(ctx); err != nil {
|
||||
finishWithErr(err)
|
||||
return
|
||||
}
|
||||
defer releaseExclusiveTask()
|
||||
|
||||
updateAssetsJobState(jobID, func(st *AssetsTaskState) {
|
||||
st.Queued = false
|
||||
st.Running = true
|
||||
st.StartedAt = time.Now()
|
||||
st.Text = ""
|
||||
})
|
||||
|
||||
settings := getSettings()
|
||||
|
||||
doneAbs, err := resolvePathRelativeToApp(settings.DoneDir)
|
||||
if err != nil || strings.TrimSpace(doneAbs) == "" {
|
||||
finishWithErr(fmt.Errorf("doneDir auflösung fehlgeschlagen: %v", err))
|
||||
return
|
||||
@ -507,7 +615,7 @@ func runGenerateMissingAssets(ctx context.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
updateAssetsState(func(st *AssetsTaskState) {
|
||||
updateAssetsJobState(jobID, func(st *AssetsTaskState) {
|
||||
st.Total = len(items) // alle Dateien, inkl. keep + skips
|
||||
st.Done = initialDone // vorhandene/ungültige direkt als erledigt zählen
|
||||
st.GeneratedThumbs = 0
|
||||
@ -538,16 +646,16 @@ func runGenerateMissingAssets(ctx context.Context) {
|
||||
}
|
||||
|
||||
fileCtx, fileCancel := context.WithCancel(ctx)
|
||||
registerAssetsTaskFileCancel(it.name, fileCancel)
|
||||
registerAssetsJobFileCancel(jobID, it.name, fileCancel)
|
||||
|
||||
finishFileControl := func() {
|
||||
fileCancel()
|
||||
clearAssetsTaskFileControl(it.name)
|
||||
clearAssetsJobFileControl(jobID, it.name)
|
||||
}
|
||||
|
||||
skipCurrentFile := func() {
|
||||
clearAssetsTaskFinishedPostworkStates(it.name, sel)
|
||||
updateAssetsState(func(st *AssetsTaskState) {
|
||||
updateAssetsJobState(jobID, func(st *AssetsTaskState) {
|
||||
st.Done++
|
||||
st.Skipped++
|
||||
st.CurrentFile = ""
|
||||
@ -560,7 +668,7 @@ func runGenerateMissingAssets(ctx context.Context) {
|
||||
|
||||
handleFileAbort := func(stepErr error) (skipFile bool, stopAll bool) {
|
||||
if stepErr == nil {
|
||||
if isAssetsTaskSkipRequested(it.name) || !fileExistsNonEmpty(it.path) {
|
||||
if isAssetsJobSkipRequested(jobID, it.name) || !fileExistsNonEmpty(it.path) {
|
||||
return true, false
|
||||
}
|
||||
return false, false
|
||||
@ -568,7 +676,7 @@ func runGenerateMissingAssets(ctx context.Context) {
|
||||
|
||||
if errors.Is(stepErr, context.Canceled) || errors.Is(stepErr, context.DeadlineExceeded) {
|
||||
// globaler Task wurde beendet
|
||||
if ctx.Err() != nil && !isAssetsTaskSkipRequested(it.name) {
|
||||
if ctx.Err() != nil && !isAssetsJobSkipRequested(jobID, it.name) {
|
||||
finishFileControl()
|
||||
finishWithErr(context.Canceled)
|
||||
return false, true
|
||||
@ -579,18 +687,18 @@ func runGenerateMissingAssets(ctx context.Context) {
|
||||
}
|
||||
|
||||
// Datei wurde evtl. während des Verarbeitungsschritts gelöscht/verschoben
|
||||
if isAssetsTaskSkipRequested(it.name) || !fileExistsNonEmpty(it.path) {
|
||||
if isAssetsJobSkipRequested(jobID, it.name) || !fileExistsNonEmpty(it.path) {
|
||||
return true, false
|
||||
}
|
||||
|
||||
return false, false
|
||||
}
|
||||
|
||||
updateAssetsState(func(st *AssetsTaskState) {
|
||||
updateAssetsJobState(jobID, func(st *AssetsTaskState) {
|
||||
st.CurrentFile = it.name
|
||||
})
|
||||
|
||||
if isAssetsTaskSkipRequested(it.name) || !fileExistsNonEmpty(it.path) {
|
||||
if isAssetsJobSkipRequested(jobID, it.name) || !fileExistsNonEmpty(it.path) {
|
||||
skipCurrentFile()
|
||||
continue
|
||||
}
|
||||
@ -601,7 +709,7 @@ func runGenerateMissingAssets(ctx context.Context) {
|
||||
if verr != nil || vfi == nil || vfi.IsDir() || vfi.Size() <= 0 {
|
||||
clearAssetsTaskFinishedPostworkStates(it.name, sel)
|
||||
|
||||
updateAssetsState(func(st *AssetsTaskState) {
|
||||
updateAssetsJobState(jobID, func(st *AssetsTaskState) {
|
||||
st.Done++
|
||||
st.Skipped++
|
||||
st.CurrentFile = ""
|
||||
@ -641,7 +749,7 @@ func runGenerateMissingAssets(ctx context.Context) {
|
||||
publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "Analyse fehlgeschlagen")
|
||||
}
|
||||
|
||||
updateAssetsState(func(st *AssetsTaskState) {
|
||||
updateAssetsJobState(jobID, func(st *AssetsTaskState) {
|
||||
st.Error = "mindestens ein Eintrag konnte nicht verarbeitet werden (siehe Logs)"
|
||||
st.Done++
|
||||
st.CurrentFile = ""
|
||||
@ -668,7 +776,7 @@ func runGenerateMissingAssets(ctx context.Context) {
|
||||
// META
|
||||
// ----------------
|
||||
if needMeta {
|
||||
updateAssetsState(func(st *AssetsTaskState) {
|
||||
updateAssetsJobState(jobID, func(st *AssetsTaskState) {
|
||||
st.CurrentFile = it.name
|
||||
st.CurrentQueue = "postwork"
|
||||
st.CurrentPhase = "meta"
|
||||
@ -684,6 +792,7 @@ func runGenerateMissingAssets(ctx context.Context) {
|
||||
skipCurrentFile()
|
||||
continue
|
||||
}
|
||||
|
||||
if merr != nil || !okMeta {
|
||||
publishAssetsTaskPhase(it.name, "postwork", "meta", "error", "Meta fehlgeschlagen")
|
||||
} else {
|
||||
@ -695,7 +804,7 @@ func runGenerateMissingAssets(ctx context.Context) {
|
||||
// THUMB
|
||||
// ----------------
|
||||
if needThumb {
|
||||
updateAssetsState(func(st *AssetsTaskState) {
|
||||
updateAssetsJobState(jobID, func(st *AssetsTaskState) {
|
||||
st.CurrentFile = it.name
|
||||
st.CurrentQueue = "postwork"
|
||||
st.CurrentPhase = "thumb"
|
||||
@ -711,6 +820,7 @@ func runGenerateMissingAssets(ctx context.Context) {
|
||||
skipCurrentFile()
|
||||
continue
|
||||
}
|
||||
|
||||
if terr != nil {
|
||||
publishAssetsTaskPhase(it.name, "postwork", "thumb", "error", "Vorschaubild fehlgeschlagen")
|
||||
} else if assetsTaskTruthForVideo(id, it.path).ThumbReady {
|
||||
@ -727,7 +837,7 @@ func runGenerateMissingAssets(ctx context.Context) {
|
||||
// TEASER
|
||||
// ----------------
|
||||
if needTeaser {
|
||||
updateAssetsState(func(st *AssetsTaskState) {
|
||||
updateAssetsJobState(jobID, func(st *AssetsTaskState) {
|
||||
st.CurrentFile = it.name
|
||||
st.CurrentQueue = "postwork"
|
||||
st.CurrentPhase = "teaser"
|
||||
@ -743,6 +853,7 @@ func runGenerateMissingAssets(ctx context.Context) {
|
||||
skipCurrentFile()
|
||||
continue
|
||||
}
|
||||
|
||||
if terr != nil {
|
||||
publishAssetsTaskPhase(it.name, "postwork", "teaser", "error", "Teaser fehlgeschlagen")
|
||||
} else if assetsTaskTruthForVideo(id, it.path).TeaserReady {
|
||||
@ -759,7 +870,7 @@ func runGenerateMissingAssets(ctx context.Context) {
|
||||
// SPRITES
|
||||
// ----------------
|
||||
if needSprites {
|
||||
updateAssetsState(func(st *AssetsTaskState) {
|
||||
updateAssetsJobState(jobID, func(st *AssetsTaskState) {
|
||||
st.CurrentFile = it.name
|
||||
st.CurrentQueue = "postwork"
|
||||
st.CurrentPhase = "sprites"
|
||||
@ -775,6 +886,7 @@ func runGenerateMissingAssets(ctx context.Context) {
|
||||
skipCurrentFile()
|
||||
continue
|
||||
}
|
||||
|
||||
if serr != nil {
|
||||
publishAssetsTaskPhase(it.name, "postwork", "sprites", "error", "Sprites fehlgeschlagen")
|
||||
} else if assetsTaskTruthForVideo(id, it.path).SpritesReady {
|
||||
@ -788,7 +900,7 @@ func runGenerateMissingAssets(ctx context.Context) {
|
||||
// ANALYZE
|
||||
// ----------------
|
||||
if needAnalyze {
|
||||
updateAssetsState(func(st *AssetsTaskState) {
|
||||
updateAssetsJobState(jobID, func(st *AssetsTaskState) {
|
||||
st.CurrentFile = it.name
|
||||
st.CurrentQueue = "enrich"
|
||||
st.CurrentPhase = "analyze"
|
||||
@ -804,6 +916,7 @@ func runGenerateMissingAssets(ctx context.Context) {
|
||||
skipCurrentFile()
|
||||
continue
|
||||
}
|
||||
|
||||
if aerr != nil {
|
||||
publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "Analyse fehlgeschlagen")
|
||||
} else if assetsTaskTruthForVideo(id, it.path).AnalyzeReady {
|
||||
@ -813,7 +926,7 @@ func runGenerateMissingAssets(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
updateAssetsState(func(st *AssetsTaskState) {
|
||||
updateAssetsJobState(jobID, func(st *AssetsTaskState) {
|
||||
if thumbGenerated {
|
||||
st.GeneratedThumbs++
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
@ -37,21 +38,6 @@ type tailBlackoutResult struct {
|
||||
Samples []tailBlackoutSample `json:"samples,omitempty"`
|
||||
}
|
||||
|
||||
type checkVideosTaskState struct {
|
||||
mu sync.Mutex
|
||||
queued bool
|
||||
running bool
|
||||
done int
|
||||
total int
|
||||
text string
|
||||
currentFile string
|
||||
err string
|
||||
queuedAt time.Time
|
||||
startedAt time.Time
|
||||
finishedAt time.Time
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func shortTaskFilename(name string, max int) string {
|
||||
s := strings.TrimSpace(name)
|
||||
if s == "" {
|
||||
@ -63,39 +49,74 @@ func shortTaskFilename(name string, max int) string {
|
||||
return "…" + s[len(s)-(max-1):]
|
||||
}
|
||||
|
||||
func snapshotCheckVideosTaskState() CheckVideosTaskState {
|
||||
checkVideosTask.mu.Lock()
|
||||
defer checkVideosTask.mu.Unlock()
|
||||
func snapshotCheckVideosJobs() []CheckVideosTaskState {
|
||||
checkVideosJobsMu.Lock()
|
||||
defer checkVideosJobsMu.Unlock()
|
||||
|
||||
// Fertige/abgebrochene/fehlerhafte Tasks nach kurzer Zeit nicht mehr zurückgeben
|
||||
if !checkVideosTask.queued &&
|
||||
!checkVideosTask.running &&
|
||||
!checkVideosTask.finishedAt.IsZero() &&
|
||||
time.Since(checkVideosTask.finishedAt) > 4*time.Second {
|
||||
return CheckVideosTaskState{}
|
||||
now := time.Now()
|
||||
out := make([]CheckVideosTaskState, 0, len(checkVideosJobs))
|
||||
|
||||
for id, job := range checkVideosJobs {
|
||||
if job == nil {
|
||||
delete(checkVideosJobs, id)
|
||||
continue
|
||||
}
|
||||
|
||||
st := job.State
|
||||
if !st.Queued && !st.Running && st.FinishedAt != nil && now.Sub(*st.FinishedAt) > 4*time.Second {
|
||||
delete(checkVideosJobs, id)
|
||||
continue
|
||||
}
|
||||
|
||||
out = append(out, st)
|
||||
}
|
||||
|
||||
out := CheckVideosTaskState{
|
||||
Queued: checkVideosTask.queued,
|
||||
Running: checkVideosTask.running,
|
||||
QueuedAt: checkVideosTask.queuedAt,
|
||||
StartedAt: checkVideosTask.startedAt,
|
||||
Text: checkVideosTask.text,
|
||||
Error: checkVideosTask.err,
|
||||
CurrentFile: checkVideosTask.currentFile,
|
||||
Done: checkVideosTask.done,
|
||||
Total: checkVideosTask.total,
|
||||
}
|
||||
|
||||
if !checkVideosTask.finishedAt.IsZero() {
|
||||
finishedAt := checkVideosTask.finishedAt
|
||||
out.FinishedAt = &finishedAt
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
ai := out[i].QueuedAt
|
||||
aj := out[j].QueuedAt
|
||||
if ai.IsZero() {
|
||||
ai = out[i].StartedAt
|
||||
}
|
||||
if aj.IsZero() {
|
||||
aj = out[j].StartedAt
|
||||
}
|
||||
return ai.Before(aj)
|
||||
})
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
var checkVideosTask checkVideosTaskState
|
||||
func updateCheckVideosJobState(jobID string, fn func(st *CheckVideosTaskState)) (CheckVideosTaskState, bool) {
|
||||
checkVideosJobsMu.Lock()
|
||||
job := checkVideosJobs[jobID]
|
||||
if job == nil {
|
||||
checkVideosJobsMu.Unlock()
|
||||
return CheckVideosTaskState{}, false
|
||||
}
|
||||
|
||||
fn(&job.State)
|
||||
st := job.State
|
||||
checkVideosJobsMu.Unlock()
|
||||
|
||||
publishTaskState()
|
||||
return st, true
|
||||
}
|
||||
|
||||
func removeCheckVideosJob(jobID string) {
|
||||
checkVideosJobsMu.Lock()
|
||||
delete(checkVideosJobs, jobID)
|
||||
checkVideosJobsMu.Unlock()
|
||||
|
||||
publishTaskState()
|
||||
}
|
||||
|
||||
type checkVideosTaskJob struct {
|
||||
State CheckVideosTaskState
|
||||
Cancel context.CancelFunc
|
||||
}
|
||||
|
||||
var checkVideosJobsMu sync.Mutex
|
||||
var checkVideosJobs = map[string]*checkVideosTaskJob{}
|
||||
|
||||
func clampFloat(v, lo, hi float64) float64 {
|
||||
if v < lo {
|
||||
@ -181,75 +202,85 @@ func collectFinishedVideosForCheck() ([]string, error) {
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func finishCheckVideosTask(err error) {
|
||||
checkVideosTask.mu.Lock()
|
||||
checkVideosTask.queued = false
|
||||
checkVideosTask.running = false
|
||||
checkVideosTask.cancel = nil
|
||||
checkVideosTask.finishedAt = time.Now()
|
||||
func finishCheckVideosTask(jobID string, err error) {
|
||||
now := time.Now()
|
||||
|
||||
errText := strings.TrimSpace(func() string {
|
||||
if err == nil {
|
||||
return ""
|
||||
updateCheckVideosJobState(jobID, func(st *CheckVideosTaskState) {
|
||||
st.Queued = false
|
||||
st.Running = false
|
||||
st.FinishedAt = &now
|
||||
|
||||
errText := strings.TrimSpace(func() string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return err.Error()
|
||||
}())
|
||||
|
||||
cancelled :=
|
||||
strings.EqualFold(errText, "abgebrochen") ||
|
||||
strings.EqualFold(errText, "abbruch") ||
|
||||
strings.EqualFold(errText, "cancelled") ||
|
||||
strings.EqualFold(errText, "canceled")
|
||||
|
||||
if cancelled {
|
||||
st.Error = "Abgebrochen"
|
||||
st.Text = "Abgebrochen."
|
||||
} else if err != nil {
|
||||
st.Error = errText
|
||||
st.Text = "Fehler bei der Video-Prüfung."
|
||||
} else {
|
||||
st.Error = ""
|
||||
st.Text = "Prüfung abgeschlossen."
|
||||
}
|
||||
return err.Error()
|
||||
}())
|
||||
})
|
||||
|
||||
cancelled :=
|
||||
strings.EqualFold(errText, "abgebrochen") ||
|
||||
strings.EqualFold(errText, "abbruch") ||
|
||||
strings.EqualFold(errText, "cancelled") ||
|
||||
strings.EqualFold(errText, "canceled")
|
||||
|
||||
if cancelled {
|
||||
checkVideosTask.err = ""
|
||||
checkVideosTask.text = "Abgebrochen."
|
||||
} else if err != nil {
|
||||
checkVideosTask.err = errText
|
||||
checkVideosTask.text = "Fehler bei der Video-Prüfung."
|
||||
} else {
|
||||
checkVideosTask.err = ""
|
||||
checkVideosTask.text = "Prüfung abgeschlossen."
|
||||
}
|
||||
|
||||
checkVideosTask.mu.Unlock()
|
||||
|
||||
publishTaskState()
|
||||
time.AfterFunc(4*time.Second, func() {
|
||||
removeCheckVideosJob(jobID)
|
||||
})
|
||||
}
|
||||
|
||||
func runCheckVideosTask(ctx context.Context) {
|
||||
func runCheckVideosTask(jobID string, ctx context.Context) {
|
||||
if err := acquireExclusiveTask(ctx); err != nil {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
finishCheckVideosTask(jobID, fmt.Errorf("abgebrochen"))
|
||||
} else {
|
||||
finishCheckVideosTask(jobID, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
defer releaseExclusiveTask()
|
||||
|
||||
files, err := collectFinishedVideosForCheck()
|
||||
if err != nil {
|
||||
finishCheckVideosTask(err)
|
||||
finishCheckVideosTask(jobID, err)
|
||||
return
|
||||
}
|
||||
|
||||
checkVideosTask.mu.Lock()
|
||||
checkVideosTask.queued = false
|
||||
checkVideosTask.running = true
|
||||
checkVideosTask.total = len(files)
|
||||
checkVideosTask.done = 0
|
||||
checkVideosTask.startedAt = time.Now()
|
||||
checkVideosTask.text = "Prüfe Videos…"
|
||||
checkVideosTask.mu.Unlock()
|
||||
|
||||
publishTaskState()
|
||||
updateCheckVideosJobState(jobID, func(st *CheckVideosTaskState) {
|
||||
st.Queued = false
|
||||
st.Running = true
|
||||
st.Total = len(files)
|
||||
st.Done = 0
|
||||
st.StartedAt = time.Now()
|
||||
st.Text = "Prüfe Videos…"
|
||||
})
|
||||
|
||||
var firstErr error
|
||||
|
||||
for i, file := range files {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
finishCheckVideosTask(fmt.Errorf("abgebrochen"))
|
||||
finishCheckVideosTask(jobID, fmt.Errorf("abgebrochen"))
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
checkVideosTask.mu.Lock()
|
||||
checkVideosTask.currentFile = file
|
||||
checkVideosTask.text = shortTaskFilename(file, 52)
|
||||
checkVideosTask.done = i
|
||||
checkVideosTask.mu.Unlock()
|
||||
updateCheckVideosJobState(jobID, func(st *CheckVideosTaskState) {
|
||||
st.CurrentFile = file
|
||||
st.Text = shortTaskFilename(file, 52)
|
||||
st.Done = i
|
||||
})
|
||||
|
||||
if _, err := runTailBlackoutCheck(ctx, file); err != nil {
|
||||
if firstErr == nil {
|
||||
@ -257,64 +288,73 @@ func runCheckVideosTask(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
checkVideosTask.mu.Lock()
|
||||
checkVideosTask.done = i + 1
|
||||
checkVideosTask.mu.Unlock()
|
||||
updateCheckVideosJobState(jobID, func(st *CheckVideosTaskState) {
|
||||
st.Done = i + 1
|
||||
})
|
||||
}
|
||||
|
||||
finishCheckVideosTask(firstErr)
|
||||
finishCheckVideosTask(jobID, firstErr)
|
||||
}
|
||||
|
||||
func startCheckVideosTask() error {
|
||||
checkVideosTask.mu.Lock()
|
||||
defer checkVideosTask.mu.Unlock()
|
||||
|
||||
if checkVideosTask.running || checkVideosTask.queued {
|
||||
return fmt.Errorf("task läuft bereits")
|
||||
}
|
||||
|
||||
func startCheckVideosTask() (CheckVideosTaskState, error) {
|
||||
jobID := newTaskID("check-videos")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
checkVideosTask.queued = true
|
||||
checkVideosTask.running = false
|
||||
checkVideosTask.done = 0
|
||||
checkVideosTask.total = 0
|
||||
checkVideosTask.text = "Wartet…"
|
||||
checkVideosTask.currentFile = ""
|
||||
checkVideosTask.err = ""
|
||||
checkVideosTask.queuedAt = time.Now()
|
||||
checkVideosTask.startedAt = time.Time{}
|
||||
checkVideosTask.finishedAt = time.Time{}
|
||||
checkVideosTask.cancel = cancel
|
||||
st := CheckVideosTaskState{
|
||||
ID: jobID,
|
||||
Queued: true,
|
||||
Running: false,
|
||||
Done: 0,
|
||||
Total: 0,
|
||||
Text: "Wartet…",
|
||||
CurrentFile: "",
|
||||
Error: "",
|
||||
QueuedAt: time.Now(),
|
||||
StartedAt: time.Time{},
|
||||
FinishedAt: nil,
|
||||
}
|
||||
|
||||
checkVideosJobsMu.Lock()
|
||||
checkVideosJobs[jobID] = &checkVideosTaskJob{
|
||||
State: st,
|
||||
Cancel: cancel,
|
||||
}
|
||||
checkVideosJobsMu.Unlock()
|
||||
|
||||
publishTaskState()
|
||||
go runCheckVideosTask(ctx)
|
||||
return nil
|
||||
go runCheckVideosTask(jobID, ctx)
|
||||
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func cancelCheckVideosTask() {
|
||||
checkVideosTask.mu.Lock()
|
||||
cancel := checkVideosTask.cancel
|
||||
checkVideosTask.mu.Unlock()
|
||||
func cancelCheckVideosTask(jobID string) {
|
||||
checkVideosJobsMu.Lock()
|
||||
job := checkVideosJobs[jobID]
|
||||
checkVideosJobsMu.Unlock()
|
||||
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
if job != nil && job.Cancel != nil {
|
||||
job.Cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func checkVideosTaskHandler(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
if err := startCheckVideosTask(); err != nil {
|
||||
st, err := startCheckVideosTask()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
|
||||
writeJSON(w, http.StatusOK, st)
|
||||
return
|
||||
|
||||
case http.MethodDelete:
|
||||
cancelCheckVideosTask()
|
||||
id := strings.TrimSpace(r.URL.Query().Get("id"))
|
||||
if id == "" {
|
||||
http.Error(w, "id fehlt", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
cancelCheckVideosTask(id)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
|
||||
|
||||
@ -5,12 +5,14 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
@ -43,106 +45,224 @@ type cleanupReq struct {
|
||||
BelowMB *int `json:"belowMB,omitempty"`
|
||||
}
|
||||
|
||||
func clearCleanupTaskState() {
|
||||
cleanupTaskMu.Lock()
|
||||
cleanupTaskState = CleanupTaskState{}
|
||||
cleanupTaskMu.Unlock()
|
||||
type cleanupTaskJob struct {
|
||||
State CleanupTaskState
|
||||
Cancel context.CancelFunc
|
||||
}
|
||||
|
||||
var cleanupJobsMu sync.Mutex
|
||||
var cleanupJobs = map[string]*cleanupTaskJob{}
|
||||
|
||||
func snapshotCleanupJobs() []CleanupTaskState {
|
||||
cleanupJobsMu.Lock()
|
||||
defer cleanupJobsMu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
out := make([]CleanupTaskState, 0, len(cleanupJobs))
|
||||
|
||||
for id, job := range cleanupJobs {
|
||||
if job == nil {
|
||||
delete(cleanupJobs, id)
|
||||
continue
|
||||
}
|
||||
|
||||
st := job.State
|
||||
if !st.Queued && !st.Running && st.FinishedAt != nil && now.Sub(*st.FinishedAt) > 4*time.Second {
|
||||
delete(cleanupJobs, id)
|
||||
continue
|
||||
}
|
||||
|
||||
out = append(out, st)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func updateCleanupJobState(jobID string, fn func(st *CleanupTaskState)) (CleanupTaskState, bool) {
|
||||
cleanupJobsMu.Lock()
|
||||
job := cleanupJobs[jobID]
|
||||
if job == nil {
|
||||
cleanupJobsMu.Unlock()
|
||||
return CleanupTaskState{}, false
|
||||
}
|
||||
|
||||
fn(&job.State)
|
||||
st := job.State
|
||||
cleanupJobsMu.Unlock()
|
||||
|
||||
publishTaskState()
|
||||
return st, true
|
||||
}
|
||||
|
||||
func removeCleanupJob(jobID string) {
|
||||
cleanupJobsMu.Lock()
|
||||
delete(cleanupJobs, jobID)
|
||||
cleanupJobsMu.Unlock()
|
||||
|
||||
publishTaskState()
|
||||
}
|
||||
|
||||
func finishCleanupJob(jobID string, text string, err error) {
|
||||
now := time.Now()
|
||||
|
||||
updateCleanupJobState(jobID, func(st *CleanupTaskState) {
|
||||
st.Queued = false
|
||||
st.Running = false
|
||||
st.FinishedAt = &now
|
||||
st.CurrentFile = ""
|
||||
|
||||
if err == nil {
|
||||
st.Error = ""
|
||||
st.Text = strings.TrimSpace(text)
|
||||
if st.Total > 0 {
|
||||
st.Done = st.Total
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
st.Error = "Abgebrochen"
|
||||
st.Text = "Abgebrochen."
|
||||
} else {
|
||||
st.Error = strings.TrimSpace(err.Error())
|
||||
st.Text = ""
|
||||
}
|
||||
})
|
||||
|
||||
time.AfterFunc(4*time.Second, func() {
|
||||
removeCleanupJob(jobID)
|
||||
})
|
||||
}
|
||||
|
||||
// /api/settings/cleanup (POST)
|
||||
// - löscht kleine Dateien < threshold MB (mp4/ts; skip .part/.tmp; skip keep-Ordner)
|
||||
// - räumt Orphans (preview/thumbs + generated) auf
|
||||
func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
s := getSettings()
|
||||
|
||||
s := getSettings()
|
||||
|
||||
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
||||
if err != nil || strings.TrimSpace(doneAbs) == "" {
|
||||
setCleanupTaskError("doneDir auflösung fehlgeschlagen")
|
||||
|
||||
time.AfterFunc(7*time.Second, func() {
|
||||
clearCleanupTaskState()
|
||||
})
|
||||
|
||||
http.Error(w, "doneDir auflösung fehlgeschlagen", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
mb := int(s.AutoDeleteSmallDownloadsBelowMB)
|
||||
|
||||
var req cleanupReq
|
||||
if r.Body != nil {
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
}
|
||||
if req.BelowMB != nil {
|
||||
mb = *req.BelowMB
|
||||
}
|
||||
if mb < 0 {
|
||||
mb = 0
|
||||
}
|
||||
|
||||
cleanupTaskMu.Lock()
|
||||
if cleanupTaskState.Queued || cleanupTaskState.Running {
|
||||
st := cleanupTaskState
|
||||
cleanupTaskMu.Unlock()
|
||||
writeJSON(w, http.StatusOK, st)
|
||||
return
|
||||
}
|
||||
cleanupTaskMu.Unlock()
|
||||
|
||||
setCleanupTaskQueued("Wartet…")
|
||||
st := snapshotCleanupTaskState()
|
||||
|
||||
go func(doneAbs string, mb int) {
|
||||
ctx := context.Background()
|
||||
|
||||
if err := acquireExclusiveTask(ctx); err != nil {
|
||||
setCleanupTaskError(err.Error())
|
||||
time.AfterFunc(7*time.Second, func() {
|
||||
clearCleanupTaskState()
|
||||
})
|
||||
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
||||
if err != nil || strings.TrimSpace(doneAbs) == "" {
|
||||
http.Error(w, "doneDir auflösung fehlgeschlagen", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer releaseExclusiveTask()
|
||||
|
||||
setCleanupTaskRunning("Räume auf…")
|
||||
mb := int(s.AutoDeleteSmallDownloadsBelowMB)
|
||||
|
||||
resp := cleanupResp{}
|
||||
|
||||
if mb > 0 {
|
||||
threshold := int64(mb) * 1024 * 1024
|
||||
cleanupSmallFiles(doneAbs, threshold, &resp)
|
||||
var req cleanupReq
|
||||
if r.Body != nil {
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
}
|
||||
if req.BelowMB != nil {
|
||||
mb = *req.BelowMB
|
||||
}
|
||||
if mb < 0 {
|
||||
mb = 0
|
||||
}
|
||||
|
||||
gcStats := triggerGeneratedGarbageCollectorSync()
|
||||
resp.GeneratedOrphansChecked = gcStats.Checked
|
||||
resp.GeneratedOrphansRemoved = gcStats.Removed
|
||||
resp.GeneratedOrphansRemovedBytes = gcStats.RemovedBytes
|
||||
resp.GeneratedOrphansRemovedBytesHuman = formatBytesSI(gcStats.RemovedBytes)
|
||||
jobID := newTaskID("cleanup")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
resp.DeletedBytes += gcStats.RemovedBytes
|
||||
resp.DeletedBytesHuman = formatBytesSI(resp.DeletedBytes)
|
||||
|
||||
if resp.DeletedFiles > 0 || resp.GeneratedOrphansRemoved > 0 {
|
||||
notifyDoneChanged()
|
||||
st := CleanupTaskState{
|
||||
ID: jobID,
|
||||
Queued: true,
|
||||
Running: false,
|
||||
QueuedAt: time.Now(),
|
||||
StartedAt: time.Time{},
|
||||
FinishedAt: nil,
|
||||
Text: "Wartet…",
|
||||
Error: "",
|
||||
CurrentFile: "",
|
||||
Done: 0,
|
||||
Total: 0,
|
||||
}
|
||||
|
||||
setCleanupTaskDone(
|
||||
cleanupProgressText(resp.DeletedFiles, resp.ScannedFiles, resp.DeletedBytes),
|
||||
)
|
||||
cleanupJobsMu.Lock()
|
||||
cleanupJobs[jobID] = &cleanupTaskJob{
|
||||
State: st,
|
||||
Cancel: cancel,
|
||||
}
|
||||
cleanupJobsMu.Unlock()
|
||||
|
||||
time.AfterFunc(7*time.Second, func() {
|
||||
clearCleanupTaskState()
|
||||
})
|
||||
}(doneAbs, mb)
|
||||
publishTaskState()
|
||||
|
||||
writeJSON(w, http.StatusOK, st)
|
||||
go func(jobID string, ctx context.Context, doneAbs string, mb int) {
|
||||
if err := acquireExclusiveTask(ctx); err != nil {
|
||||
finishCleanupJob(jobID, "", err)
|
||||
return
|
||||
}
|
||||
defer releaseExclusiveTask()
|
||||
|
||||
updateCleanupJobState(jobID, func(st *CleanupTaskState) {
|
||||
st.Queued = false
|
||||
st.Running = true
|
||||
st.StartedAt = time.Now()
|
||||
st.Text = "Räume auf…"
|
||||
})
|
||||
|
||||
resp := cleanupResp{}
|
||||
|
||||
if mb > 0 {
|
||||
threshold := int64(mb) * 1024 * 1024
|
||||
if err := cleanupSmallFiles(ctx, jobID, doneAbs, threshold, &resp); err != nil {
|
||||
finishCleanupJob(jobID, "", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
finishCleanupJob(jobID, "", ctx.Err())
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
gcStats := triggerGeneratedGarbageCollectorSync()
|
||||
resp.GeneratedOrphansChecked = gcStats.Checked
|
||||
resp.GeneratedOrphansRemoved = gcStats.Removed
|
||||
resp.GeneratedOrphansRemovedBytes = gcStats.RemovedBytes
|
||||
resp.GeneratedOrphansRemovedBytesHuman = formatBytesSI(gcStats.RemovedBytes)
|
||||
|
||||
resp.DeletedBytes += gcStats.RemovedBytes
|
||||
resp.DeletedBytesHuman = formatBytesSI(resp.DeletedBytes)
|
||||
|
||||
if resp.DeletedFiles > 0 || resp.GeneratedOrphansRemoved > 0 {
|
||||
notifyDoneChanged()
|
||||
}
|
||||
|
||||
finishCleanupJob(
|
||||
jobID,
|
||||
cleanupProgressText(resp.DeletedFiles, resp.ScannedFiles, resp.DeletedBytes),
|
||||
nil,
|
||||
)
|
||||
}(jobID, ctx, doneAbs, mb)
|
||||
|
||||
writeJSON(w, http.StatusOK, st)
|
||||
return
|
||||
|
||||
case http.MethodDelete:
|
||||
id := strings.TrimSpace(r.URL.Query().Get("id"))
|
||||
if id == "" {
|
||||
http.Error(w, "id fehlt", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
cleanupJobsMu.Lock()
|
||||
job := cleanupJobs[id]
|
||||
cleanupJobsMu.Unlock()
|
||||
|
||||
if job != nil && job.Cancel != nil {
|
||||
job.Cancel()
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
|
||||
default:
|
||||
http.Error(w, "Nur POST/DELETE erlaubt", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func cleanupProgressText(deleted int, total int, freedBytes int64) string {
|
||||
@ -154,7 +274,7 @@ func cleanupProgressText(deleted int, total int, freedBytes int64) string {
|
||||
)
|
||||
}
|
||||
|
||||
func cleanupSmallFiles(doneAbs string, threshold int64, resp *cleanupResp) {
|
||||
func cleanupSmallFiles(ctx context.Context, jobID string, doneAbs string, threshold int64, resp *cleanupResp) error {
|
||||
isCandidate := func(name string) bool {
|
||||
low := strings.ToLower(name)
|
||||
if strings.Contains(low, ".part") || strings.Contains(low, ".tmp") {
|
||||
@ -166,13 +286,19 @@ func cleanupSmallFiles(doneAbs string, threshold int64, resp *cleanupResp) {
|
||||
|
||||
candidates := make([]cleanupCandidate, 0, 1024)
|
||||
|
||||
collectCandidates := func(dir string, allowSubdirs bool) {
|
||||
collectCandidates := func(dir string, allowSubdirs bool) error {
|
||||
ents, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, e := range ents {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
full := filepath.Join(dir, e.Name())
|
||||
|
||||
if e.IsDir() {
|
||||
@ -233,20 +359,35 @@ func cleanupSmallFiles(doneAbs string, threshold int64, resp *cleanupResp) {
|
||||
size: fi.Size(),
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
collectCandidates(doneAbs, true)
|
||||
if err := collectCandidates(doneAbs, true); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp.ScannedFiles = len(candidates)
|
||||
|
||||
setCleanupTaskProgress(
|
||||
0,
|
||||
resp.ScannedFiles,
|
||||
"",
|
||||
cleanupProgressText(resp.DeletedFiles, resp.ScannedFiles, resp.DeletedBytes),
|
||||
)
|
||||
updateCleanupJobState(jobID, func(st *CleanupTaskState) {
|
||||
st.Queued = false
|
||||
st.Running = true
|
||||
if st.StartedAt.IsZero() {
|
||||
st.StartedAt = time.Now()
|
||||
}
|
||||
st.Done = 0
|
||||
st.Total = resp.ScannedFiles
|
||||
st.CurrentFile = ""
|
||||
st.Text = cleanupProgressText(resp.DeletedFiles, resp.ScannedFiles, resp.DeletedBytes)
|
||||
st.Error = ""
|
||||
st.FinishedAt = nil
|
||||
})
|
||||
|
||||
for i, c := range candidates {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
if c.size < threshold {
|
||||
base := strings.TrimSuffix(filepath.Base(c.path), filepath.Ext(c.path))
|
||||
id := stripHotPrefix(base)
|
||||
@ -265,13 +406,22 @@ func cleanupSmallFiles(doneAbs string, threshold int64, resp *cleanupResp) {
|
||||
}
|
||||
}
|
||||
|
||||
setCleanupTaskProgress(
|
||||
i+1,
|
||||
resp.ScannedFiles,
|
||||
"",
|
||||
cleanupProgressText(resp.DeletedFiles, resp.ScannedFiles, resp.DeletedBytes),
|
||||
)
|
||||
updateCleanupJobState(jobID, func(st *CleanupTaskState) {
|
||||
st.Queued = false
|
||||
st.Running = true
|
||||
if st.StartedAt.IsZero() {
|
||||
st.StartedAt = time.Now()
|
||||
}
|
||||
st.Done = i + 1
|
||||
st.Total = resp.ScannedFiles
|
||||
st.CurrentFile = ""
|
||||
st.Text = cleanupProgressText(resp.DeletedFiles, resp.ScannedFiles, resp.DeletedBytes)
|
||||
st.Error = ""
|
||||
st.FinishedAt = nil
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var generatedGCRunning int32
|
||||
|
||||
@ -48,20 +48,27 @@ func cancelAssetsTaskForFile(file string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
assetsTaskMu.Lock()
|
||||
active := assetsTaskState.Running || assetsTaskState.Queued
|
||||
assetsTaskMu.Unlock()
|
||||
assetsJobsMu.Lock()
|
||||
ids := make([]string, 0, len(assetsJobs))
|
||||
for id, job := range assetsJobs {
|
||||
if job == nil {
|
||||
continue
|
||||
}
|
||||
if !job.State.Queued && !job.State.Running {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
assetsJobsMu.Unlock()
|
||||
|
||||
if !active {
|
||||
return false
|
||||
cancelled := false
|
||||
for _, id := range ids {
|
||||
if requestAssetsJobSkip(id, file) {
|
||||
cancelled = true
|
||||
}
|
||||
}
|
||||
|
||||
// Wichtig:
|
||||
// NICHT den globalen Assets-Task canceln.
|
||||
// Stattdessen nur diese Datei im Assets-Task zum Überspringen markieren.
|
||||
_ = requestAssetsTaskSkip(file)
|
||||
|
||||
return true
|
||||
return cancelled
|
||||
}
|
||||
|
||||
func cancelRegenerateAssetsTaskForFile(file string) bool {
|
||||
|
||||
@ -4,13 +4,16 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CleanupTaskState struct {
|
||||
ID string `json:"id"`
|
||||
Queued bool `json:"queued"`
|
||||
Running bool `json:"running"`
|
||||
QueuedAt time.Time `json:"queuedAt"`
|
||||
@ -24,6 +27,7 @@ type CleanupTaskState struct {
|
||||
}
|
||||
|
||||
type CheckVideosTaskState struct {
|
||||
ID string `json:"id"`
|
||||
Queued bool `json:"queued"`
|
||||
Running bool `json:"running"`
|
||||
QueuedAt time.Time `json:"queuedAt"`
|
||||
@ -44,12 +48,11 @@ type RegenerateAssetsTaskState struct {
|
||||
}
|
||||
|
||||
type TasksStatusResponse struct {
|
||||
GenerateAssets AssetsTaskState `json:"generateAssets"`
|
||||
RegenerateAssets RegenerateAssetsTaskState `json:"regenerateAssets"`
|
||||
RegenerateAssetsJobs []map[string]any `json:"regenerateAssetsJobs"`
|
||||
CheckVideos CheckVideosTaskState `json:"checkVideos"`
|
||||
Cleanup CleanupTaskState `json:"cleanup"`
|
||||
AnyRunning bool `json:"anyRunning"`
|
||||
GenerateAssetsJobs []AssetsTaskState `json:"generateAssetsJobs"`
|
||||
RegenerateAssetsJobs []map[string]any `json:"regenerateAssetsJobs"`
|
||||
CheckVideosJobs []CheckVideosTaskState `json:"checkVideosJobs"`
|
||||
CleanupJobs []CleanupTaskState `json:"cleanupJobs"`
|
||||
AnyRunning bool `json:"anyRunning"`
|
||||
}
|
||||
|
||||
var (
|
||||
@ -60,6 +63,13 @@ var (
|
||||
regenerateAssetsTaskState RegenerateAssetsTaskState
|
||||
)
|
||||
|
||||
var nextTaskID atomic.Uint64
|
||||
|
||||
func newTaskID(prefix string) string {
|
||||
n := nextTaskID.Add(1)
|
||||
return fmt.Sprintf("%s-%d-%d", prefix, time.Now().UnixNano(), n)
|
||||
}
|
||||
|
||||
func cleanupNowTime() *time.Time {
|
||||
t := time.Now()
|
||||
return &t
|
||||
@ -247,30 +257,56 @@ func tasksStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
assets := getGenerateAssetsTaskStatus()
|
||||
regenerate := snapshotRegenerateAssetsTaskState()
|
||||
assetsJobs := snapshotAssetsJobs()
|
||||
regenerateJobs := getRegenerateAssetsJobsSnapshot()
|
||||
checkVideos := snapshotCheckVideosTaskState()
|
||||
cleanup := snapshotCleanupTaskState()
|
||||
checkVideosJobs := snapshotCheckVideosJobs()
|
||||
cleanupJobs := snapshotCleanupJobs()
|
||||
|
||||
anyRegenerateRunning := regenerate.Running
|
||||
if !anyRegenerateRunning {
|
||||
anyRunning := false
|
||||
|
||||
if !anyRunning {
|
||||
for _, st := range assetsJobs {
|
||||
if st.Queued || st.Running {
|
||||
anyRunning = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !anyRunning {
|
||||
for _, st := range checkVideosJobs {
|
||||
if st.Queued || st.Running {
|
||||
anyRunning = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !anyRunning {
|
||||
for _, st := range cleanupJobs {
|
||||
if st.Queued || st.Running {
|
||||
anyRunning = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !anyRunning {
|
||||
for _, job := range regenerateJobs {
|
||||
state := strings.TrimSpace(strings.ToLower(anyToString(job["state"])))
|
||||
if state == "queued" || state == "running" {
|
||||
anyRegenerateRunning = true
|
||||
anyRunning = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resp := TasksStatusResponse{
|
||||
GenerateAssets: assets,
|
||||
RegenerateAssets: regenerate,
|
||||
GenerateAssetsJobs: assetsJobs,
|
||||
RegenerateAssetsJobs: regenerateJobs,
|
||||
CheckVideos: checkVideos,
|
||||
Cleanup: cleanup,
|
||||
AnyRunning: assets.Queued || assets.Running || anyRegenerateRunning || checkVideos.Queued || checkVideos.Running || cleanup.Queued || cleanup.Running,
|
||||
CheckVideosJobs: checkVideosJobs,
|
||||
CleanupJobs: cleanupJobs,
|
||||
AnyRunning: anyRunning,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
279
frontend/src/components/ui/FinishedDownloadRating.tsx
Normal file
279
frontend/src/components/ui/FinishedDownloadRating.tsx
Normal file
@ -0,0 +1,279 @@
|
||||
// frontend\src\components\ui\FinishedDownloadRating.tsx
|
||||
|
||||
'use client'
|
||||
|
||||
import HoverPopover from './HoverPopover'
|
||||
import type { RecordJobMeta } from '../../types'
|
||||
|
||||
type FinishedDownloadRatingProps = {
|
||||
metaRaw: unknown
|
||||
variant?: 'overlay' | 'badge'
|
||||
reserveSpace?: boolean
|
||||
}
|
||||
|
||||
function parseMetaObject(metaRaw: unknown): RecordJobMeta | null {
|
||||
if (!metaRaw) return null
|
||||
|
||||
if (typeof metaRaw === 'string') {
|
||||
try {
|
||||
return JSON.parse(metaRaw) as RecordJobMeta
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return typeof metaRaw === 'object' ? (metaRaw as RecordJobMeta) : null
|
||||
}
|
||||
|
||||
function readRatingNode(metaRaw: unknown): Record<string, unknown> | null {
|
||||
const meta = parseMetaObject(metaRaw) as any
|
||||
|
||||
const rating =
|
||||
meta?.analysis?.ai?.rating ??
|
||||
meta?.ai?.rating ??
|
||||
null
|
||||
|
||||
if (!rating || typeof rating !== 'object' || Array.isArray(rating)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return rating as Record<string, unknown>
|
||||
}
|
||||
|
||||
function readNSFWStars(metaRaw: unknown): number | undefined {
|
||||
const rating = readRatingNode(metaRaw)
|
||||
|
||||
const raw = rating?.stars
|
||||
const n = Number(raw)
|
||||
if (!Number.isFinite(n)) return undefined
|
||||
|
||||
return Math.max(1, Math.min(5, Math.floor(n)))
|
||||
}
|
||||
|
||||
function humanizeKey(key: string): string {
|
||||
return key
|
||||
.replace(/\./g, ' ')
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/\b\w/g, (m) => m.toUpperCase())
|
||||
}
|
||||
|
||||
function formatScalar(value: unknown): string | null {
|
||||
if (value == null) return null
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const s = value.trim()
|
||||
return s || null
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value)) return null
|
||||
if (Number.isInteger(value)) return String(value)
|
||||
return value.toFixed(4).replace(/\.?0+$/, '')
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'Ja' : 'Nein'
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const parts = value.map(formatScalar).filter(Boolean) as string[]
|
||||
return parts.length > 0 ? parts.join(', ') : null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function readRatingEntries(metaRaw: unknown): Array<{ key: string; label: string; value: string }> {
|
||||
const rating = readRatingNode(metaRaw)
|
||||
if (!rating) return []
|
||||
|
||||
const out: Array<{ key: string; label: string; value: string }> = []
|
||||
|
||||
for (const [key, raw] of Object.entries(rating)) {
|
||||
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
||||
for (const [subKey, subRaw] of Object.entries(raw as Record<string, unknown>)) {
|
||||
if (subRaw && typeof subRaw === 'object') continue
|
||||
|
||||
const formatted = formatScalar(subRaw)
|
||||
if (!formatted) continue
|
||||
|
||||
out.push({
|
||||
key: `${key}.${subKey}`,
|
||||
label: humanizeKey(`${key} ${subKey}`),
|
||||
value: formatted,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const formatted = formatScalar(raw)
|
||||
if (!formatted) continue
|
||||
|
||||
out.push({
|
||||
key,
|
||||
label: humanizeKey(key),
|
||||
value: formatted,
|
||||
})
|
||||
}
|
||||
|
||||
out.sort((a, b) => {
|
||||
if (a.key === 'stars') return -1
|
||||
if (b.key === 'stars') return 1
|
||||
return a.label.localeCompare(b.label, 'de')
|
||||
})
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
function nsfwRatingTone(stars: number) {
|
||||
switch (stars) {
|
||||
case 1:
|
||||
return { star: 'text-yellow-400 opacity-40' }
|
||||
case 2:
|
||||
return { star: 'text-yellow-400 opacity-55' }
|
||||
case 3:
|
||||
return { star: 'text-yellow-400 opacity-70' }
|
||||
case 4:
|
||||
return { star: 'text-yellow-400 opacity-85' }
|
||||
default:
|
||||
return { star: 'text-yellow-400 opacity-100' }
|
||||
}
|
||||
}
|
||||
|
||||
function RatingStar({
|
||||
stars,
|
||||
className,
|
||||
}: {
|
||||
stars: number
|
||||
className: string
|
||||
}) {
|
||||
const tone = nsfwRatingTone(stars)
|
||||
|
||||
return (
|
||||
<span className={`relative inline-grid shrink-0 place-items-center leading-none ${className}`}>
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
aria-hidden="true"
|
||||
className={`shrink-0 fill-current ${tone.star}`}
|
||||
style={{
|
||||
filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.75)) drop-shadow(0 0 3px rgba(0,0,0,0.35))',
|
||||
}}
|
||||
>
|
||||
<path
|
||||
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.176 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 9.72c-.783-.57-.38-1.81.588-1.81H7.03a1 1 0 00.951-.69l1.07-3.292z"
|
||||
stroke="rgba(0,0,0,0.75)"
|
||||
strokeWidth="1.1"
|
||||
paintOrder="stroke fill"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<span
|
||||
className="absolute inset-0 grid place-items-center text-[10px] font-black leading-none text-white"
|
||||
style={{
|
||||
textShadow: '0 1px 2px rgba(0,0,0,0.95), 0 0 2px rgba(0,0,0,0.85)',
|
||||
}}
|
||||
>
|
||||
{stars}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function RatingWithPopover({
|
||||
stars,
|
||||
metaRaw,
|
||||
className,
|
||||
}: {
|
||||
stars: number
|
||||
metaRaw: unknown
|
||||
className: string
|
||||
}) {
|
||||
const entries = readRatingEntries(metaRaw)
|
||||
|
||||
const starNode = (
|
||||
<span
|
||||
className="inline-flex items-center justify-center"
|
||||
aria-label={`Rating ${stars} von 5`}
|
||||
>
|
||||
<RatingStar stars={stars} className={className} />
|
||||
</span>
|
||||
)
|
||||
|
||||
if (entries.length === 0) {
|
||||
return starNode
|
||||
}
|
||||
|
||||
return (
|
||||
<HoverPopover
|
||||
content={(open) =>
|
||||
open && (
|
||||
<div className="w-[240px] rounded-lg">
|
||||
<div className="border-b border-gray-200 px-3 py-2 dark:border-white/10">
|
||||
<div className="text-xs font-semibold text-gray-900 dark:text-white">
|
||||
AI Rating
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-gray-500 dark:text-gray-400">
|
||||
Bewertungswerte aus der Analyse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2">
|
||||
<div className="space-y-1.5">
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.key} className="flex items-start justify-between gap-3 text-[12px]">
|
||||
<span className="min-w-0 text-gray-500 dark:text-gray-400">
|
||||
{entry.label}
|
||||
</span>
|
||||
<span className="shrink-0 font-medium text-gray-900 dark:text-white">
|
||||
{entry.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
{starNode}
|
||||
</HoverPopover>
|
||||
)
|
||||
}
|
||||
|
||||
export default function FinishedDownloadRating({
|
||||
metaRaw,
|
||||
variant = 'overlay',
|
||||
reserveSpace = false,
|
||||
}: FinishedDownloadRatingProps) {
|
||||
const stars = readNSFWStars(metaRaw)
|
||||
|
||||
if (typeof stars !== 'number') {
|
||||
if (reserveSpace && variant === 'badge') {
|
||||
return <span aria-hidden="true" className="inline-block h-6 w-6 shrink-0 opacity-0" />
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (variant === 'badge') {
|
||||
return (
|
||||
<span className="inline-flex h-6 w-6 shrink-0 items-center justify-center align-middle">
|
||||
<RatingWithPopover
|
||||
stars={stars}
|
||||
metaRaw={metaRaw}
|
||||
className="h-6 w-6"
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex h-6 min-w-[24px] shrink-0 items-center justify-center">
|
||||
<RatingWithPopover
|
||||
stars={stars}
|
||||
metaRaw={metaRaw}
|
||||
className="h-6 w-6"
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@ -59,6 +59,7 @@ import {
|
||||
type FinishedSelectionStore,
|
||||
} from './finishedSelectionStore'
|
||||
import { formatResolution, formatDuration, formatBytes } from './formatters'
|
||||
import FinishedDownloadRating from './FinishedDownloadRating'
|
||||
|
||||
type Props = {
|
||||
jobs: RecordJob[]
|
||||
@ -1804,6 +1805,25 @@ export default function FinishedDownloads({
|
||||
return renderDownloadIntegrityBadge((job as any)?.meta)
|
||||
}, [])
|
||||
|
||||
const renderNSFWBadge = useCallback((job: RecordJob) => {
|
||||
return (
|
||||
<FinishedDownloadRating
|
||||
metaRaw={(job as any)?.meta}
|
||||
variant="badge"
|
||||
reserveSpace
|
||||
/>
|
||||
)
|
||||
}, [])
|
||||
|
||||
const renderRatingOverlay = useCallback((job: RecordJob) => {
|
||||
return (
|
||||
<FinishedDownloadRating
|
||||
metaRaw={(job as any)?.meta}
|
||||
variant="overlay"
|
||||
/>
|
||||
)
|
||||
}, [])
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// memoized derived data
|
||||
// -----------------------------------------------------------------------------
|
||||
@ -3609,13 +3629,6 @@ export default function FinishedDownloads({
|
||||
let changed = false
|
||||
const next = { ...prev }
|
||||
|
||||
for (const oldFile of regenerateTaskFilesRef.current) {
|
||||
if (!syncedFiles.has(oldFile) && oldFile in next) {
|
||||
delete next[oldFile]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of jobs) {
|
||||
const file = String(item?.file ?? '').trim()
|
||||
if (!file) continue
|
||||
@ -3713,6 +3726,14 @@ export default function FinishedDownloads({
|
||||
}
|
||||
}
|
||||
|
||||
// WICHTIG: stale cleanup erst NACH dem Einsammeln aller aktuellen Dateien
|
||||
for (const oldFile of regenerateTaskFilesRef.current) {
|
||||
if (!syncedFiles.has(oldFile) && oldFile in next) {
|
||||
delete next[oldFile]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
return changed ? next : prev
|
||||
})
|
||||
|
||||
@ -4870,6 +4891,8 @@ export default function FinishedDownloads({
|
||||
onUnlockMobileTeaserAudio={() => setMobileTeaserAudioUnlocked(true)}
|
||||
renderPostworkBadge={renderClickablePostworkBadge}
|
||||
renderIntegrityBadge={renderIntegrityBadge}
|
||||
renderNSFWBadge={renderNSFWBadge}
|
||||
renderRatingOverlay={renderRatingOverlay}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@ -4927,6 +4950,7 @@ export default function FinishedDownloads({
|
||||
forcePreviewMuted={forcePreviewMuted}
|
||||
renderPostworkBadge={renderClickablePostworkBadge}
|
||||
renderIntegrityBadge={renderIntegrityBadge}
|
||||
renderRatingOverlay={renderRatingOverlay}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -4978,6 +5002,8 @@ export default function FinishedDownloads({
|
||||
onRecommendedPageSizeChange={setGalleryPageSize}
|
||||
assetNonce={assetNonce ?? 0}
|
||||
renderIntegrityBadge={renderIntegrityBadge}
|
||||
renderNSFWBadge={renderNSFWBadge}
|
||||
renderRatingOverlay={renderRatingOverlay}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -24,13 +24,11 @@ import RecordJobActions from './RecordJobActions'
|
||||
import TagOverflowRow from './TagOverflowRow'
|
||||
import PreviewScrubber from './PreviewScrubber'
|
||||
import { isHotName, stripHotPrefix } from './hotName'
|
||||
import { formatResolution } from './formatters'
|
||||
import type { FinishedDownloadsTeaserState } from './teaserPlayback'
|
||||
import {
|
||||
shouldAnimateTeaser,
|
||||
shouldEnableTeaserAudio,
|
||||
} from './teaserPlayback'
|
||||
import Checkbox from './Checkbox'
|
||||
import { SpeakerWaveIcon } from '@heroicons/react/24/solid'
|
||||
import {
|
||||
buildSpriteFrameStyle,
|
||||
@ -43,9 +41,13 @@ import { parseFinishedTags, resolveFinishedModelFlags } from './finishedModelFla
|
||||
import CountryFlag, { resolveFinishedCountry } from './CountryFlag'
|
||||
import {
|
||||
type FinishedSelectionStore,
|
||||
useSelectionChecked,
|
||||
} from './finishedSelectionStore'
|
||||
import SpritePreloader from './SpritePreloader'
|
||||
import FinishedDownloadsDesktopCard, {
|
||||
SelectionCheckboxOverlay,
|
||||
} from './FinishedDownloadsDesktopCard'
|
||||
import PreviewRatingOverlay from './PreviewRatingOverlay'
|
||||
import PreviewMetaOverlay from './PreviewMetaOverlay'
|
||||
|
||||
|
||||
type SwipeRefs = {
|
||||
@ -127,6 +129,8 @@ type Props = {
|
||||
mobileTeaserAudioUnlocked?: boolean
|
||||
onUnlockMobileTeaserAudio?: () => void
|
||||
renderIntegrityBadge?: (job: RecordJob) => ReactNode
|
||||
renderNSFWBadge?: (job: RecordJob) => ReactNode
|
||||
renderRatingOverlay?: (job: RecordJob) => ReactNode
|
||||
}
|
||||
|
||||
function CardBlurWrapper({
|
||||
@ -309,441 +313,11 @@ function MobileLoadingStack({
|
||||
)
|
||||
}
|
||||
|
||||
type FinishedDownloadsDesktopCardProps = {
|
||||
job: RecordJob
|
||||
selectionStore: FinishedSelectionStore
|
||||
isDeleting: boolean
|
||||
isKeeping: boolean
|
||||
isRemoving: boolean
|
||||
postworkBadge?: FinishedPostworkSummary
|
||||
|
||||
blurPreviews?: boolean
|
||||
durationSeconds?: number
|
||||
teaserState: FinishedDownloadsTeaserState
|
||||
inlinePlay: InlinePlayState
|
||||
assetNonce?: number
|
||||
forcePreviewMuted?: boolean
|
||||
|
||||
keyFor: (j: RecordJob) => string
|
||||
baseName: (p: string) => string
|
||||
modelNameFromOutput: (output?: string) => string
|
||||
runtimeOf: (job: RecordJob) => string
|
||||
sizeBytesOf: (job: RecordJob) => number | null
|
||||
formatBytes: (bytes?: number | null) => string
|
||||
lower: (s: string) => string
|
||||
|
||||
jobForDetails: (job: RecordJob) => RecordJob
|
||||
handleDuration: (job: RecordJob, seconds: number) => void
|
||||
handleResolution: (job: RecordJob, w: number, h: number) => void
|
||||
handleScrubberClickIndex: (job: RecordJob, segmentIndex: number, segmentCount: number) => void
|
||||
|
||||
onToggleSelected: (job: RecordJob) => void
|
||||
onSelectOnly: (job: RecordJob) => void
|
||||
onHoverPreviewKeyChange?: (key: string | null) => void
|
||||
onOpenPlayer: (job: RecordJob) => void
|
||||
openPlayer: (job: RecordJob) => void
|
||||
startInline: (key: string) => void
|
||||
registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void
|
||||
|
||||
modelsByKey: Record<string, GalleryModelFlags>
|
||||
activeTagSet: Set<string>
|
||||
onToggleTagFilter: (tag: string) => void
|
||||
onToggleHot?: (job: RecordJob) => void | Promise<void>
|
||||
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
|
||||
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
||||
onToggleWatch?: (job: RecordJob) => void | Promise<void>
|
||||
onSplit?: (job: RecordJob) => void | Promise<void>
|
||||
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
|
||||
deleteVideo: (job: RecordJob) => Promise<boolean>
|
||||
keepVideo: (job: RecordJob) => Promise<boolean>
|
||||
|
||||
renderPostworkBadge?: (badge?: FinishedPostworkSummary) => ReactNode
|
||||
renderIntegrityBadge?: (job: RecordJob) => ReactNode
|
||||
}
|
||||
|
||||
function samePostworkSummaryLite(
|
||||
a?: FinishedPostworkSummary,
|
||||
b?: FinishedPostworkSummary
|
||||
) {
|
||||
if (a === b) return true
|
||||
if (!a || !b) return !a && !b
|
||||
|
||||
return (
|
||||
a.file === b.file &&
|
||||
a.state === b.state &&
|
||||
a.label === b.label &&
|
||||
a.ts === b.ts
|
||||
)
|
||||
}
|
||||
|
||||
function isPreviewActiveForKey(
|
||||
state: FinishedDownloadsTeaserState,
|
||||
key: string
|
||||
) {
|
||||
return state.activeKey === key || state.hoverKey === key
|
||||
}
|
||||
|
||||
function inlineStateForKey(
|
||||
inlinePlay: InlinePlayState,
|
||||
key: string
|
||||
) {
|
||||
const active = inlinePlay?.key === key
|
||||
return {
|
||||
active,
|
||||
nonce: active ? inlinePlay?.nonce ?? 0 : 0,
|
||||
}
|
||||
}
|
||||
|
||||
function SelectionCheckboxOverlay({
|
||||
selectionStore,
|
||||
rowKey,
|
||||
label,
|
||||
onToggle,
|
||||
}: {
|
||||
selectionStore: FinishedSelectionStore
|
||||
rowKey: string
|
||||
label: string
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const checked = useSelectionChecked(selectionStore, rowKey)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'absolute left-3 top-3 z-20 transition-opacity duration-150',
|
||||
checked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
|
||||
].join(' ')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
ariaLabel={label}
|
||||
onChange={onToggle}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const FinishedDownloadsDesktopCard = memo(
|
||||
function FinishedDownloadsDesktopCard({
|
||||
job: j,
|
||||
selectionStore,
|
||||
isDeleting,
|
||||
isKeeping,
|
||||
isRemoving,
|
||||
postworkBadge,
|
||||
blurPreviews,
|
||||
durationSeconds,
|
||||
teaserState,
|
||||
inlinePlay,
|
||||
assetNonce,
|
||||
forcePreviewMuted,
|
||||
keyFor,
|
||||
baseName,
|
||||
modelNameFromOutput,
|
||||
runtimeOf,
|
||||
sizeBytesOf,
|
||||
formatBytes,
|
||||
lower,
|
||||
jobForDetails,
|
||||
handleDuration,
|
||||
handleResolution,
|
||||
onToggleSelected,
|
||||
onHoverPreviewKeyChange,
|
||||
onOpenPlayer,
|
||||
openPlayer,
|
||||
startInline,
|
||||
registerTeaserHost,
|
||||
modelsByKey,
|
||||
activeTagSet,
|
||||
onToggleTagFilter,
|
||||
onToggleHot,
|
||||
onToggleFavorite,
|
||||
onToggleLike,
|
||||
onToggleWatch,
|
||||
onSplit,
|
||||
onAddToDownloads,
|
||||
deleteVideo,
|
||||
keepVideo,
|
||||
renderPostworkBadge,
|
||||
renderIntegrityBadge,
|
||||
}: FinishedDownloadsDesktopCardProps) {
|
||||
const k = keyFor(j)
|
||||
const busy = isDeleting || isKeeping || isRemoving
|
||||
const inlineActive = inlinePlay?.key === k
|
||||
const inlineNonce = inlineActive ? inlinePlay?.nonce ?? 0 : 0
|
||||
|
||||
const allowSound = shouldEnableTeaserAudio({
|
||||
state: teaserState,
|
||||
itemKey: k,
|
||||
inlineActive,
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
const handleSelectOrOpen = useCallback(() => {
|
||||
if (selectionStore.hasAnySelection()) {
|
||||
onToggleSelected(j)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}, [selectionStore, onToggleSelected, j])
|
||||
|
||||
const previewMuted = forcePreviewMuted ? true : !allowSound
|
||||
|
||||
const model = modelNameFromOutput(j.output)
|
||||
const fileRaw = baseName(j.output || '')
|
||||
const isHot = isHotName(fileRaw)
|
||||
|
||||
const { flags } = resolveFinishedModelFlags(
|
||||
j,
|
||||
modelsByKey,
|
||||
modelNameFromOutput,
|
||||
lower
|
||||
)
|
||||
|
||||
const tags = parseFinishedTags(flags?.tags)
|
||||
const modelCountry = resolveFinishedCountry(flags, j)
|
||||
const dur = runtimeOf(j)
|
||||
const size = formatBytes(sizeBytesOf(j))
|
||||
|
||||
const meta = parseJobMeta((j as any)?.meta)
|
||||
const resObj =
|
||||
typeof meta?.file?.video?.width === 'number' &&
|
||||
typeof meta?.file?.video?.height === 'number' &&
|
||||
meta.file.video.width > 0 &&
|
||||
meta.file.video.height > 0
|
||||
? { w: meta.file.video.width, h: meta.file.video.height }
|
||||
: null
|
||||
|
||||
const resLabel = formatResolution(resObj)
|
||||
|
||||
const inlineDomId = `inline-prev-${encodeURIComponent(k)}`
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-finished-hover-key={k}
|
||||
className={[
|
||||
'group relative rounded-lg overflow-visible outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10',
|
||||
'bg-white dark:bg-gray-900/40',
|
||||
'transition-all duration-200',
|
||||
'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none',
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500',
|
||||
busy && 'pointer-events-none opacity-70',
|
||||
isDeleting && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
|
||||
isKeeping && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
|
||||
isRemoving && 'opacity-0 translate-y-2 scale-[0.98]',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (handleSelectOrOpen()) return
|
||||
openPlayer(j)
|
||||
}}
|
||||
onMouseEnter={() => onHoverPreviewKeyChange?.(k)}
|
||||
onMouseLeave={() => onHoverPreviewKeyChange?.(null)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
|
||||
if (handleSelectOrOpen()) return
|
||||
onOpenPlayer(j)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectionCheckboxOverlay
|
||||
selectionStore={selectionStore}
|
||||
rowKey={k}
|
||||
label={`Auswählen: ${baseName(j.output || '') || 'Download'}`}
|
||||
onToggle={() => onToggleSelected(j)}
|
||||
/>
|
||||
|
||||
<Card noBodyPadding className="overflow-hidden bg-transparent">
|
||||
<div
|
||||
id={inlineDomId}
|
||||
ref={registerTeaserHost(k)}
|
||||
className="group/thumb relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (handleSelectOrOpen()) return
|
||||
startInline(k)
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
|
||||
{!inlineActive ? (
|
||||
<div className="pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 group-hover/thumb:opacity-0 group-focus-within/thumb:opacity-0">
|
||||
<div
|
||||
className="flex items-center gap-1.5 text-right text-[11px] font-semibold leading-none text-white"
|
||||
title={[dur, resObj ? `${resObj.w}×${resObj.h}` : resLabel || '', size].filter(Boolean).join(' • ')}
|
||||
>
|
||||
<span>{dur}</span>
|
||||
{resLabel ? <span aria-hidden="true">•</span> : null}
|
||||
{resLabel ? <span>{resLabel}</span> : null}
|
||||
<span aria-hidden="true">•</span>
|
||||
<span>{size}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<FinishedVideoPreview
|
||||
job={j}
|
||||
getFileName={(p) => stripHotPrefix(baseName(p))}
|
||||
className="h-full w-full"
|
||||
variant="fill"
|
||||
durationSeconds={durationSeconds ?? (j as any)?.meta?.file?.durationSeconds}
|
||||
onDuration={handleDuration}
|
||||
onResolution={handleResolution}
|
||||
showPopover={false}
|
||||
blur={inlineActive ? false : Boolean(blurPreviews)}
|
||||
animated={shouldAnimateTeaser({
|
||||
state: teaserState,
|
||||
itemKey: k,
|
||||
disabled: false,
|
||||
})}
|
||||
animatedMode="teaser"
|
||||
animatedTrigger="always"
|
||||
inlineVideo={inlineActive ? 'always' : false}
|
||||
inlineNonce={inlineNonce}
|
||||
inlineControls={inlineActive}
|
||||
inlineLoop={false}
|
||||
muted={previewMuted}
|
||||
popoverMuted={previewMuted}
|
||||
assetNonce={assetNonce ?? 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative min-h-[112px] overflow-visible px-4 py-3 border-t border-black/5 dark:border-white/10 bg-white dark:bg-gray-900"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (handleSelectOrOpen()) return
|
||||
openPlayer(j)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-2 min-w-0">
|
||||
<div className="min-w-0 flex items-center gap-1.5">
|
||||
<div className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{model}
|
||||
</div>
|
||||
|
||||
<ModelGenderIcon gender={flags?.gender} className="shrink-0" />
|
||||
<CountryFlag country={modelCountry} size="sm" className="shrink-0" />
|
||||
</div>
|
||||
|
||||
{(renderIntegrityBadge || (renderPostworkBadge && postworkBadge)) ? (
|
||||
<div className="relative z-20 shrink-0 flex items-center gap-2">
|
||||
{renderIntegrityBadge ? renderIntegrityBadge(j) : null}
|
||||
{renderPostworkBadge && postworkBadge ? renderPostworkBadge(postworkBadge) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-0.5 flex items-start gap-2 min-w-0">
|
||||
{isHot ? (
|
||||
<span className="shrink-0 self-start rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] leading-none font-semibold text-amber-800 dark:text-amber-300">
|
||||
HOT
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<span className="min-w-0 truncate text-xs text-gray-500 dark:text-gray-400">
|
||||
{stripHotPrefix(fileRaw) || '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mt-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="w-full rounded-md bg-gray-50/70 p-1 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
|
||||
<RecordJobActions
|
||||
job={jobForDetails(j)}
|
||||
variant="table"
|
||||
busy={busy}
|
||||
collapseToMenu
|
||||
compact={false}
|
||||
isHot={isHot}
|
||||
isFavorite={Boolean(flags?.favorite)}
|
||||
isLiked={flags?.liked === true}
|
||||
isWatching={Boolean(flags?.watching)}
|
||||
onToggleWatch={onToggleWatch}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onToggleLike={onToggleLike}
|
||||
onToggleHot={onToggleHot}
|
||||
onKeep={keepVideo}
|
||||
onDelete={deleteVideo}
|
||||
onSplit={onSplit}
|
||||
onAddToDownloads={onAddToDownloads}
|
||||
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']}
|
||||
className="w-full gap-1.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
|
||||
<TagOverflowRow
|
||||
rowKey={k}
|
||||
tags={tags}
|
||||
activeTagSet={activeTagSet}
|
||||
lower={lower}
|
||||
onToggleTagFilter={onToggleTagFilter}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
(prev, next) => {
|
||||
const prevKey = prev.keyFor(prev.job)
|
||||
const nextKey = next.keyFor(next.job)
|
||||
|
||||
const prevPreviewActive = isPreviewActiveForKey(prev.teaserState, prevKey)
|
||||
const nextPreviewActive = isPreviewActiveForKey(next.teaserState, nextKey)
|
||||
|
||||
const prevInline = inlineStateForKey(prev.inlinePlay, prevKey)
|
||||
const nextInline = inlineStateForKey(next.inlinePlay, nextKey)
|
||||
|
||||
return (
|
||||
prev.job === next.job &&
|
||||
prev.isDeleting === next.isDeleting &&
|
||||
prev.isKeeping === next.isKeeping &&
|
||||
prev.isRemoving === next.isRemoving &&
|
||||
samePostworkSummaryLite(prev.postworkBadge, next.postworkBadge) &&
|
||||
prev.durationSeconds === next.durationSeconds &&
|
||||
prev.assetNonce === next.assetNonce &&
|
||||
prev.forcePreviewMuted === next.forcePreviewMuted &&
|
||||
prev.blurPreviews === next.blurPreviews &&
|
||||
prev.teaserState.mode === next.teaserState.mode &&
|
||||
prev.teaserState.audioEnabled === next.teaserState.audioEnabled &&
|
||||
prevPreviewActive === nextPreviewActive &&
|
||||
prevInline.active === nextInline.active &&
|
||||
prevInline.nonce === nextInline.nonce &&
|
||||
prev.modelsByKey === next.modelsByKey &&
|
||||
prev.activeTagSet === next.activeTagSet &&
|
||||
prev.renderPostworkBadge === next.renderPostworkBadge
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
function FinishedDownloadsCardsView({
|
||||
rows,
|
||||
isSmall,
|
||||
selectionStore,
|
||||
onToggleSelected,
|
||||
onSelectOnly,
|
||||
isLoading,
|
||||
blurPreviews,
|
||||
durations,
|
||||
@ -775,6 +349,7 @@ function FinishedDownloadsCardsView({
|
||||
registerTeaserHost,
|
||||
handleDuration,
|
||||
handleResolution,
|
||||
resolutionLabelOf,
|
||||
|
||||
deleteVideo,
|
||||
keepVideo,
|
||||
@ -796,6 +371,8 @@ function FinishedDownloadsCardsView({
|
||||
mobileTeaserAudioUnlocked,
|
||||
onUnlockMobileTeaserAudio,
|
||||
renderIntegrityBadge,
|
||||
renderNSFWBadge,
|
||||
renderRatingOverlay,
|
||||
}: Props) {
|
||||
|
||||
const footerTapRef = useRef<Record<string, boolean>>({})
|
||||
@ -816,23 +393,6 @@ function FinishedDownloadsCardsView({
|
||||
return parseJobMeta((j as any)?.meta)
|
||||
}, [])
|
||||
|
||||
const resolutionObjOf = useCallback((j: RecordJob): { w: number; h: number } | null => {
|
||||
const meta = parseMeta(j)
|
||||
|
||||
const w =
|
||||
typeof meta?.file?.video?.width === 'number' && Number.isFinite(meta.file.video.width)
|
||||
? meta.file.video.width
|
||||
: 0
|
||||
|
||||
const h =
|
||||
typeof meta?.file?.video?.height === 'number' && Number.isFinite(meta.file.video.height)
|
||||
? meta.file.video.height
|
||||
: 0
|
||||
|
||||
if (w > 0 && h > 0) return { w, h }
|
||||
return null
|
||||
}, [parseMeta])
|
||||
|
||||
const [scrubActiveByKey, setScrubActiveByKey] = useState<Record<string, number | undefined>>({})
|
||||
const [scrubHoveringByKey, setScrubHoveringByKey] = useState<Record<string, boolean | undefined>>({})
|
||||
|
||||
@ -993,8 +553,24 @@ function FinishedDownloadsCardsView({
|
||||
const dur = runtimeOf(j)
|
||||
const size = formatBytes(sizeBytesOf(j))
|
||||
|
||||
const resObj = resolutionObjOf(j)
|
||||
const resLabel = formatResolution(resObj)
|
||||
const meta = parseMeta(j)
|
||||
|
||||
const resObj = (() => {
|
||||
const w = meta?.media?.video?.width ?? meta?.file?.video?.width
|
||||
const h = meta?.media?.video?.height ?? meta?.file?.video?.height
|
||||
|
||||
if (
|
||||
typeof w === 'number' && Number.isFinite(w) && w > 0 &&
|
||||
typeof h === 'number' && Number.isFinite(h) && h > 0
|
||||
) {
|
||||
return { w, h }
|
||||
}
|
||||
|
||||
return null
|
||||
})()
|
||||
|
||||
const resLabelRaw = resolutionLabelOf(j)
|
||||
const resLabel = resLabelRaw && resLabelRaw !== '—' ? resLabelRaw : ''
|
||||
|
||||
const inlineDomId = `inline-prev-${encodeURIComponent(k)}`
|
||||
|
||||
@ -1109,33 +685,20 @@ function FinishedDownloadsCardsView({
|
||||
>
|
||||
{/* media */}
|
||||
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
|
||||
{/* Meta-Overlay im Video nur anzeigen, wenn KEIN Inline-Player aktiv ist */}
|
||||
{!inlineActive ? (
|
||||
<div
|
||||
className={
|
||||
'pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 ' +
|
||||
'group-hover/thumb:opacity-0 group-focus-within/thumb:opacity-0 ' +
|
||||
(inlineActive ? 'opacity-0' : 'opacity-100')
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-1.5 text-right text-[11px] font-semibold leading-none text-white [text-shadow:_0_1px_0_rgba(0,0,0,0.95),_1px_0_0_rgba(0,0,0,0.95),_-1px_0_0_rgba(0,0,0,0.95),_0_-1px_0_rgba(0,0,0,0.95),_1px_1px_0_rgba(0,0,0,0.8),_-1px_1px_0_rgba(0,0,0,0.8),_1px_-1px_0_rgba(0,0,0,0.8),_-1px_-1px_0_rgba(0,0,0,0.8)]"
|
||||
title={[
|
||||
dur,
|
||||
resObj ? `${resObj.w}×${resObj.h}` : resLabel || '',
|
||||
size,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' • ')}
|
||||
>
|
||||
<span>{dur}</span>
|
||||
{resLabel ? <span aria-hidden="true">•</span> : null}
|
||||
{resLabel ? <span>{resLabel}</span> : null}
|
||||
<span aria-hidden="true">•</span>
|
||||
<span>{size}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<PreviewRatingOverlay
|
||||
hidden={inlineActive}
|
||||
raised={!inlineActive && scrubberCount > 1 && hoveredThumbKey === k}
|
||||
>
|
||||
{renderRatingOverlay ? renderRatingOverlay(j) : null}
|
||||
</PreviewRatingOverlay>
|
||||
|
||||
<PreviewMetaOverlay
|
||||
hidden={inlineActive}
|
||||
dur={dur}
|
||||
resObj={resObj}
|
||||
resLabel={resLabel}
|
||||
size={size}
|
||||
/>
|
||||
|
||||
<FinishedVideoPreview
|
||||
job={j}
|
||||
@ -1293,9 +856,9 @@ function FinishedDownloadsCardsView({
|
||||
openPlayer(j)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex min-h-6 items-center justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-2 min-w-0">
|
||||
<div className="flex min-h-6 items-center justify-between gap-2 min-w-0">
|
||||
<div className="min-w-0 flex items-center gap-1.5">
|
||||
<div className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{model}
|
||||
@ -1313,9 +876,10 @@ function FinishedDownloadsCardsView({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(renderIntegrityBadge || (renderPostworkBadge && livePostwork)) ? (
|
||||
<div className="relative z-20 shrink-0 flex items-center gap-2">
|
||||
{(renderIntegrityBadge || renderNSFWBadge || (renderPostworkBadge && livePostwork)) ? (
|
||||
<div className="relative z-20 shrink-0 flex h-6 items-center gap-2">
|
||||
{renderIntegrityBadge ? renderIntegrityBadge(j) : null}
|
||||
{renderNSFWBadge ? renderNSFWBadge(j) : null}
|
||||
{renderPostworkBadge && livePostwork ? renderPostworkBadge(livePostwork) : null}
|
||||
</div>
|
||||
) : null}
|
||||
@ -1573,9 +1137,8 @@ function FinishedDownloadsCardsView({
|
||||
jobForDetails={jobForDetails}
|
||||
handleDuration={handleDuration}
|
||||
handleResolution={handleResolution}
|
||||
handleScrubberClickIndex={handleScrubberClickIndex}
|
||||
resolutionLabelOf={resolutionLabelOf}
|
||||
onToggleSelected={onToggleSelected}
|
||||
onSelectOnly={onSelectOnly}
|
||||
onHoverPreviewKeyChange={onHoverPreviewKeyChange}
|
||||
onOpenPlayer={onOpenPlayer}
|
||||
openPlayer={openPlayer}
|
||||
@ -1594,6 +1157,8 @@ function FinishedDownloadsCardsView({
|
||||
keepVideo={keepVideo}
|
||||
renderPostworkBadge={renderPostworkBadge}
|
||||
renderIntegrityBadge={renderIntegrityBadge}
|
||||
renderNSFWBadge={renderNSFWBadge}
|
||||
renderRatingOverlay={renderRatingOverlay}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
477
frontend/src/components/ui/FinishedDownloadsDesktopCard.tsx
Normal file
477
frontend/src/components/ui/FinishedDownloadsDesktopCard.tsx
Normal file
@ -0,0 +1,477 @@
|
||||
// frontend\src\components\ui\FinishedDownloadsDesktopCard.tsx
|
||||
|
||||
'use client'
|
||||
|
||||
import { memo, useCallback, type ReactNode } from 'react'
|
||||
import Card from './Card'
|
||||
import Checkbox from './Checkbox'
|
||||
import CountryFlag, { resolveFinishedCountry } from './CountryFlag'
|
||||
import FinishedVideoPreview from './FinishedVideoPreview'
|
||||
import ModelGenderIcon from './ModelGenderIcon'
|
||||
import RecordJobActions from './RecordJobActions'
|
||||
import TagOverflowRow from './TagOverflowRow'
|
||||
import { parseFinishedTags, resolveFinishedModelFlags } from './finishedModelFlags'
|
||||
import { isHotName, stripHotPrefix } from './hotName'
|
||||
import {
|
||||
type FinishedSelectionStore,
|
||||
useSelectionChecked,
|
||||
} from './finishedSelectionStore'
|
||||
import type {
|
||||
FinishedPostworkSummary,
|
||||
GalleryModelFlags,
|
||||
InlinePlayState,
|
||||
RecordJob,
|
||||
} from '../../types'
|
||||
import type { FinishedDownloadsTeaserState } from './teaserPlayback'
|
||||
import {
|
||||
shouldAnimateTeaser,
|
||||
shouldEnableTeaserAudio,
|
||||
} from './teaserPlayback'
|
||||
import { parseJobMeta } from './previewSprite'
|
||||
import PreviewRatingOverlay from './PreviewRatingOverlay'
|
||||
import PreviewMetaOverlay from './PreviewMetaOverlay'
|
||||
|
||||
export type FinishedDownloadsDesktopCardProps = {
|
||||
job: RecordJob
|
||||
selectionStore: FinishedSelectionStore
|
||||
isDeleting: boolean
|
||||
isKeeping: boolean
|
||||
isRemoving: boolean
|
||||
postworkBadge?: FinishedPostworkSummary
|
||||
|
||||
blurPreviews?: boolean
|
||||
durationSeconds?: number
|
||||
teaserState: FinishedDownloadsTeaserState
|
||||
inlinePlay: InlinePlayState
|
||||
assetNonce?: number
|
||||
forcePreviewMuted?: boolean
|
||||
|
||||
keyFor: (j: RecordJob) => string
|
||||
baseName: (p: string) => string
|
||||
modelNameFromOutput: (output?: string) => string
|
||||
runtimeOf: (job: RecordJob) => string
|
||||
sizeBytesOf: (job: RecordJob) => number | null
|
||||
formatBytes: (bytes?: number | null) => string
|
||||
lower: (s: string) => string
|
||||
|
||||
jobForDetails: (job: RecordJob) => RecordJob
|
||||
handleDuration: (job: RecordJob, seconds: number) => void
|
||||
handleResolution: (job: RecordJob, w: number, h: number) => void
|
||||
resolutionLabelOf: (job: RecordJob) => string
|
||||
|
||||
onToggleSelected: (job: RecordJob) => void
|
||||
onHoverPreviewKeyChange?: (key: string | null) => void
|
||||
onOpenPlayer: (job: RecordJob) => void
|
||||
openPlayer: (job: RecordJob) => void
|
||||
startInline: (key: string) => void
|
||||
registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void
|
||||
|
||||
modelsByKey: Record<string, GalleryModelFlags>
|
||||
activeTagSet: Set<string>
|
||||
onToggleTagFilter: (tag: string) => void
|
||||
onToggleHot?: (job: RecordJob) => void | Promise<void>
|
||||
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
|
||||
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
||||
onToggleWatch?: (job: RecordJob) => void | Promise<void>
|
||||
onSplit?: (job: RecordJob) => void | Promise<void>
|
||||
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
|
||||
deleteVideo: (job: RecordJob) => Promise<boolean>
|
||||
keepVideo: (job: RecordJob) => Promise<boolean>
|
||||
|
||||
renderPostworkBadge?: (badge?: FinishedPostworkSummary) => ReactNode
|
||||
renderIntegrityBadge?: (job: RecordJob) => ReactNode
|
||||
renderNSFWBadge?: (job: RecordJob) => ReactNode
|
||||
renderRatingOverlay?: (job: RecordJob) => ReactNode
|
||||
}
|
||||
|
||||
function samePostworkSummaryLite(
|
||||
a?: FinishedPostworkSummary,
|
||||
b?: FinishedPostworkSummary
|
||||
) {
|
||||
if (a === b) return true
|
||||
if (!a || !b) return !a && !b
|
||||
|
||||
return (
|
||||
a.file === b.file &&
|
||||
a.state === b.state &&
|
||||
a.label === b.label &&
|
||||
a.ts === b.ts
|
||||
)
|
||||
}
|
||||
|
||||
function isPreviewActiveForKey(
|
||||
state: FinishedDownloadsTeaserState,
|
||||
key: string
|
||||
) {
|
||||
return state.activeKey === key || state.hoverKey === key
|
||||
}
|
||||
|
||||
function inlineStateForKey(
|
||||
inlinePlay: InlinePlayState,
|
||||
key: string
|
||||
) {
|
||||
const active = inlinePlay?.key === key
|
||||
return {
|
||||
active,
|
||||
nonce: active ? inlinePlay?.nonce ?? 0 : 0,
|
||||
}
|
||||
}
|
||||
|
||||
export function SelectionCheckboxOverlay({
|
||||
selectionStore,
|
||||
rowKey,
|
||||
label,
|
||||
onToggle,
|
||||
}: {
|
||||
selectionStore: FinishedSelectionStore
|
||||
rowKey: string
|
||||
label: string
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const checked = useSelectionChecked(selectionStore, rowKey)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'absolute left-3 top-3 z-20 transition-opacity duration-150',
|
||||
checked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
|
||||
].join(' ')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
ariaLabel={label}
|
||||
onChange={onToggle}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const FinishedDownloadsDesktopCard = memo(
|
||||
function FinishedDownloadsDesktopCard({
|
||||
job: j,
|
||||
selectionStore,
|
||||
isDeleting,
|
||||
isKeeping,
|
||||
isRemoving,
|
||||
postworkBadge,
|
||||
blurPreviews,
|
||||
durationSeconds,
|
||||
teaserState,
|
||||
inlinePlay,
|
||||
assetNonce,
|
||||
forcePreviewMuted,
|
||||
keyFor,
|
||||
baseName,
|
||||
modelNameFromOutput,
|
||||
runtimeOf,
|
||||
sizeBytesOf,
|
||||
formatBytes,
|
||||
lower,
|
||||
jobForDetails,
|
||||
handleDuration,
|
||||
handleResolution,
|
||||
resolutionLabelOf,
|
||||
onToggleSelected,
|
||||
onHoverPreviewKeyChange,
|
||||
onOpenPlayer,
|
||||
openPlayer,
|
||||
startInline,
|
||||
registerTeaserHost,
|
||||
modelsByKey,
|
||||
activeTagSet,
|
||||
onToggleTagFilter,
|
||||
onToggleHot,
|
||||
onToggleFavorite,
|
||||
onToggleLike,
|
||||
onToggleWatch,
|
||||
onSplit,
|
||||
onAddToDownloads,
|
||||
deleteVideo,
|
||||
keepVideo,
|
||||
renderPostworkBadge,
|
||||
renderIntegrityBadge,
|
||||
renderNSFWBadge,
|
||||
renderRatingOverlay,
|
||||
}: FinishedDownloadsDesktopCardProps) {
|
||||
const k = keyFor(j)
|
||||
const busy = isDeleting || isKeeping || isRemoving
|
||||
const inlineActive = inlinePlay?.key === k
|
||||
const inlineNonce = inlineActive ? inlinePlay?.nonce ?? 0 : 0
|
||||
|
||||
const allowSound = shouldEnableTeaserAudio({
|
||||
state: teaserState,
|
||||
itemKey: k,
|
||||
inlineActive,
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
const handleSelectOrOpen = useCallback(() => {
|
||||
if (selectionStore.hasAnySelection()) {
|
||||
onToggleSelected(j)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}, [selectionStore, onToggleSelected, j])
|
||||
|
||||
const previewMuted = forcePreviewMuted ? true : !allowSound
|
||||
|
||||
const model = modelNameFromOutput(j.output)
|
||||
const fileRaw = baseName(j.output || '')
|
||||
const isHot = isHotName(fileRaw)
|
||||
|
||||
const { flags } = resolveFinishedModelFlags(
|
||||
j,
|
||||
modelsByKey,
|
||||
modelNameFromOutput,
|
||||
lower
|
||||
)
|
||||
|
||||
const tags = parseFinishedTags(flags?.tags)
|
||||
const modelCountry = resolveFinishedCountry(flags, j)
|
||||
const dur = runtimeOf(j)
|
||||
const size = formatBytes(sizeBytesOf(j))
|
||||
|
||||
const meta = parseJobMeta((j as any)?.meta)
|
||||
|
||||
const resObj = (() => {
|
||||
const w = meta?.media?.video?.width ?? meta?.file?.video?.width
|
||||
const h = meta?.media?.video?.height ?? meta?.file?.video?.height
|
||||
|
||||
if (
|
||||
typeof w === 'number' && Number.isFinite(w) && w > 0 &&
|
||||
typeof h === 'number' && Number.isFinite(h) && h > 0
|
||||
) {
|
||||
return { w, h }
|
||||
}
|
||||
|
||||
return null
|
||||
})()
|
||||
|
||||
const resLabelRaw = resolutionLabelOf(j)
|
||||
const resLabel = resLabelRaw && resLabelRaw !== '—' ? resLabelRaw : ''
|
||||
|
||||
const inlineDomId = `inline-prev-${encodeURIComponent(k)}`
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-finished-hover-key={k}
|
||||
className={[
|
||||
'group relative rounded-lg overflow-visible outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10',
|
||||
'bg-white dark:bg-gray-900/40',
|
||||
'transition-all duration-200',
|
||||
'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none',
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500',
|
||||
busy && 'pointer-events-none opacity-70',
|
||||
isDeleting && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
|
||||
isKeeping && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
|
||||
isRemoving && 'opacity-0 translate-y-2 scale-[0.98]',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (handleSelectOrOpen()) return
|
||||
openPlayer(j)
|
||||
}}
|
||||
onMouseEnter={() => onHoverPreviewKeyChange?.(k)}
|
||||
onMouseLeave={() => onHoverPreviewKeyChange?.(null)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
|
||||
if (handleSelectOrOpen()) return
|
||||
onOpenPlayer(j)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectionCheckboxOverlay
|
||||
selectionStore={selectionStore}
|
||||
rowKey={k}
|
||||
label={`Auswählen: ${baseName(j.output || '') || 'Download'}`}
|
||||
onToggle={() => onToggleSelected(j)}
|
||||
/>
|
||||
|
||||
<Card noBodyPadding className="overflow-hidden bg-transparent">
|
||||
<div
|
||||
id={inlineDomId}
|
||||
ref={registerTeaserHost(k)}
|
||||
className="group/thumb relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (handleSelectOrOpen()) return
|
||||
startInline(k)
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
|
||||
<PreviewRatingOverlay hidden={inlineActive}>
|
||||
{renderRatingOverlay ? renderRatingOverlay(j) : null}
|
||||
</PreviewRatingOverlay>
|
||||
|
||||
<PreviewMetaOverlay
|
||||
hidden={inlineActive}
|
||||
dur={dur}
|
||||
resObj={resObj}
|
||||
resLabel={resLabel}
|
||||
size={size}
|
||||
/>
|
||||
|
||||
<FinishedVideoPreview
|
||||
job={j}
|
||||
getFileName={(p) => stripHotPrefix(baseName(p))}
|
||||
className="h-full w-full"
|
||||
variant="fill"
|
||||
durationSeconds={durationSeconds ?? (j as any)?.meta?.file?.durationSeconds}
|
||||
onDuration={handleDuration}
|
||||
onResolution={handleResolution}
|
||||
showPopover={false}
|
||||
blur={inlineActive ? false : Boolean(blurPreviews)}
|
||||
animated={shouldAnimateTeaser({
|
||||
state: teaserState,
|
||||
itemKey: k,
|
||||
disabled: false,
|
||||
})}
|
||||
animatedMode="teaser"
|
||||
animatedTrigger="always"
|
||||
inlineVideo={inlineActive ? 'always' : false}
|
||||
inlineNonce={inlineNonce}
|
||||
inlineControls={inlineActive}
|
||||
inlineLoop={false}
|
||||
muted={previewMuted}
|
||||
popoverMuted={previewMuted}
|
||||
assetNonce={assetNonce ?? 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative min-h-[112px] overflow-visible border-t border-black/5 bg-white px-4 py-3 dark:border-white/10 dark:bg-gray-900"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (handleSelectOrOpen()) return
|
||||
openPlayer(j)
|
||||
}}
|
||||
>
|
||||
<div className="flex min-h-6 items-center justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-h-6 min-w-0 items-center justify-between gap-2">
|
||||
<div className="min-w-0 flex items-center gap-1.5">
|
||||
<div className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{model}
|
||||
</div>
|
||||
|
||||
<ModelGenderIcon gender={flags?.gender} className="shrink-0" />
|
||||
<CountryFlag country={modelCountry} size="sm" className="shrink-0" />
|
||||
</div>
|
||||
|
||||
{(renderIntegrityBadge || renderNSFWBadge || (renderPostworkBadge && postworkBadge)) ? (
|
||||
<div className="relative z-20 flex h-6 shrink-0 items-center gap-2">
|
||||
{renderIntegrityBadge ? renderIntegrityBadge(j) : null}
|
||||
{renderNSFWBadge ? renderNSFWBadge(j) : null}
|
||||
{renderPostworkBadge && postworkBadge ? renderPostworkBadge(postworkBadge) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-0.5 flex min-w-0 items-start gap-2">
|
||||
{isHot ? (
|
||||
<span className="shrink-0 self-start rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] font-semibold leading-none text-amber-800 dark:text-amber-300">
|
||||
HOT
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<span className="min-w-0 truncate text-xs text-gray-500 dark:text-gray-400">
|
||||
{stripHotPrefix(fileRaw) || '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mt-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="w-full rounded-md bg-gray-50/70 p-1 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
|
||||
<RecordJobActions
|
||||
job={jobForDetails(j)}
|
||||
variant="table"
|
||||
busy={busy}
|
||||
collapseToMenu
|
||||
compact={false}
|
||||
isHot={isHot}
|
||||
isFavorite={Boolean(flags?.favorite)}
|
||||
isLiked={flags?.liked === true}
|
||||
isWatching={Boolean(flags?.watching)}
|
||||
onToggleWatch={onToggleWatch}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onToggleLike={onToggleLike}
|
||||
onToggleHot={onToggleHot}
|
||||
onKeep={keepVideo}
|
||||
onDelete={deleteVideo}
|
||||
onSplit={onSplit}
|
||||
onAddToDownloads={onAddToDownloads}
|
||||
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']}
|
||||
className="w-full gap-1.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mt-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<TagOverflowRow
|
||||
rowKey={k}
|
||||
tags={tags}
|
||||
activeTagSet={activeTagSet}
|
||||
lower={lower}
|
||||
onToggleTagFilter={onToggleTagFilter}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
(prev, next) => {
|
||||
const prevKey = prev.keyFor(prev.job)
|
||||
const nextKey = next.keyFor(next.job)
|
||||
|
||||
const prevPreviewActive = isPreviewActiveForKey(prev.teaserState, prevKey)
|
||||
const nextPreviewActive = isPreviewActiveForKey(next.teaserState, nextKey)
|
||||
|
||||
const prevInline = inlineStateForKey(prev.inlinePlay, prevKey)
|
||||
const nextInline = inlineStateForKey(next.inlinePlay, nextKey)
|
||||
|
||||
return (
|
||||
prev.job === next.job &&
|
||||
prev.isDeleting === next.isDeleting &&
|
||||
prev.isKeeping === next.isKeeping &&
|
||||
prev.isRemoving === next.isRemoving &&
|
||||
samePostworkSummaryLite(prev.postworkBadge, next.postworkBadge) &&
|
||||
prev.durationSeconds === next.durationSeconds &&
|
||||
prev.assetNonce === next.assetNonce &&
|
||||
prev.forcePreviewMuted === next.forcePreviewMuted &&
|
||||
prev.blurPreviews === next.blurPreviews &&
|
||||
prev.teaserState.mode === next.teaserState.mode &&
|
||||
prev.teaserState.audioEnabled === next.teaserState.audioEnabled &&
|
||||
prevPreviewActive === nextPreviewActive &&
|
||||
prevInline.active === nextInline.active &&
|
||||
prevInline.nonce === nextInline.nonce &&
|
||||
prev.modelsByKey === next.modelsByKey &&
|
||||
prev.activeTagSet === next.activeTagSet &&
|
||||
prev.renderPostworkBadge === next.renderPostworkBadge
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export default FinishedDownloadsDesktopCard
|
||||
@ -29,6 +29,8 @@ import ModelGenderIcon from './ModelGenderIcon'
|
||||
import { resolveFinishedModelFlags } from './finishedModelFlags'
|
||||
import CountryFlag, { resolveFinishedCountry } from './CountryFlag'
|
||||
import SpritePreloader from './SpritePreloader'
|
||||
import PreviewRatingOverlay from './PreviewRatingOverlay'
|
||||
import PreviewMetaOverlay from './PreviewMetaOverlay'
|
||||
|
||||
function normalizeDurationSeconds(value: unknown): number | undefined {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
|
||||
@ -89,6 +91,8 @@ type Props = {
|
||||
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
|
||||
jobForDetails: (job: RecordJob) => RecordJob
|
||||
renderIntegrityBadge?: (job: RecordJob) => ReactNode
|
||||
renderNSFWBadge?: (job: RecordJob) => ReactNode
|
||||
renderRatingOverlay?: (job: RecordJob) => ReactNode
|
||||
|
||||
contentPaddingClass: string
|
||||
footerMinHeightClass: string
|
||||
@ -140,6 +144,8 @@ function FinishedDownloadsGalleryCardInner({
|
||||
onAddToDownloads,
|
||||
jobForDetails,
|
||||
renderIntegrityBadge,
|
||||
renderNSFWBadge,
|
||||
renderRatingOverlay,
|
||||
contentPaddingClass,
|
||||
footerMinHeightClass,
|
||||
titleClass,
|
||||
@ -381,6 +387,19 @@ function FinishedDownloadsGalleryCardInner({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PreviewRatingOverlay
|
||||
raised={hasScrubber && hoveredThumbKey === k}
|
||||
>
|
||||
{renderRatingOverlay ? renderRatingOverlay(j) : null}
|
||||
</PreviewRatingOverlay>
|
||||
|
||||
<PreviewMetaOverlay
|
||||
dur={dur}
|
||||
resObj={resObj}
|
||||
resLabel={resLabel}
|
||||
size={size}
|
||||
/>
|
||||
|
||||
<SpritePreloader
|
||||
src={spriteUrl}
|
||||
enabled={shouldPreloadSprite}
|
||||
@ -411,19 +430,6 @@ function FinishedDownloadsGalleryCardInner({
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 group-hover/thumb:opacity-0 group-focus-within/thumb:opacity-0">
|
||||
<div
|
||||
className="flex items-center gap-1.5 text-right text-[11px] font-semibold leading-none text-white [text-shadow:_0_1px_0_rgba(0,0,0,0.95),_1px_0_0_rgba(0,0,0,0.95),_-1px_0_0_rgba(0,0,0,0.95),_0_-1px_0_rgba(0,0,0,0.95),_1px_1px_0_rgba(0,0,0,0.8),_-1px_1px_0_rgba(0,0,0,0.8),_1px_-1px_0_rgba(0,0,0,0.8),_-1px_-1px_0_rgba(0,0,0,0.8)]"
|
||||
title={[dur, resObj ? `${resObj.w}×${resObj.h}` : resLabel || '', size].filter(Boolean).join(' • ')}
|
||||
>
|
||||
<span>{dur}</span>
|
||||
{resLabel ? <span aria-hidden="true">•</span> : null}
|
||||
{resLabel ? <span>{resLabel}</span> : null}
|
||||
<span aria-hidden="true">•</span>
|
||||
<span>{size}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -438,7 +444,7 @@ function FinishedDownloadsGalleryCardInner({
|
||||
}}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="mt-0.5 flex min-h-[22px] items-start justify-between gap-2 min-w-0">
|
||||
<div className="mt-0.5 flex min-h-6 items-center justify-between gap-2 min-w-0">
|
||||
<div className="min-w-0 flex items-center gap-1.5">
|
||||
<div className={['min-w-0 truncate font-semibold text-gray-900 dark:text-white', titleClass].join(' ')}>
|
||||
{model}
|
||||
@ -456,9 +462,10 @@ function FinishedDownloadsGalleryCardInner({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(renderIntegrityBadge || (showPostworkBadge && renderPostworkBadge)) ? (
|
||||
<div className="relative z-20 shrink-0 flex items-center gap-2">
|
||||
{(renderIntegrityBadge || renderNSFWBadge || (showPostworkBadge && renderPostworkBadge)) ? (
|
||||
<div className="relative z-20 shrink-0 flex h-6 items-center gap-2">
|
||||
{renderIntegrityBadge ? renderIntegrityBadge(j) : null}
|
||||
{renderNSFWBadge ? renderNSFWBadge(j) : null}
|
||||
{showPostworkBadge && renderPostworkBadge ? renderPostworkBadge(postworkBadge) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@ -72,6 +72,8 @@ type Props = {
|
||||
forcePreviewMuted?: boolean
|
||||
assetNonce?: number
|
||||
renderIntegrityBadge?: (job: RecordJob) => ReactNode
|
||||
renderNSFWBadge?: (job: RecordJob) => ReactNode
|
||||
renderRatingOverlay?: (job: RecordJob) => ReactNode
|
||||
}
|
||||
|
||||
function snapTo(value: number, step: number, base = 0) {
|
||||
@ -132,6 +134,8 @@ function FinishedDownloadsGalleryView({
|
||||
forcePreviewMuted,
|
||||
assetNonce,
|
||||
renderIntegrityBadge,
|
||||
renderNSFWBadge,
|
||||
renderRatingOverlay,
|
||||
}: Props) {
|
||||
const observeTeasers = shouldObserveTeasers(teaserState.mode)
|
||||
|
||||
@ -317,6 +321,8 @@ function FinishedDownloadsGalleryView({
|
||||
sublineClass={sublineClass}
|
||||
assetNonce={assetNonce ?? 0}
|
||||
renderIntegrityBadge={renderIntegrityBadge}
|
||||
renderNSFWBadge={renderNSFWBadge}
|
||||
renderRatingOverlay={renderRatingOverlay}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -25,6 +25,7 @@ import { parseJobMeta } from './previewSprite'
|
||||
import ModelGenderIcon from './ModelGenderIcon'
|
||||
import { resolveFinishedModelFlags } from './finishedModelFlags'
|
||||
import CountryFlag, { resolveFinishedCountry } from './CountryFlag'
|
||||
import PreviewRatingOverlay from './PreviewRatingOverlay'
|
||||
|
||||
type Props = {
|
||||
rows: RecordJob[]
|
||||
@ -89,6 +90,7 @@ type Props = {
|
||||
keepVideo: (job: RecordJob) => Promise<boolean>
|
||||
forcePreviewMuted?: boolean
|
||||
renderIntegrityBadge?: (job: RecordJob) => ReactNode
|
||||
renderRatingOverlay?: (job: RecordJob) => ReactNode
|
||||
}
|
||||
|
||||
export default function FinishedDownloadsTableView({
|
||||
@ -145,6 +147,7 @@ export default function FinishedDownloadsTableView({
|
||||
keepVideo,
|
||||
forcePreviewMuted,
|
||||
renderIntegrityBadge,
|
||||
renderRatingOverlay,
|
||||
}: Props) {
|
||||
const [sort, setSort] = useState<SortState>(null)
|
||||
|
||||
@ -326,38 +329,43 @@ export default function FinishedDownloadsTableView({
|
||||
setScrubActiveIndex(k, undefined)
|
||||
}}
|
||||
>
|
||||
<FinishedVideoPreview
|
||||
job={jobForDetails(j)}
|
||||
getFileName={baseName}
|
||||
durationSeconds={durations[k] ?? (j as any)?.meta?.file?.durationSeconds}
|
||||
muted={previewMuted}
|
||||
popoverMuted={previewMuted}
|
||||
onDuration={handleDuration}
|
||||
onResolution={handleResolution}
|
||||
className="w-28 h-16 rounded-md ring-1 ring-black/5 dark:ring-white/10"
|
||||
showPopover={false}
|
||||
blur={blurPreviews}
|
||||
animated={shouldAnimateTeaser({
|
||||
state: teaserState,
|
||||
itemKey: k,
|
||||
})}
|
||||
animatedMode="teaser"
|
||||
animatedTrigger="always"
|
||||
assetNonce={assetNonce}
|
||||
forceActive={isPreviewActive}
|
||||
teaserPreloadEnabled={false}
|
||||
/>
|
||||
|
||||
{/* 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}
|
||||
<div className="relative w-28 h-16">
|
||||
<FinishedVideoPreview
|
||||
job={jobForDetails(j)}
|
||||
getFileName={baseName}
|
||||
durationSeconds={durations[k] ?? (j as any)?.meta?.file?.durationSeconds}
|
||||
muted={previewMuted}
|
||||
popoverMuted={previewMuted}
|
||||
onDuration={handleDuration}
|
||||
onResolution={handleResolution}
|
||||
className="w-28 h-16 rounded-md ring-1 ring-black/5 dark:ring-white/10"
|
||||
showPopover={false}
|
||||
blur={blurPreviews}
|
||||
animated={shouldAnimateTeaser({
|
||||
state: teaserState,
|
||||
itemKey: k,
|
||||
})}
|
||||
animatedMode="teaser"
|
||||
animatedTrigger="always"
|
||||
assetNonce={assetNonce}
|
||||
forceActive={isPreviewActive}
|
||||
teaserPreloadEnabled={false}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<PreviewRatingOverlay>
|
||||
{renderRatingOverlay ? renderRatingOverlay(j) : null}
|
||||
</PreviewRatingOverlay>
|
||||
|
||||
{spriteInfo && typeof scrubActiveIndex === 'number' && scrubFrameSrc ? (
|
||||
<img
|
||||
src={scrubFrameSrc}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 z-[15] h-16 w-28 rounded-md object-cover ring-1 ring-black/5 dark:ring-white/10"
|
||||
draggable={false}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
@ -414,7 +422,7 @@ export default function FinishedDownloadsTableView({
|
||||
</div>
|
||||
|
||||
{(renderIntegrityBadge || (showPostworkBadge && renderPostworkBadge)) ? (
|
||||
<div className="relative z-20 shrink-0 flex items-center gap-2">
|
||||
<div className="relative z-20 shrink-0 flex h-6 items-center gap-2">
|
||||
{renderIntegrityBadge ? renderIntegrityBadge(j) : null}
|
||||
{showPostworkBadge && renderPostworkBadge ? renderPostworkBadge(postworkBadge) : null}
|
||||
</div>
|
||||
@ -422,7 +430,7 @@ export default function FinishedDownloadsTableView({
|
||||
</div>
|
||||
|
||||
<div className="mt-0.5 min-w-0 text-xs text-gray-500 dark:text-gray-400">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="flex min-h-6 items-center gap-2 min-w-0">
|
||||
<span className="truncate" title={file}>
|
||||
{file || '—'}
|
||||
</span>
|
||||
@ -607,7 +615,8 @@ export default function FinishedDownloadsTableView({
|
||||
onToggleSelectAllPage,
|
||||
allSelectedOnPage,
|
||||
someSelectedOnPage,
|
||||
renderIntegrityBadge
|
||||
renderIntegrityBadge,
|
||||
renderRatingOverlay,
|
||||
])
|
||||
|
||||
const sortStateToMode = useCallback((s: SortState): DoneSortMode => {
|
||||
|
||||
@ -118,9 +118,6 @@ export default function FinishedVideoPreview({
|
||||
inlineNonce = 0,
|
||||
inlineControls = false,
|
||||
inlineLoop = true,
|
||||
|
||||
assetNonce = 0,
|
||||
|
||||
muted = DEFAULT_INLINE_MUTED,
|
||||
popoverMuted = DEFAULT_INLINE_MUTED,
|
||||
alwaysLoadStill = false,
|
||||
@ -564,12 +561,11 @@ export default function FinishedVideoPreview({
|
||||
setTeaserReady(false)
|
||||
setTeaserOk(true)
|
||||
|
||||
// ✅ neue Datei/Asset -> Callback-Dedupe zurücksetzen
|
||||
emittedDurationRef.current = ''
|
||||
emittedResolutionRef.current = ''
|
||||
teaserSoundForcedRef.current = false
|
||||
setMetaLoaded(false)
|
||||
}, [previewId, assetNonce])
|
||||
}, [previewId])
|
||||
|
||||
useEffect(() => {
|
||||
const onRelease = (ev: any) => {
|
||||
@ -674,18 +670,16 @@ export default function FinishedVideoPreview({
|
||||
return Math.min(dur - 0.05, Math.max(0.05, t))
|
||||
}, [animated, animatedMode, hasDuration, effectiveDurationSec, localTick, thumbStepSec, thumbSpread, thumbSamples])
|
||||
|
||||
const v = assetNonce ?? 0
|
||||
|
||||
const thumbSrc = useMemo(() => {
|
||||
if (!previewId) return ''
|
||||
if (thumbTimeSec == null) return `/api/preview?id=${encodeURIComponent(previewId)}&v=${v}`
|
||||
return `/api/preview?id=${encodeURIComponent(previewId)}&t=${thumbTimeSec}&v=${v}`
|
||||
}, [previewId, thumbTimeSec, v])
|
||||
if (thumbTimeSec == null) return `/api/preview?id=${encodeURIComponent(previewId)}`
|
||||
return `/api/preview?id=${encodeURIComponent(previewId)}&t=${thumbTimeSec}`
|
||||
}, [previewId, thumbTimeSec])
|
||||
|
||||
const teaserSrc = useMemo(() => {
|
||||
if (!previewId) return ''
|
||||
return `/api/generated/teaser?id=${encodeURIComponent(previewId)}&noGenerate=1&v=${v}`
|
||||
}, [previewId, v])
|
||||
return `/api/generated/teaser?id=${encodeURIComponent(previewId)}&noGenerate=1`
|
||||
}, [previewId])
|
||||
|
||||
// --- Inline Video sichtbar?
|
||||
const showingInlineVideo =
|
||||
@ -804,11 +798,10 @@ export default function FinishedVideoPreview({
|
||||
setThumbOk(true)
|
||||
setVideoOk(true)
|
||||
|
||||
// ✅ Mount-Guards zurücksetzen
|
||||
lastMountedRef.current.inline = null
|
||||
lastMountedRef.current.teaser = null
|
||||
lastMountedRef.current.clips = null
|
||||
}, [previewId, assetNonce])
|
||||
}, [previewId])
|
||||
|
||||
if (!videoSrc) {
|
||||
return <div className={[sizeClass, 'rounded bg-gray-100 dark:bg-white/5'].join(' ')} />
|
||||
@ -1402,7 +1395,7 @@ export default function FinishedVideoPreview({
|
||||
{/* ✅ Teaser prewarm (lädt Datei/Metadata vor, bleibt unsichtbar) */}
|
||||
{!showingInlineVideo && teaserCanPrewarm && !(teaserActive && animatedMode === 'teaser') ? (
|
||||
<video
|
||||
key={`teaser-prewarm-${previewId}-${v}`}
|
||||
key={`teaser-prewarm-${previewId}`}
|
||||
src={teaserSrc}
|
||||
className="hidden"
|
||||
muted
|
||||
@ -1426,7 +1419,7 @@ export default function FinishedVideoPreview({
|
||||
{/* ✅ Teaser MP4 */}
|
||||
{!showingInlineVideo && teaserActive && animatedMode === 'teaser' ? (
|
||||
<video
|
||||
key={`teaser-${previewId}-${v}`}
|
||||
key={`teaser-${previewId}`}
|
||||
ref={(el) => {
|
||||
teaserMp4Ref.current = el
|
||||
bumpMountTickIfNew('teaser', el)
|
||||
|
||||
38
frontend/src/components/ui/PreviewMetaOverlay.tsx
Normal file
38
frontend/src/components/ui/PreviewMetaOverlay.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
// frontend\src\components\ui\PreviewMetaOverlay.tsx
|
||||
|
||||
'use client'
|
||||
|
||||
type PreviewMetaOverlayProps = {
|
||||
dur: string
|
||||
resLabel?: string
|
||||
resObj?: { w: number; h: number } | null
|
||||
size: string
|
||||
hidden?: boolean
|
||||
}
|
||||
|
||||
export default function PreviewMetaOverlay({
|
||||
dur,
|
||||
resLabel,
|
||||
resObj,
|
||||
size,
|
||||
hidden = false,
|
||||
}: PreviewMetaOverlayProps) {
|
||||
if (hidden) return null
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-2 z-10 transition-opacity duration-150 group-hover/thumb:opacity-0 group-focus-within/thumb:opacity-0">
|
||||
<div
|
||||
className="absolute right-2 bottom-0 flex items-center gap-1.5 text-right text-[11px] font-semibold leading-none text-white [text-shadow:_0_1px_0_rgba(0,0,0,0.95),_1px_0_0_rgba(0,0,0,0.95),_-1px_0_0_rgba(0,0,0,0.95),_0_-1px_0_rgba(0,0,0,0.95),_1px_1px_0_rgba(0,0,0,0.8),_-1px_1px_0_rgba(0,0,0,0.8),_1px_-1px_0_rgba(0,0,0,0.8),_-1px_-1px_0_rgba(0,0,0,0.8)]"
|
||||
title={[dur, resObj ? `${resObj.w}×${resObj.h}` : resLabel || '', size].filter(Boolean).join(' • ')}
|
||||
>
|
||||
<span>{dur}</span>
|
||||
{resLabel ? <span aria-hidden="true">•</span> : null}
|
||||
{resLabel ? <span>{resLabel}</span> : null}
|
||||
<span aria-hidden="true">•</span>
|
||||
<span>{size}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type { PreviewMetaOverlayProps }
|
||||
37
frontend/src/components/ui/PreviewRatingOverlay.tsx
Normal file
37
frontend/src/components/ui/PreviewRatingOverlay.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
// frontend\src\components\ui\PreviewRatingOverlay.tsx
|
||||
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { PREVIEW_SCRUBBER_HEIGHT_PX } from './PreviewScrubber'
|
||||
|
||||
const PREVIEW_RATING_BASE_BOTTOM_PX = 7
|
||||
|
||||
type PreviewRatingOverlayProps = {
|
||||
hidden?: boolean
|
||||
children?: ReactNode
|
||||
raised?: boolean
|
||||
}
|
||||
|
||||
export default function PreviewRatingOverlay({
|
||||
hidden = false,
|
||||
children,
|
||||
raised = false,
|
||||
}: PreviewRatingOverlayProps) {
|
||||
if (hidden || !children) return null
|
||||
|
||||
const bottomPx = raised
|
||||
? PREVIEW_RATING_BASE_BOTTOM_PX + PREVIEW_SCRUBBER_HEIGHT_PX
|
||||
: PREVIEW_RATING_BASE_BOTTOM_PX
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute left-1.5 z-40 transition-[bottom] duration-150"
|
||||
style={{ bottom: `${bottomPx}px` }}
|
||||
>
|
||||
<div className="pointer-events-auto flex items-end">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -4,6 +4,8 @@
|
||||
|
||||
import { useEffect, useRef, useCallback, type PointerEvent, type MouseEvent } from 'react'
|
||||
|
||||
export const PREVIEW_SCRUBBER_HEIGHT_PX = 28
|
||||
|
||||
type Props = {
|
||||
imageCount: number
|
||||
activeIndex?: number
|
||||
@ -146,7 +148,6 @@ export default function PreviewScrubber({
|
||||
ref={rootRef}
|
||||
className={[
|
||||
'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',
|
||||
@ -179,7 +180,7 @@ export default function PreviewScrubber({
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
|
||||
<div
|
||||
className={[
|
||||
'pointer-events-none absolute bottom-[19px] z-10',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -2,35 +2,13 @@
|
||||
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import Button from './Button'
|
||||
import { subscribeSSE } from '../../lib/sseSingleton'
|
||||
|
||||
type TaskState = {
|
||||
running: boolean
|
||||
total: number
|
||||
done: number
|
||||
generatedThumbs: number
|
||||
generatedPreviews: number
|
||||
skipped: number
|
||||
startedAt?: string
|
||||
finishedAt?: string
|
||||
error?: string
|
||||
currentFile?: string
|
||||
}
|
||||
|
||||
type Progress = { done: number; total: number; currentFile?: string }
|
||||
|
||||
type Props = {
|
||||
/** API-Endpunkte (optional, wenn onTrigger verwendet wird) */
|
||||
startUrl?: string
|
||||
stopUrl?: string
|
||||
sseUrl?: string
|
||||
onTrigger?: () => Promise<any> | any
|
||||
|
||||
/** Optional: lokaler Trigger statt API/SSE */
|
||||
onTrigger?: () => Promise<void> | void
|
||||
|
||||
/** UI-Texte */
|
||||
title?: string
|
||||
description?: string
|
||||
startLabel?: string
|
||||
@ -38,13 +16,7 @@ type Props = {
|
||||
disabled?: boolean
|
||||
busy?: boolean
|
||||
|
||||
/** Callback-Hooks (nur für API/SSE-Variante relevant) */
|
||||
onFinished?: () => void
|
||||
onStart?: (ac: AbortController) => void
|
||||
onQueued?: () => void
|
||||
onProgress?: (p: Progress) => void
|
||||
onDone?: () => void
|
||||
onCancelled?: () => void
|
||||
onCreated?: (job: any) => void
|
||||
onError?: (message: string) => void
|
||||
}
|
||||
|
||||
@ -75,192 +47,41 @@ async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
|
||||
export default function Task({
|
||||
startUrl,
|
||||
stopUrl,
|
||||
sseUrl,
|
||||
onTrigger,
|
||||
title = 'Task',
|
||||
description = 'Startet eine Hintergrundaufgabe. Fortschritt & Abbrechen oben in der Taskliste.',
|
||||
description = 'Startet eine Hintergrundaufgabe.',
|
||||
startLabel = 'Start',
|
||||
startingLabel = 'Starte…',
|
||||
disabled = false,
|
||||
busy = false,
|
||||
onFinished,
|
||||
onStart,
|
||||
onQueued,
|
||||
onProgress,
|
||||
onDone,
|
||||
onCancelled,
|
||||
onCreated,
|
||||
onError,
|
||||
}: Props) {
|
||||
const [state, setState] = useState<TaskState | null>(null)
|
||||
const [starting, setStarting] = useState(false)
|
||||
const [startError, setStartError] = useState<string | null>(null)
|
||||
|
||||
// Plumbing (für Abbrechen über TaskList)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const armedRef = useRef(false) // onStart nur 1× pro Lauf feuern
|
||||
const cancelledRef = useRef(false)
|
||||
const stopInFlightRef = useRef(false)
|
||||
const prevRunningRef = useRef(false)
|
||||
const lastErrorRef = useRef('')
|
||||
|
||||
const onProgressRef = useRef<Props['onProgress']>(onProgress)
|
||||
const onErrorRef = useRef<Props['onError']>(onError)
|
||||
|
||||
useEffect(() => {
|
||||
onProgressRef.current = onProgress
|
||||
}, [onProgress])
|
||||
|
||||
useEffect(() => {
|
||||
onErrorRef.current = onError
|
||||
}, [onError])
|
||||
|
||||
async function stopInternal() {
|
||||
if (!stopUrl) return
|
||||
if (stopInFlightRef.current) return
|
||||
stopInFlightRef.current = true
|
||||
try {
|
||||
await fetch(stopUrl, { method: 'DELETE', cache: 'no-store' as any })
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
stopInFlightRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
function ensureControllerCreated() {
|
||||
if (abortRef.current) return abortRef.current
|
||||
|
||||
const ac = new AbortController()
|
||||
abortRef.current = ac
|
||||
|
||||
const onAbort = () => {
|
||||
cancelledRef.current = true
|
||||
void stopInternal()
|
||||
}
|
||||
ac.signal.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
return ac
|
||||
}
|
||||
|
||||
function armTaskList(ac: AbortController) {
|
||||
if (armedRef.current) return
|
||||
armedRef.current = true
|
||||
onStart?.(ac)
|
||||
}
|
||||
|
||||
// Detect finish (running -> false)
|
||||
useEffect(() => {
|
||||
const prev = prevRunningRef.current
|
||||
const cur = Boolean(state?.running)
|
||||
prevRunningRef.current = cur
|
||||
|
||||
if (prev && !cur) {
|
||||
const errText = String(state?.error ?? '').trim()
|
||||
const errTextNorm = errText.toLowerCase()
|
||||
|
||||
// Reset pro Run
|
||||
abortRef.current = null
|
||||
armedRef.current = false
|
||||
|
||||
if (cancelledRef.current || errTextNorm === 'abgebrochen') {
|
||||
cancelledRef.current = false
|
||||
onCancelled?.()
|
||||
} else if (errText) {
|
||||
// Fehlerfälle werden über onError schon gemeldet
|
||||
} else {
|
||||
onDone?.()
|
||||
}
|
||||
|
||||
onFinished?.()
|
||||
}
|
||||
}, [state?.running, state?.error, onFinished, onDone, onCancelled])
|
||||
|
||||
// SSE: State + Progress nur nach oben (TaskList), kein UI hier
|
||||
useEffect(() => {
|
||||
if (!sseUrl) return
|
||||
|
||||
const unsub = subscribeSSE<TaskState>(sseUrl, 'state', (st) => {
|
||||
setState(st)
|
||||
|
||||
if (st?.running) {
|
||||
const ac = ensureControllerCreated()
|
||||
armTaskList(ac)
|
||||
onProgressRef.current?.({
|
||||
done: st?.done ?? 0,
|
||||
total: st?.total ?? 0,
|
||||
currentFile: st?.currentFile ?? '',
|
||||
})
|
||||
}
|
||||
|
||||
const errText = String(st?.error ?? '').trim()
|
||||
const errTextNorm = errText.toLowerCase()
|
||||
|
||||
// Abbruch ist kein "Fehler"-Event für die UI
|
||||
if (errText && errTextNorm !== 'abgebrochen' && errText !== lastErrorRef.current) {
|
||||
lastErrorRef.current = errText
|
||||
onErrorRef.current?.(errText)
|
||||
}
|
||||
})
|
||||
|
||||
return () => unsub()
|
||||
}, [sseUrl])
|
||||
|
||||
async function start() {
|
||||
if (busy) return
|
||||
if (state?.running) return
|
||||
if (starting) return
|
||||
|
||||
setStartError(null)
|
||||
setStarting(true)
|
||||
cancelledRef.current = false
|
||||
lastErrorRef.current = ''
|
||||
|
||||
onQueued?.()
|
||||
|
||||
// 1) Lokaler Task-Modus
|
||||
if (onTrigger) {
|
||||
try {
|
||||
await onTrigger()
|
||||
} catch (e: any) {
|
||||
const msg = e?.message ?? String(e)
|
||||
setStartError(msg)
|
||||
onError?.(msg)
|
||||
} finally {
|
||||
setStarting(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 2) API/SSE-Task-Modus
|
||||
if (!startUrl) {
|
||||
const msg = 'Task ist nicht konfiguriert (startUrl fehlt).'
|
||||
setStartError(msg)
|
||||
onError?.(msg)
|
||||
setStarting(false)
|
||||
return
|
||||
}
|
||||
|
||||
const ac = ensureControllerCreated()
|
||||
|
||||
try {
|
||||
const st = await fetchJSON<TaskState & { queued?: boolean }>(startUrl, { method: 'POST' })
|
||||
setState(st)
|
||||
let created: any = null
|
||||
|
||||
// Nur wenn der Task wirklich schon läuft
|
||||
if (st?.running) {
|
||||
armTaskList(ac)
|
||||
if (onTrigger) {
|
||||
created = await onTrigger()
|
||||
} else {
|
||||
if (!startUrl) {
|
||||
throw new Error('Task ist nicht konfiguriert (startUrl fehlt).')
|
||||
}
|
||||
|
||||
onProgress?.({
|
||||
done: st?.done ?? 0,
|
||||
total: st?.total ?? 0,
|
||||
currentFile: st?.currentFile ?? '',
|
||||
})
|
||||
created = await fetchJSON<any>(startUrl, { method: 'POST' })
|
||||
}
|
||||
} catch (e: any) {
|
||||
abortRef.current = null
|
||||
armedRef.current = false
|
||||
|
||||
onCreated?.(created)
|
||||
} catch (e: any) {
|
||||
const msg = e?.message ?? String(e)
|
||||
setStartError(msg)
|
||||
onError?.(msg)
|
||||
@ -283,7 +104,11 @@ export default function Task({
|
||||
</div>
|
||||
|
||||
<div className="shrink-0">
|
||||
<Button variant="primary" onClick={start} disabled={disabled || starting}>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={start}
|
||||
disabled={disabled || starting}
|
||||
>
|
||||
{starting ? startingLabel : startLabel}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -5,6 +5,20 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
|
||||
export const DEFAULT_SPRITE_STEP_SECONDS = 5
|
||||
export const DEFAULT_PREVIEW_SPRITE_COLS = 12
|
||||
export const DEFAULT_PREVIEW_SPRITE_ROWS = 10
|
||||
export const DEFAULT_PREVIEW_SPRITE_COUNT =
|
||||
DEFAULT_PREVIEW_SPRITE_COLS * DEFAULT_PREVIEW_SPRITE_ROWS
|
||||
|
||||
function fixedPreviewSpriteGrid(count: number): [number, number] {
|
||||
if (count <= 1) return [1, 1]
|
||||
|
||||
// Legacy-Fall, falls alte Sprites noch 80 Frames hatten
|
||||
if (count === 80) return [10, 8]
|
||||
|
||||
// Neue feste Sprite-Layout-Logik aus dem Backend
|
||||
return [DEFAULT_PREVIEW_SPRITE_COLS, DEFAULT_PREVIEW_SPRITE_ROWS]
|
||||
}
|
||||
|
||||
export function firstNonEmptyString(...values: unknown[]): string | undefined {
|
||||
for (const v of values) {
|
||||
@ -130,8 +144,7 @@ export function readPreviewSpriteInfo(
|
||||
? Math.max(0, Math.floor(countRaw))
|
||||
: 0
|
||||
|
||||
const [inferredCols, inferredRows] =
|
||||
count > 1 ? chooseSpriteGrid(count) : [0, 0]
|
||||
const [fallbackCols, fallbackRows] = fixedPreviewSpriteGrid(count)
|
||||
|
||||
const explicitCols = Number(colsRaw)
|
||||
const explicitRows = Number(rowsRaw)
|
||||
@ -144,8 +157,8 @@ export function readPreviewSpriteInfo(
|
||||
!(count > 1 && explicitCols === 1 && explicitRows === 1) &&
|
||||
(count <= 1 || explicitCols * explicitRows >= count)
|
||||
|
||||
const cols = explicitGridLooksValid ? Math.floor(explicitCols) : inferredCols
|
||||
const rows = explicitGridLooksValid ? Math.floor(explicitRows) : inferredRows
|
||||
const cols = explicitGridLooksValid ? Math.floor(explicitCols) : fallbackCols
|
||||
const rows = explicitGridLooksValid ? Math.floor(explicitRows) : fallbackRows
|
||||
|
||||
const version =
|
||||
(typeof meta?.updatedAtUnix === 'number' && Number.isFinite(meta.updatedAtUnix)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user