improved rating and added small favorite download protection
This commit is contained in:
parent
68013e7b42
commit
590b113190
1
.gitignore
vendored
1
.gitignore
vendored
@ -3,3 +3,4 @@ backend/generated
|
|||||||
backend/nsfwapp.exe
|
backend/nsfwapp.exe
|
||||||
backend/web/dist
|
backend/web/dist
|
||||||
backend/recorder_settings.json
|
backend/recorder_settings.json
|
||||||
|
backend/data/pending-autostart/__global__.json
|
||||||
|
|||||||
@ -44,27 +44,15 @@ type analyzeVideoResp struct {
|
|||||||
Error string `json:"error,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 {
|
type spriteFrameCandidate struct {
|
||||||
Index int
|
Index int
|
||||||
Time float64
|
Time float64
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
nsfwThresholdModerate = 0.35
|
analyzeSegmentMergeGapSeconds = 8.0
|
||||||
nsfwThresholdStrong = 0.60
|
nsfwThresholdModerate = 0.35
|
||||||
|
nsfwThresholdStrong = 0.60
|
||||||
)
|
)
|
||||||
|
|
||||||
var autoSelectedAILabels = map[string]struct{}{
|
var autoSelectedAILabels = map[string]struct{}{
|
||||||
@ -633,8 +621,9 @@ func mergeAnalyzeHits(in []analyzeHit) []analyzeHit {
|
|||||||
// Direkt aufeinanderfolgende Treffer mit gleichem Label immer zusammenfassen.
|
// Direkt aufeinanderfolgende Treffer mit gleichem Label immer zusammenfassen.
|
||||||
// Sobald ein anderes Label dazwischen liegt, wird automatisch nicht gemergt.
|
// Sobald ein anderes Label dazwischen liegt, wird automatisch nicht gemergt.
|
||||||
sameLabel := strings.EqualFold(cur.Label, n.Label)
|
sameLabel := strings.EqualFold(cur.Label, n.Label)
|
||||||
|
gap := n.Start - cur.End
|
||||||
|
|
||||||
if sameLabel {
|
if sameLabel && gap <= analyzeSegmentMergeGapSeconds {
|
||||||
if n.Start < cur.Start {
|
if n.Start < cur.Start {
|
||||||
cur.Start = n.Start
|
cur.Start = n.Start
|
||||||
}
|
}
|
||||||
@ -656,119 +645,116 @@ func mergeAnalyzeHits(in []analyzeHit) []analyzeHit {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func nsfwSegmentSeverityWeight(label string) float64 {
|
func inferAnalyzePointSpanSeconds(hits []analyzeHit, duration float64) float64 {
|
||||||
switch strings.ToLower(strings.TrimSpace(label)) {
|
const fallback = 3.0
|
||||||
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 {
|
if len(hits) < 2 {
|
||||||
switch {
|
return fallback
|
||||||
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 {
|
times := make([]float64, 0, len(hits))
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
videoMinutes := math.Max(durationSec/60.0, 0.25)
|
for _, h := range hits {
|
||||||
|
t := h.Time
|
||||||
|
|
||||||
var totalFlagged float64
|
if t < 0 {
|
||||||
var totalWeighted float64
|
if h.Start >= 0 {
|
||||||
var longest float64
|
t = h.Start
|
||||||
var confSum float64
|
} else if h.End >= 0 {
|
||||||
var n int
|
t = h.End
|
||||||
|
}
|
||||||
for _, s := range segments {
|
|
||||||
segDur := s.DurationSeconds
|
|
||||||
if segDur <= 0 {
|
|
||||||
segDur = s.EndSeconds - s.StartSeconds
|
|
||||||
}
|
}
|
||||||
if segDur <= 0 {
|
|
||||||
|
if t < 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
sev := nsfwSegmentSeverityWeight(s.Label)
|
if duration > 0 {
|
||||||
conf := clamp01(s.Score)
|
t = math.Max(0, math.Min(t, duration))
|
||||||
|
}
|
||||||
|
|
||||||
// Confidence beeinflusst, dominiert aber nicht.
|
times = append(times, t)
|
||||||
weight := sev * (0.65 + 0.35*conf)
|
}
|
||||||
|
|
||||||
totalFlagged += segDur
|
if len(times) < 2 {
|
||||||
totalWeighted += segDur * weight
|
return fallback
|
||||||
confSum += conf
|
}
|
||||||
n++
|
|
||||||
|
|
||||||
if segDur > longest {
|
sort.Float64s(times)
|
||||||
longest = segDur
|
|
||||||
|
gaps := make([]float64, 0, len(times)-1)
|
||||||
|
prev := times[0]
|
||||||
|
|
||||||
|
for _, t := range times[1:] {
|
||||||
|
gap := t - prev
|
||||||
|
if gap > 0.05 {
|
||||||
|
gaps = append(gaps, gap)
|
||||||
|
prev = t
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if n == 0 {
|
if len(gaps) == 0 {
|
||||||
return r
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
coverageRatio := clamp01(totalFlagged / durationSec)
|
sort.Float64s(gaps)
|
||||||
weightedCoverageRatio := clamp01(totalWeighted / durationSec)
|
|
||||||
segmentsPerMinute := float64(n) / videoMinutes
|
|
||||||
avgConfidence := confSum / float64(n)
|
|
||||||
|
|
||||||
// Normalisierung / Sättigung:
|
median := gaps[len(gaps)/2]
|
||||||
// - 20% gewichtete Abdeckung => voll
|
if len(gaps)%2 == 0 {
|
||||||
// - 4 Segmente pro Minute => voll
|
median = (gaps[len(gaps)/2-1] + gaps[len(gaps)/2]) / 2
|
||||||
// - 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 +
|
// Ein einzelner Frame repräsentiert ungefähr seinen Sample-Abstand,
|
||||||
0.20*frequencyNorm +
|
// aber wir deckeln, damit Sparse-Hits nicht riesig werden.
|
||||||
0.15*longestNorm +
|
span := median * 0.90
|
||||||
0.10*confNorm
|
|
||||||
|
|
||||||
score := math.Round(clamp01(raw)*1000) / 10 // 0..100 mit 1 Nachkommastelle
|
if span < 2 {
|
||||||
stars := starsFromNSFWScore(score)
|
span = 2
|
||||||
|
}
|
||||||
|
if span > 12 {
|
||||||
|
span = 12
|
||||||
|
}
|
||||||
|
|
||||||
r.Score = score
|
return span
|
||||||
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 expandAnalyzePointToSpan(t, span, duration float64) (float64, float64) {
|
||||||
|
if span <= 0 {
|
||||||
|
span = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
if t < 0 {
|
||||||
|
t = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if duration > 0 {
|
||||||
|
t = math.Max(0, math.Min(t, duration))
|
||||||
|
}
|
||||||
|
|
||||||
|
half := span / 2
|
||||||
|
|
||||||
|
start := t - half
|
||||||
|
end := t + half
|
||||||
|
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
if duration > 0 && end > duration {
|
||||||
|
end = duration
|
||||||
|
}
|
||||||
|
|
||||||
|
if end <= start {
|
||||||
|
if duration > 0 {
|
||||||
|
end = math.Min(duration, start+math.Max(1, span))
|
||||||
|
if end <= start {
|
||||||
|
start = math.Max(0, end-math.Max(1, span))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
end = start + math.Max(1, span)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return start, end
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegmentMeta {
|
func buildSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegmentMeta {
|
||||||
@ -776,6 +762,8 @@ func buildSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegme
|
|||||||
return []aiSegmentMeta{}
|
return []aiSegmentMeta{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pointSpan := inferAnalyzePointSpanSeconds(hits, duration)
|
||||||
|
|
||||||
out := make([]aiSegmentMeta, 0, len(hits))
|
out := make([]aiSegmentMeta, 0, len(hits))
|
||||||
|
|
||||||
for _, hit := range hits {
|
for _, hit := range hits {
|
||||||
@ -797,6 +785,7 @@ func buildSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegme
|
|||||||
end = hit.Time
|
end = hit.Time
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if start > end {
|
if start > end {
|
||||||
start, end = end, start
|
start, end = end, start
|
||||||
}
|
}
|
||||||
@ -804,6 +793,18 @@ func buildSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegme
|
|||||||
start = math.Max(0, math.Min(start, duration))
|
start = math.Max(0, math.Min(start, duration))
|
||||||
end = math.Max(0, math.Min(end, duration))
|
end = math.Max(0, math.Min(end, duration))
|
||||||
|
|
||||||
|
// Wichtig:
|
||||||
|
// Einzelne AI-Treffer sind oft Punkt-Treffer: Start == End.
|
||||||
|
// Für Segmente und Rating brauchen sie aber eine kleine Dauer.
|
||||||
|
if end <= start {
|
||||||
|
marker := hit.Time
|
||||||
|
if marker < 0 {
|
||||||
|
marker = start
|
||||||
|
}
|
||||||
|
|
||||||
|
start, end = expandAnalyzePointToSpan(marker, pointSpan, duration)
|
||||||
|
}
|
||||||
|
|
||||||
if end <= start {
|
if end <= start {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@ -838,10 +839,12 @@ func buildSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegme
|
|||||||
for i := 1; i < len(out); i++ {
|
for i := 1; i < len(out); i++ {
|
||||||
n := out[i]
|
n := out[i]
|
||||||
|
|
||||||
// Direkt aufeinanderfolgende Segmente mit gleichem Label immer mergen,
|
gap := n.StartSeconds - cur.EndSeconds
|
||||||
// unabhängig von der Lücke. Sobald ein anderes Label dazwischen liegt,
|
if gap < 0 {
|
||||||
// wird automatisch nicht gemergt, weil wir nur mit dem direkten Nachfolger arbeiten.
|
gap = 0
|
||||||
if strings.EqualFold(cur.Label, n.Label) {
|
}
|
||||||
|
|
||||||
|
if strings.EqualFold(cur.Label, n.Label) && gap <= analyzeSegmentMergeGapSeconds {
|
||||||
if n.StartSeconds < cur.StartSeconds {
|
if n.StartSeconds < cur.StartSeconds {
|
||||||
cur.StartSeconds = n.StartSeconds
|
cur.StartSeconds = n.StartSeconds
|
||||||
}
|
}
|
||||||
|
|||||||
@ -389,8 +389,6 @@ var startedAtFromFilenameRe = regexp.MustCompile(
|
|||||||
)
|
)
|
||||||
|
|
||||||
func shouldAutoDeleteSmallDownload(filePath string) (bool, int64, int64) {
|
func shouldAutoDeleteSmallDownload(filePath string) (bool, int64, int64) {
|
||||||
// returns: (shouldDelete, sizeBytes, thresholdBytes)
|
|
||||||
|
|
||||||
s := getSettings()
|
s := getSettings()
|
||||||
if !s.AutoDeleteSmallDownloads {
|
if !s.AutoDeleteSmallDownloads {
|
||||||
return false, 0, 0
|
return false, 0, 0
|
||||||
@ -406,7 +404,6 @@ func shouldAutoDeleteSmallDownload(filePath string) (bool, int64, int64) {
|
|||||||
return false, 0, 0
|
return false, 0, 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// relativ -> absolut versuchen (best effort)
|
|
||||||
if !filepath.IsAbs(p) {
|
if !filepath.IsAbs(p) {
|
||||||
if abs, err := resolvePathRelativeToApp(p); err == nil && strings.TrimSpace(abs) != "" {
|
if abs, err := resolvePathRelativeToApp(p); err == nil && strings.TrimSpace(abs) != "" {
|
||||||
p = abs
|
p = abs
|
||||||
@ -420,9 +417,15 @@ func shouldAutoDeleteSmallDownload(filePath string) (bool, int64, int64) {
|
|||||||
|
|
||||||
size := fi.Size()
|
size := fi.Size()
|
||||||
thr := int64(mb) * 1024 * 1024
|
thr := int64(mb) * 1024 * 1024
|
||||||
|
|
||||||
if size > 0 && size < thr {
|
if size > 0 && size < thr {
|
||||||
|
if isFavoriteProtectedDownload(p, "") {
|
||||||
|
return false, size, thr
|
||||||
|
}
|
||||||
|
|
||||||
return true, size, thr
|
return true, size, thr
|
||||||
}
|
}
|
||||||
|
|
||||||
return false, size, thr
|
return false, size, thr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -293,6 +293,46 @@ LIMIT 1;
|
|||||||
return &m, true
|
return &m, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ModelStore) IsFavoriteModel(host string, modelKey string) bool {
|
||||||
|
if s == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if err := s.ensureInit(); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
host = canonicalHost(host)
|
||||||
|
modelKey = strings.TrimSpace(modelKey)
|
||||||
|
if modelKey == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var ok bool
|
||||||
|
|
||||||
|
// host ist optional:
|
||||||
|
// - Wenn host gesetzt ist: exakter Host oder alte Legacy-Einträge ohne Host schützen.
|
||||||
|
// - Wenn host leer ist: alle Hosts nach diesem model_key prüfen.
|
||||||
|
err := s.db.QueryRow(`
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM models
|
||||||
|
WHERE lower(trim(model_key)) = lower(trim($1))
|
||||||
|
AND COALESCE(favorite, false) = true
|
||||||
|
AND (
|
||||||
|
trim($2) = ''
|
||||||
|
OR lower(trim(COALESCE(host, ''))) = lower(trim($2))
|
||||||
|
OR trim(COALESCE(host, '')) = ''
|
||||||
|
)
|
||||||
|
);
|
||||||
|
`, modelKey, host).Scan(&ok)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
func canonicalHost(host string) string {
|
func canonicalHost(host string) string {
|
||||||
h := strings.ToLower(strings.TrimSpace(host))
|
h := strings.ToLower(strings.TrimSpace(host))
|
||||||
h = strings.TrimPrefix(h, "www.")
|
h = strings.TrimPrefix(h, "www.")
|
||||||
|
|||||||
186
backend/rating.go
Normal file
186
backend/rating.go
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
// backend\rating.go
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ratingClamp01(v float64) float64 {
|
||||||
|
if v < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if v > 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func ratingRound(v float64, places int) float64 {
|
||||||
|
p := math.Pow(10, float64(places))
|
||||||
|
return math.Round(v*p) / p
|
||||||
|
}
|
||||||
|
|
||||||
|
func ratingSmoothStep(v float64) float64 {
|
||||||
|
v = ratingClamp01(v)
|
||||||
|
return v * v * (3 - 2*v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ratingSoftCap(value, knee float64) float64 {
|
||||||
|
if value <= 0 || knee <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return value / (value + knee)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ratingEffectiveDurationSeconds(seconds float64) float64 {
|
||||||
|
if seconds <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lange Segmente zählen weiter, aber mit abnehmendem Zusatznutzen.
|
||||||
|
// Dadurch kippen falsche oder zu breite Merges nicht sofort auf 5 Sterne.
|
||||||
|
const kneeSeconds = 24.0
|
||||||
|
return kneeSeconds * math.Log1p(seconds/kneeSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ratingConfidenceWeight(conf float64) float64 {
|
||||||
|
conf = ratingClamp01(conf)
|
||||||
|
|
||||||
|
// Unterhalb ~0.30 soll Confidence kaum boosten.
|
||||||
|
// Oberhalb ~0.95 ist praktisch gesättigt.
|
||||||
|
n := ratingSmoothStep((conf - 0.30) / 0.65)
|
||||||
|
|
||||||
|
return 0.60 + 0.40*n
|
||||||
|
}
|
||||||
|
|
||||||
|
func starsFromNSFWScore(score float64) int {
|
||||||
|
switch {
|
||||||
|
case score < 15:
|
||||||
|
return 1
|
||||||
|
case score < 35:
|
||||||
|
return 2
|
||||||
|
case score < 58:
|
||||||
|
return 3
|
||||||
|
case score < 78:
|
||||||
|
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 totalEffectiveWeighted 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 := ratingClamp01(s.Score)
|
||||||
|
weight := sev * ratingConfidenceWeight(conf)
|
||||||
|
|
||||||
|
totalFlagged += segDur
|
||||||
|
totalWeighted += segDur * weight
|
||||||
|
totalEffectiveWeighted += ratingEffectiveDurationSeconds(segDur) * weight
|
||||||
|
|
||||||
|
confSum += conf
|
||||||
|
n++
|
||||||
|
|
||||||
|
if segDur > longest {
|
||||||
|
longest = segDur
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if n == 0 {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
coverageRatio := ratingClamp01(totalFlagged / durationSec)
|
||||||
|
weightedCoverageRatio := ratingClamp01(totalWeighted / durationSec)
|
||||||
|
segmentsPerMinute := float64(n) / videoMinutes
|
||||||
|
avgConfidence := confSum / float64(n)
|
||||||
|
effectiveWeightedSecondsPerMinute := totalEffectiveWeighted / videoMinutes
|
||||||
|
|
||||||
|
// Weichere Normalisierung statt harter Sättigung.
|
||||||
|
// Das reduziert 1/5-Ausreißer und verteilt mehr Fälle auf 2–4 Sterne.
|
||||||
|
densityNorm := ratingSoftCap(effectiveWeightedSecondsPerMinute, 8.0)
|
||||||
|
coverageNorm := ratingSoftCap(weightedCoverageRatio, 0.22)
|
||||||
|
frequencyNorm := ratingSoftCap(segmentsPerMinute, 1.25)
|
||||||
|
longestNorm := ratingSoftCap(longest, 25.0)
|
||||||
|
confNorm := ratingSmoothStep((avgConfidence - 0.35) / 0.60)
|
||||||
|
|
||||||
|
raw :=
|
||||||
|
0.38*densityNorm +
|
||||||
|
0.30*coverageNorm +
|
||||||
|
0.12*frequencyNorm +
|
||||||
|
0.10*longestNorm +
|
||||||
|
0.10*confNorm
|
||||||
|
|
||||||
|
score := ratingRound(ratingClamp01(raw)*100, 1)
|
||||||
|
|
||||||
|
r.Score = score
|
||||||
|
r.Stars = starsFromNSFWScore(score)
|
||||||
|
r.Segments = n
|
||||||
|
r.SegmentsPerMinute = ratingRound(segmentsPerMinute, 2)
|
||||||
|
r.FlaggedSeconds = ratingRound(totalFlagged, 2)
|
||||||
|
r.WeightedFlaggedSeconds = ratingRound(totalWeighted, 2)
|
||||||
|
r.CoverageRatio = ratingRound(coverageRatio, 4)
|
||||||
|
r.WeightedCoverageRatio = ratingRound(weightedCoverageRatio, 4)
|
||||||
|
r.LongestSegmentSeconds = ratingRound(longest, 2)
|
||||||
|
r.AvgConfidence = ratingRound(avgConfidence, 3)
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
@ -985,6 +985,48 @@ func ensureUsableRecordedOutput(job *RecordJob, target JobStatus, out string) bo
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func favoriteProtectionModelKeyForDownload(out string, sourceURL string) (host string, modelKey string) {
|
||||||
|
sourceURL = strings.TrimSpace(sourceURL)
|
||||||
|
|
||||||
|
if sourceURL != "" {
|
||||||
|
switch detectProvider(sourceURL) {
|
||||||
|
case "chaturbate":
|
||||||
|
host = "chaturbate.com"
|
||||||
|
modelKey = strings.TrimSpace(extractUsername(sourceURL))
|
||||||
|
case "mfc":
|
||||||
|
host = "myfreecams.com"
|
||||||
|
modelKey = strings.TrimSpace(extractMFCUsername(sourceURL))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(modelKey) == "" {
|
||||||
|
stem := strings.TrimSuffix(filepath.Base(strings.TrimSpace(out)), filepath.Ext(out))
|
||||||
|
stem = stripHotPrefix(stem)
|
||||||
|
modelKey = sanitizeModelKey(strings.TrimSpace(modelNameFromFilename(stem)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(host), strings.ToLower(strings.TrimSpace(modelKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
func isFavoriteProtectedDownload(out string, sourceURL string) bool {
|
||||||
|
s := getSettings()
|
||||||
|
if !s.AutoDeleteSmallDownloadsKeepFavorites {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
host, modelKey := favoriteProtectionModelKeyForDownload(out, sourceURL)
|
||||||
|
if modelKey == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
store := getModelStore()
|
||||||
|
if store == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return store.IsFavoriteModel(host, modelKey)
|
||||||
|
}
|
||||||
|
|
||||||
func maybeDeleteSmallRecordedOutput(job *RecordJob, out string) bool {
|
func maybeDeleteSmallRecordedOutput(job *RecordJob, out string) bool {
|
||||||
fi, err := os.Stat(out)
|
fi, err := os.Stat(out)
|
||||||
if err != nil || fi == nil || fi.IsDir() {
|
if err != nil || fi == nil || fi.IsDir() {
|
||||||
@ -1006,6 +1048,11 @@ func maybeDeleteSmallRecordedOutput(job *RecordJob, out string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isFavoriteProtectedDownload(out, job.SourceURL) {
|
||||||
|
fmt.Println("⭐ auto-delete skipped for favorite:", filepath.Base(out))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
base := filepath.Base(out)
|
base := filepath.Base(out)
|
||||||
id := stripHotPrefix(strings.TrimSuffix(base, filepath.Ext(base)))
|
id := stripHotPrefix(strings.TrimSuffix(base, filepath.Ext(base)))
|
||||||
|
|
||||||
|
|||||||
@ -29,9 +29,10 @@ type RecorderSettings struct {
|
|||||||
UseChaturbateAPI bool `json:"useChaturbateApi"`
|
UseChaturbateAPI bool `json:"useChaturbateApi"`
|
||||||
UseMyFreeCamsWatcher bool `json:"useMyFreeCamsWatcher"`
|
UseMyFreeCamsWatcher bool `json:"useMyFreeCamsWatcher"`
|
||||||
// Wenn aktiv, werden fertige Downloads automatisch gelöscht, wenn sie kleiner als der Grenzwert sind.
|
// Wenn aktiv, werden fertige Downloads automatisch gelöscht, wenn sie kleiner als der Grenzwert sind.
|
||||||
AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"`
|
AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"`
|
||||||
AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"`
|
AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"`
|
||||||
LowDiskPauseBelowGB int `json:"lowDiskPauseBelowGB"`
|
AutoDeleteSmallDownloadsKeepFavorites bool `json:"autoDeleteSmallDownloadsKeepFavorites"`
|
||||||
|
LowDiskPauseBelowGB int `json:"lowDiskPauseBelowGB"`
|
||||||
|
|
||||||
BlurPreviews bool `json:"blurPreviews"`
|
BlurPreviews bool `json:"blurPreviews"`
|
||||||
TeaserPlayback string `json:"teaserPlayback"` // still | hover | all
|
TeaserPlayback string `json:"teaserPlayback"` // still | hover | all
|
||||||
@ -63,11 +64,12 @@ var (
|
|||||||
EnableConcurrentDownloadsLimit: false,
|
EnableConcurrentDownloadsLimit: false,
|
||||||
MaxConcurrentDownloads: 20,
|
MaxConcurrentDownloads: 20,
|
||||||
|
|
||||||
UseChaturbateAPI: false,
|
UseChaturbateAPI: false,
|
||||||
UseMyFreeCamsWatcher: false,
|
UseMyFreeCamsWatcher: false,
|
||||||
AutoDeleteSmallDownloads: false,
|
AutoDeleteSmallDownloads: false,
|
||||||
AutoDeleteSmallDownloadsBelowMB: 50,
|
AutoDeleteSmallDownloadsBelowMB: 50,
|
||||||
LowDiskPauseBelowGB: 5,
|
AutoDeleteSmallDownloadsKeepFavorites: true,
|
||||||
|
LowDiskPauseBelowGB: 5,
|
||||||
|
|
||||||
BlurPreviews: false,
|
BlurPreviews: false,
|
||||||
TeaserPlayback: "hover",
|
TeaserPlayback: "hover",
|
||||||
@ -226,9 +228,10 @@ type RecorderSettingsPublic struct {
|
|||||||
UseChaturbateAPI bool `json:"useChaturbateApi"`
|
UseChaturbateAPI bool `json:"useChaturbateApi"`
|
||||||
UseMyFreeCamsWatcher bool `json:"useMyFreeCamsWatcher"`
|
UseMyFreeCamsWatcher bool `json:"useMyFreeCamsWatcher"`
|
||||||
|
|
||||||
AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"`
|
AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"`
|
||||||
AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"`
|
AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"`
|
||||||
LowDiskPauseBelowGB int `json:"lowDiskPauseBelowGB"`
|
AutoDeleteSmallDownloadsKeepFavorites bool `json:"autoDeleteSmallDownloadsKeepFavorites"`
|
||||||
|
LowDiskPauseBelowGB int `json:"lowDiskPauseBelowGB"`
|
||||||
|
|
||||||
BlurPreviews bool `json:"blurPreviews"`
|
BlurPreviews bool `json:"blurPreviews"`
|
||||||
TeaserPlayback string `json:"teaserPlayback"`
|
TeaserPlayback string `json:"teaserPlayback"`
|
||||||
@ -259,9 +262,10 @@ func toPublicSettings(s RecorderSettings) RecorderSettingsPublic {
|
|||||||
UseChaturbateAPI: s.UseChaturbateAPI,
|
UseChaturbateAPI: s.UseChaturbateAPI,
|
||||||
UseMyFreeCamsWatcher: s.UseMyFreeCamsWatcher,
|
UseMyFreeCamsWatcher: s.UseMyFreeCamsWatcher,
|
||||||
|
|
||||||
AutoDeleteSmallDownloads: s.AutoDeleteSmallDownloads,
|
AutoDeleteSmallDownloads: s.AutoDeleteSmallDownloads,
|
||||||
AutoDeleteSmallDownloadsBelowMB: s.AutoDeleteSmallDownloadsBelowMB,
|
AutoDeleteSmallDownloadsBelowMB: s.AutoDeleteSmallDownloadsBelowMB,
|
||||||
LowDiskPauseBelowGB: s.LowDiskPauseBelowGB,
|
AutoDeleteSmallDownloadsKeepFavorites: s.AutoDeleteSmallDownloadsKeepFavorites,
|
||||||
|
LowDiskPauseBelowGB: s.LowDiskPauseBelowGB,
|
||||||
|
|
||||||
BlurPreviews: s.BlurPreviews,
|
BlurPreviews: s.BlurPreviews,
|
||||||
TeaserPlayback: s.TeaserPlayback,
|
TeaserPlayback: s.TeaserPlayback,
|
||||||
@ -417,6 +421,7 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
next.AutoDeleteSmallDownloads = in.AutoDeleteSmallDownloads
|
next.AutoDeleteSmallDownloads = in.AutoDeleteSmallDownloads
|
||||||
next.AutoDeleteSmallDownloadsBelowMB = in.AutoDeleteSmallDownloadsBelowMB
|
next.AutoDeleteSmallDownloadsBelowMB = in.AutoDeleteSmallDownloadsBelowMB
|
||||||
|
next.AutoDeleteSmallDownloadsKeepFavorites = in.AutoDeleteSmallDownloadsKeepFavorites
|
||||||
next.LowDiskPauseBelowGB = in.LowDiskPauseBelowGB
|
next.LowDiskPauseBelowGB = in.LowDiskPauseBelowGB
|
||||||
|
|
||||||
next.BlurPreviews = in.BlurPreviews
|
next.BlurPreviews = in.BlurPreviews
|
||||||
|
|||||||
@ -461,6 +461,34 @@ func dirSizeBytes(root string) int64 {
|
|||||||
return total
|
return total
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func removeDirIfEmpty(dir string) (bool, error) {
|
||||||
|
dir = strings.TrimSpace(dir)
|
||||||
|
if dir == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(entries) > 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Remove(dir); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Läuft synchron und liefert Zahlen zurück (für /api/settings/cleanup Response).
|
// Läuft synchron und liefert Zahlen zurück (für /api/settings/cleanup Response).
|
||||||
func triggerGeneratedGarbageCollectorSync() generatedGCStats {
|
func triggerGeneratedGarbageCollectorSync() generatedGCStats {
|
||||||
// nur 1 GC gleichzeitig
|
// nur 1 GC gleichzeitig
|
||||||
@ -603,7 +631,9 @@ func runGeneratedGarbageCollector() generatedGCStats {
|
|||||||
collectLiveIDsFromTrash(filepath.Join(doneAbs, ".trash"), live)
|
collectLiveIDsFromTrash(filepath.Join(doneAbs, ".trash"), live)
|
||||||
|
|
||||||
// 1b) Auch aktuell laufende / nachbearbeitete Jobs als "live" behandeln.
|
// 1b) Auch aktuell laufende / nachbearbeitete Jobs als "live" behandeln.
|
||||||
// Sonst räumt der GC generated/meta/<id> weg, obwohl RecordStream dort noch schreibt.
|
// Zusätzlich separat merken, damit leere Ordner aktiver Jobs nicht gelöscht werden.
|
||||||
|
activeJobIDs := make(map[string]struct{}, 128)
|
||||||
|
|
||||||
jobsMu.RLock()
|
jobsMu.RLock()
|
||||||
for _, j := range jobs {
|
for _, j := range jobs {
|
||||||
if j == nil {
|
if j == nil {
|
||||||
@ -616,6 +646,7 @@ func runGeneratedGarbageCollector() generatedGCStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
live[id] = struct{}{}
|
live[id] = struct{}{}
|
||||||
|
activeJobIDs[id] = struct{}{}
|
||||||
}
|
}
|
||||||
jobsMu.RUnlock()
|
jobsMu.RUnlock()
|
||||||
|
|
||||||
@ -637,21 +668,38 @@ func runGeneratedGarbageCollector() generatedGCStats {
|
|||||||
if !e.IsDir() {
|
if !e.IsDir() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
id := strings.TrimSpace(e.Name())
|
id := strings.TrimSpace(e.Name())
|
||||||
if id == "" || strings.HasPrefix(id, ".") {
|
if id == "" || strings.HasPrefix(id, ".") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
checkedMeta++
|
checkedMeta++
|
||||||
|
|
||||||
|
orphanDir := filepath.Join(metaRoot, id)
|
||||||
|
|
||||||
|
// Leere generated/meta/<id>-Ordner immer entfernen,
|
||||||
|
// außer ein aktiver Job könnte gerade hinein schreiben.
|
||||||
|
if _, active := activeJobIDs[id]; !active {
|
||||||
|
if removedEmpty, err := removeDirIfEmpty(orphanDir); err == nil && removedEmpty {
|
||||||
|
removedMeta++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if _, ok := live[id]; ok {
|
if _, ok := live[id]; ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
orphanDir := filepath.Join(metaRoot, id)
|
|
||||||
orphanBytes := dirSizeBytes(orphanDir)
|
orphanBytes := dirSizeBytes(orphanDir)
|
||||||
|
|
||||||
removeGeneratedForID(id)
|
removeGeneratedForID(id)
|
||||||
|
|
||||||
|
// Falls removeGeneratedForID nur Dateien gelöscht hat, den nun leeren Ordner entfernen.
|
||||||
|
if removedEmpty, err := removeDirIfEmpty(orphanDir); err == nil && removedEmpty {
|
||||||
|
// ok
|
||||||
|
}
|
||||||
|
|
||||||
if _, err := os.Stat(orphanDir); os.IsNotExist(err) {
|
if _, err := os.Stat(orphanDir); os.IsNotExist(err) {
|
||||||
removedMeta++
|
removedMeta++
|
||||||
removedBytes += orphanBytes
|
removedBytes += orphanBytes
|
||||||
|
|||||||
@ -3949,7 +3949,7 @@ export default function App() {
|
|||||||
blurPreviews={Boolean(recSettings.blurPreviews)}
|
blurPreviews={Boolean(recSettings.blurPreviews)}
|
||||||
teaserPlayback={recSettings.teaserPlayback ?? 'hover'}
|
teaserPlayback={recSettings.teaserPlayback ?? 'hover'}
|
||||||
teaserAudio={Boolean(recSettings.teaserAudio)}
|
teaserAudio={Boolean(recSettings.teaserAudio)}
|
||||||
pauseTeasers={Boolean(detailsModelKey) || splitModalOpen}
|
pauseTeasers={Boolean(detailsModelKey) || splitModalOpen || Boolean(playerJob)}
|
||||||
assetNonce={assetNonce}
|
assetNonce={assetNonce}
|
||||||
sortMode={doneSort}
|
sortMode={doneSort}
|
||||||
onSortModeChange={(m) => {
|
onSortModeChange={(m) => {
|
||||||
|
|||||||
@ -1,279 +0,0 @@
|
|||||||
// 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,7 +59,7 @@ import {
|
|||||||
type FinishedSelectionStore,
|
type FinishedSelectionStore,
|
||||||
} from './finishedSelectionStore'
|
} from './finishedSelectionStore'
|
||||||
import { formatResolution, formatDuration, formatBytes } from './formatters'
|
import { formatResolution, formatDuration, formatBytes } from './formatters'
|
||||||
import FinishedDownloadRating from './FinishedDownloadRating'
|
import RatingOverlay from './RatingOverlay'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
jobs: RecordJob[]
|
jobs: RecordJob[]
|
||||||
@ -1614,8 +1614,7 @@ export default function FinishedDownloads({
|
|||||||
const LAST_UNDO_KEY = 'finishedDownloads_lastUndo_v1'
|
const LAST_UNDO_KEY = 'finishedDownloads_lastUndo_v1'
|
||||||
|
|
||||||
const requestedTeaserPlaybackMode: TeaserPlaybackMode = teaserPlayback ?? 'hover'
|
const requestedTeaserPlaybackMode: TeaserPlaybackMode = teaserPlayback ?? 'hover'
|
||||||
const teaserPlaybackMode: TeaserPlaybackMode =
|
const teaserPlaybackMode: TeaserPlaybackMode = requestedTeaserPlaybackMode
|
||||||
pauseTeasers ? 'still' : requestedTeaserPlaybackMode
|
|
||||||
|
|
||||||
const notify = useNotify()
|
const notify = useNotify()
|
||||||
|
|
||||||
@ -1631,7 +1630,6 @@ export default function FinishedDownloads({
|
|||||||
!canHover && teaserPlaybackMode === 'hover' ? 'still' : teaserPlaybackMode
|
!canHover && teaserPlaybackMode === 'hover' ? 'still' : teaserPlaybackMode
|
||||||
|
|
||||||
const mobileNeedsTapForAudio = isCoarsePointer || isTabletOrSmaller
|
const mobileNeedsTapForAudio = isCoarsePointer || isTabletOrSmaller
|
||||||
const previewMuted = !Boolean(teaserAudio)
|
|
||||||
|
|
||||||
const cardsMobileOffsetTopClass = 'mt-10'
|
const cardsMobileOffsetTopClass = 'mt-10'
|
||||||
const cardsMobileOffsetBottomClass = 'mb-2'
|
const cardsMobileOffsetBottomClass = 'mb-2'
|
||||||
@ -1805,22 +1803,39 @@ export default function FinishedDownloads({
|
|||||||
return renderDownloadIntegrityBadge((job as any)?.meta)
|
return renderDownloadIntegrityBadge((job as any)?.meta)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const openPlayerAndPauseTeasers = useCallback((job: RecordJob, startAtSec?: number) => {
|
||||||
|
onOpenPlayer(job, startAtSec)
|
||||||
|
}, [onOpenPlayer])
|
||||||
|
|
||||||
const renderRatingOverlay = useCallback((job: RecordJob) => {
|
const renderRatingOverlay = useCallback((job: RecordJob) => {
|
||||||
return (
|
return (
|
||||||
<FinishedDownloadRating
|
<RatingOverlay
|
||||||
metaRaw={(job as any)?.meta}
|
metaRaw={(job as any)?.meta}
|
||||||
variant="overlay"
|
variant="overlay"
|
||||||
|
onOpenAt={(seconds) => {
|
||||||
|
openPlayerAndPauseTeasers(job, seconds)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}, [])
|
}, [openPlayerAndPauseTeasers])
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// memoized derived data
|
// memoized derived data
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
const forcePreviewMuted =
|
const forcePreviewMuted =
|
||||||
mobileNeedsTapForAudio && teaserAudio
|
Boolean(pauseTeasers) ||
|
||||||
|
(mobileNeedsTapForAudio && teaserAudio
|
||||||
? !mobileTeaserAudioUnlocked
|
? !mobileTeaserAudioUnlocked
|
||||||
: false
|
: false)
|
||||||
|
|
||||||
|
const previewMuted = !Boolean(teaserAudio) || forcePreviewMuted
|
||||||
|
|
||||||
|
const audibleTeaserKey =
|
||||||
|
previewMuted
|
||||||
|
? null
|
||||||
|
: canHover
|
||||||
|
? hoverTeaserKey
|
||||||
|
: inlinePlay?.key ?? teaserKey
|
||||||
|
|
||||||
const deferredSearchQuery = useDeferredValue(searchQuery)
|
const deferredSearchQuery = useDeferredValue(searchQuery)
|
||||||
|
|
||||||
@ -1848,9 +1863,9 @@ export default function FinishedDownloads({
|
|||||||
mode: effectiveTeaserPlaybackMode,
|
mode: effectiveTeaserPlaybackMode,
|
||||||
activeKey: teaserKey,
|
activeKey: teaserKey,
|
||||||
hoverKey: hoverTeaserKey,
|
hoverKey: hoverTeaserKey,
|
||||||
audioEnabled: Boolean(teaserAudio),
|
audioEnabled: Boolean(teaserAudio) && !forcePreviewMuted && Boolean(audibleTeaserKey),
|
||||||
}),
|
}),
|
||||||
[effectiveTeaserPlaybackMode, teaserKey, hoverTeaserKey, teaserAudio]
|
[effectiveTeaserPlaybackMode, teaserKey, hoverTeaserKey, teaserAudio, forcePreviewMuted, audibleTeaserKey]
|
||||||
)
|
)
|
||||||
|
|
||||||
const modelTags = useMemo(() => {
|
const modelTags = useMemo(() => {
|
||||||
@ -2347,16 +2362,26 @@ export default function FinishedDownloads({
|
|||||||
const tryAutoplayInline = useCallback((domId: string) => {
|
const tryAutoplayInline = useCallback((domId: string) => {
|
||||||
const host = document.getElementById(domId)
|
const host = document.getElementById(domId)
|
||||||
const v = host?.querySelector('video') as HTMLVideoElement | null
|
const v = host?.querySelector('video') as HTMLVideoElement | null
|
||||||
if (!v) return false
|
if (!host || !v) return false
|
||||||
|
|
||||||
applyInlineVideoPolicy(v, { muted: previewMuted })
|
const teaserHost =
|
||||||
|
host.closest<HTMLElement>('[data-finished-hover-key]') ??
|
||||||
|
host.querySelector<HTMLElement>('[data-finished-hover-key]')
|
||||||
|
|
||||||
|
const key = teaserHost?.dataset.finishedHoverKey ?? null
|
||||||
|
const muted = !audibleTeaserKey || key !== audibleTeaserKey
|
||||||
|
|
||||||
|
applyInlineVideoPolicy(v, { muted })
|
||||||
|
v.defaultMuted = muted
|
||||||
|
v.muted = muted
|
||||||
|
|
||||||
const p = v.play?.()
|
const p = v.play?.()
|
||||||
if (p && typeof (p as any).catch === 'function') {
|
if (p && typeof (p as any).catch === 'function') {
|
||||||
(p as Promise<void>).catch(() => {})
|
;(p as Promise<void>).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}, [previewMuted])
|
}, [audibleTeaserKey])
|
||||||
|
|
||||||
const startInline = useCallback((key: string) => {
|
const startInline = useCallback((key: string) => {
|
||||||
setInlinePlay((prev) => (
|
setInlinePlay((prev) => (
|
||||||
@ -2382,7 +2407,11 @@ export default function FinishedDownloads({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
applyInlineVideoPolicy(v, { muted: previewMuted })
|
const muted = !audibleTeaserKey || key !== audibleTeaserKey
|
||||||
|
|
||||||
|
applyInlineVideoPolicy(v, { muted })
|
||||||
|
v.defaultMuted = muted
|
||||||
|
v.muted = muted
|
||||||
|
|
||||||
const applySeek = () => {
|
const applySeek = () => {
|
||||||
try {
|
try {
|
||||||
@ -2420,18 +2449,16 @@ export default function FinishedDownloads({
|
|||||||
}
|
}
|
||||||
|
|
||||||
requestAnimationFrame(() => trySeekAndPlay(8))
|
requestAnimationFrame(() => trySeekAndPlay(8))
|
||||||
}, [previewMuted])
|
}, [audibleTeaserKey])
|
||||||
|
|
||||||
const openPlayer = useCallback((job: RecordJob) => {
|
const openPlayer = useCallback((job: RecordJob) => {
|
||||||
setInlinePlay(null)
|
openPlayerAndPauseTeasers(job)
|
||||||
onOpenPlayer(job)
|
}, [openPlayerAndPauseTeasers])
|
||||||
}, [onOpenPlayer])
|
|
||||||
|
|
||||||
const openPlayerAt = useCallback((job: RecordJob, seconds: number) => {
|
const openPlayerAt = useCallback((job: RecordJob, seconds: number) => {
|
||||||
const s = Number.isFinite(seconds) && seconds >= 0 ? seconds : 0
|
const s = Number.isFinite(seconds) && seconds >= 0 ? seconds : 0
|
||||||
setInlinePlay(null)
|
openPlayerAndPauseTeasers(job, s)
|
||||||
onOpenPlayer(job, s)
|
}, [openPlayerAndPauseTeasers])
|
||||||
}, [onOpenPlayer])
|
|
||||||
|
|
||||||
const handleScrubberClickIndex = useCallback(
|
const handleScrubberClickIndex = useCallback(
|
||||||
(job: RecordJob, segmentIndex: number, segmentCount: number) => {
|
(job: RecordJob, segmentIndex: number, segmentCount: number) => {
|
||||||
@ -3548,12 +3575,17 @@ export default function FinishedDownloads({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pauseTeasers) return
|
for (const [key, host] of teaserHostsRef.current.entries()) {
|
||||||
|
const muted = !audibleTeaserKey || key !== audibleTeaserKey
|
||||||
|
const videos = host.querySelectorAll('video')
|
||||||
|
|
||||||
setTeaserKey(null)
|
videos.forEach((video) => {
|
||||||
setHoverTeaserKey(null)
|
applyInlineVideoPolicy(video, { muted })
|
||||||
setInlinePlay(null)
|
video.defaultMuted = muted
|
||||||
}, [pauseTeasers])
|
video.muted = muted
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [audibleTeaserKey, view, pageRows])
|
||||||
|
|
||||||
const refreshPostworkState = useCallback(async () => {
|
const refreshPostworkState = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@ -3774,13 +3806,73 @@ export default function FinishedDownloads({
|
|||||||
}, [refreshPostworkState])
|
}, [refreshPostworkState])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!canHover) {
|
||||||
|
pointerPosRef.current = null
|
||||||
|
setHoverTeaserKey(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let raf: number | null = null
|
||||||
|
|
||||||
|
const syncHoverFromPointer = () => {
|
||||||
|
raf = null
|
||||||
|
|
||||||
|
const pos = pointerPosRef.current
|
||||||
|
if (!pos) {
|
||||||
|
setHoverTeaserKey((prev) => (prev === null ? prev : null))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const el = document.elementFromPoint(pos.x, pos.y) as HTMLElement | null
|
||||||
|
const hoverHost = el?.closest<HTMLElement>('[data-finished-hover-key]') ?? null
|
||||||
|
const nextKey = hoverHost?.dataset.finishedHoverKey ?? null
|
||||||
|
|
||||||
|
setHoverTeaserKey((prev) => (prev === nextKey ? prev : nextKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleSync = () => {
|
||||||
|
if (raf != null) return
|
||||||
|
raf = requestAnimationFrame(syncHoverFromPointer)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearHover = () => {
|
||||||
|
pointerPosRef.current = null
|
||||||
|
setHoverTeaserKey((prev) => (prev === null ? prev : null))
|
||||||
|
}
|
||||||
|
|
||||||
const onPointerMove = (e: PointerEvent) => {
|
const onPointerMove = (e: PointerEvent) => {
|
||||||
pointerPosRef.current = { x: e.clientX, y: e.clientY }
|
pointerPosRef.current = { x: e.clientX, y: e.clientY }
|
||||||
|
scheduleSync()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPointerOut = (e: PointerEvent) => {
|
||||||
|
if (!e.relatedTarget) {
|
||||||
|
clearHover()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onVisibilityChange = () => {
|
||||||
|
if (document.visibilityState !== 'visible') {
|
||||||
|
clearHover()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('pointermove', onPointerMove, { passive: true })
|
window.addEventListener('pointermove', onPointerMove, { passive: true })
|
||||||
return () => window.removeEventListener('pointermove', onPointerMove)
|
window.addEventListener('pointerout', onPointerOut, { passive: true })
|
||||||
}, [])
|
window.addEventListener('blur', clearHover)
|
||||||
|
window.addEventListener('scroll', scheduleSync, true)
|
||||||
|
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (raf != null) cancelAnimationFrame(raf)
|
||||||
|
|
||||||
|
window.removeEventListener('pointermove', onPointerMove)
|
||||||
|
window.removeEventListener('pointerout', onPointerOut)
|
||||||
|
window.removeEventListener('blur', clearHover)
|
||||||
|
window.removeEventListener('scroll', scheduleSync, true)
|
||||||
|
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||||
|
}
|
||||||
|
}, [canHover])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
@ -4849,7 +4941,7 @@ export default function FinishedDownloads({
|
|||||||
sizeBytesOf={sizeBytesOf}
|
sizeBytesOf={sizeBytesOf}
|
||||||
formatBytes={formatBytes}
|
formatBytes={formatBytes}
|
||||||
lower={lowerTag}
|
lower={lowerTag}
|
||||||
onOpenPlayer={onOpenPlayer}
|
onOpenPlayer={openPlayerAndPauseTeasers}
|
||||||
openPlayer={openPlayer}
|
openPlayer={openPlayer}
|
||||||
onOpenPlayerAt={openPlayerAt}
|
onOpenPlayerAt={openPlayerAt}
|
||||||
handleScrubberClickIndex={handleScrubberClickIndex}
|
handleScrubberClickIndex={handleScrubberClickIndex}
|
||||||
@ -4921,7 +5013,7 @@ export default function FinishedDownloads({
|
|||||||
modelsByKey={modelsByKey}
|
modelsByKey={modelsByKey}
|
||||||
activeTagSet={activeTagSet}
|
activeTagSet={activeTagSet}
|
||||||
onToggleTagFilter={toggleTagFilter}
|
onToggleTagFilter={toggleTagFilter}
|
||||||
onOpenPlayer={onOpenPlayer}
|
onOpenPlayer={openPlayerAndPauseTeasers}
|
||||||
handleScrubberClickIndex={handleScrubberClickIndex}
|
handleScrubberClickIndex={handleScrubberClickIndex}
|
||||||
onSortModeChange={onSortModeChange}
|
onSortModeChange={onSortModeChange}
|
||||||
page={page}
|
page={page}
|
||||||
@ -4968,7 +5060,7 @@ export default function FinishedDownloads({
|
|||||||
keepingKeys={keepingKeys}
|
keepingKeys={keepingKeys}
|
||||||
removingKeys={removingKeys}
|
removingKeys={removingKeys}
|
||||||
registerTeaserHost={registerTeaserHost}
|
registerTeaserHost={registerTeaserHost}
|
||||||
onOpenPlayer={onOpenPlayer}
|
onOpenPlayer={openPlayerAndPauseTeasers}
|
||||||
handleScrubberClickIndex={handleScrubberClickIndex}
|
handleScrubberClickIndex={handleScrubberClickIndex}
|
||||||
deleteVideo={deleteVideo}
|
deleteVideo={deleteVideo}
|
||||||
keepVideo={keepVideo}
|
keepVideo={keepVideo}
|
||||||
|
|||||||
@ -683,10 +683,7 @@ function FinishedDownloadsCardsView({
|
|||||||
>
|
>
|
||||||
{/* media */}
|
{/* media */}
|
||||||
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
|
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
|
||||||
<PreviewRatingOverlay
|
<PreviewRatingOverlay hidden={inlineActive}>
|
||||||
hidden={inlineActive}
|
|
||||||
raised={!inlineActive && scrubberCount > 1 && hoveredThumbKey === k}
|
|
||||||
>
|
|
||||||
{renderRatingOverlay ? renderRatingOverlay(j) : null}
|
{renderRatingOverlay ? renderRatingOverlay(j) : null}
|
||||||
</PreviewRatingOverlay>
|
</PreviewRatingOverlay>
|
||||||
|
|
||||||
|
|||||||
@ -385,9 +385,7 @@ function FinishedDownloadsGalleryCardInner({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PreviewRatingOverlay
|
<PreviewRatingOverlay>
|
||||||
raised={hasScrubber && hoveredThumbKey === k}
|
|
||||||
>
|
|
||||||
{renderRatingOverlay ? renderRatingOverlay(j) : null}
|
{renderRatingOverlay ? renderRatingOverlay(j) : null}
|
||||||
</PreviewRatingOverlay>
|
</PreviewRatingOverlay>
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
// frontend\src\components\ui\LoadingSpinner.tsx
|
// frontend\src\components\ui\LoadingSpinner.tsx
|
||||||
|
|
||||||
|
// frontend/src/components/ui/LoadingSpinner.tsx
|
||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { type ReactNode } from 'react'
|
import { type ReactNode } from 'react'
|
||||||
@ -44,36 +46,45 @@ export default function LoadingSpinner({
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center gap-2',
|
'inline-flex items-center gap-2',
|
||||||
center && 'justify-center w-full',
|
center && 'w-full justify-center',
|
||||||
)}
|
)}
|
||||||
role="status"
|
role="status"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
|
aria-label={label ? undefined : srLabel}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
width={px}
|
width={px}
|
||||||
height={px}
|
height={px}
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
className={cn('animate-spin text-gray-500', className)}
|
className={cn(
|
||||||
|
'block shrink-0 text-gray-500 motion-safe:animate-spin',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{/* Hintergrund-Ring (leicht transparent) */}
|
{/* Hintergrund-Ring */}
|
||||||
<circle
|
<circle
|
||||||
cx="12"
|
cx="12"
|
||||||
cy="12"
|
cy="12"
|
||||||
r="9"
|
r="8.5"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="3"
|
strokeWidth="3"
|
||||||
opacity="0.25"
|
opacity="0.22"
|
||||||
/>
|
/>
|
||||||
{/* “Arc” vorne */}
|
|
||||||
<path
|
{/* Vorderes Segment mit runden Enden */}
|
||||||
|
<circle
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="8.5"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="3"
|
strokeWidth="3"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
d="M21 12a9 9 0 0 0-9-9"
|
strokeDasharray="22 54"
|
||||||
opacity="0.95"
|
strokeDashoffset="0"
|
||||||
|
opacity="0.98"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
@ -84,4 +95,4 @@ export default function LoadingSpinner({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -3,33 +3,21 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { PREVIEW_SCRUBBER_HEIGHT_PX } from './PreviewScrubber'
|
|
||||||
|
|
||||||
const PREVIEW_RATING_BASE_BOTTOM_PX = 7
|
|
||||||
|
|
||||||
type PreviewRatingOverlayProps = {
|
type PreviewRatingOverlayProps = {
|
||||||
hidden?: boolean
|
hidden?: boolean
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
raised?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PreviewRatingOverlay({
|
export default function PreviewRatingOverlay({
|
||||||
hidden = false,
|
hidden = false,
|
||||||
children,
|
children,
|
||||||
raised = false,
|
|
||||||
}: PreviewRatingOverlayProps) {
|
}: PreviewRatingOverlayProps) {
|
||||||
if (hidden || !children) return null
|
if (hidden || !children) return null
|
||||||
|
|
||||||
const bottomPx = raised
|
|
||||||
? PREVIEW_RATING_BASE_BOTTOM_PX + PREVIEW_SCRUBBER_HEIGHT_PX
|
|
||||||
: PREVIEW_RATING_BASE_BOTTOM_PX
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="absolute right-1.5 top-1.5 z-40">
|
||||||
className="absolute left-1.5 z-40 transition-[bottom] duration-150"
|
<div className="pointer-events-auto flex items-start justify-end">
|
||||||
style={{ bottom: `${bottomPx}px` }}
|
|
||||||
>
|
|
||||||
<div className="pointer-events-auto flex items-end">
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
1180
frontend/src/components/ui/RatingOverlay.tsx
Normal file
1180
frontend/src/components/ui/RatingOverlay.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -24,6 +24,7 @@ type RecorderSettings = {
|
|||||||
useMyFreeCamsWatcher?: boolean
|
useMyFreeCamsWatcher?: boolean
|
||||||
autoDeleteSmallDownloads?: boolean
|
autoDeleteSmallDownloads?: boolean
|
||||||
autoDeleteSmallDownloadsBelowMB?: number
|
autoDeleteSmallDownloadsBelowMB?: number
|
||||||
|
autoDeleteSmallDownloadsKeepFavorites?: boolean
|
||||||
blurPreviews?: boolean
|
blurPreviews?: boolean
|
||||||
teaserPlayback?: 'still' | 'hover' | 'all'
|
teaserPlayback?: 'still' | 'hover' | 'all'
|
||||||
teaserAudio?: boolean
|
teaserAudio?: boolean
|
||||||
@ -60,6 +61,7 @@ const DEFAULTS: RecorderSettings = {
|
|||||||
useMyFreeCamsWatcher: false,
|
useMyFreeCamsWatcher: false,
|
||||||
autoDeleteSmallDownloads: true,
|
autoDeleteSmallDownloads: true,
|
||||||
autoDeleteSmallDownloadsBelowMB: 200,
|
autoDeleteSmallDownloadsBelowMB: 200,
|
||||||
|
autoDeleteSmallDownloadsKeepFavorites: true,
|
||||||
blurPreviews: false,
|
blurPreviews: false,
|
||||||
teaserPlayback: 'hover',
|
teaserPlayback: 'hover',
|
||||||
teaserAudio: false,
|
teaserAudio: false,
|
||||||
@ -397,6 +399,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
useMyFreeCamsWatcher: data.useMyFreeCamsWatcher ?? DEFAULTS.useMyFreeCamsWatcher,
|
useMyFreeCamsWatcher: data.useMyFreeCamsWatcher ?? DEFAULTS.useMyFreeCamsWatcher,
|
||||||
autoDeleteSmallDownloads: data.autoDeleteSmallDownloads ?? DEFAULTS.autoDeleteSmallDownloads,
|
autoDeleteSmallDownloads: data.autoDeleteSmallDownloads ?? DEFAULTS.autoDeleteSmallDownloads,
|
||||||
autoDeleteSmallDownloadsBelowMB: data.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB,
|
autoDeleteSmallDownloadsBelowMB: data.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB,
|
||||||
|
autoDeleteSmallDownloadsKeepFavorites:
|
||||||
|
(data as any).autoDeleteSmallDownloadsKeepFavorites ?? DEFAULTS.autoDeleteSmallDownloadsKeepFavorites,
|
||||||
blurPreviews: data.blurPreviews ?? DEFAULTS.blurPreviews,
|
blurPreviews: data.blurPreviews ?? DEFAULTS.blurPreviews,
|
||||||
teaserPlayback: (data as any).teaserPlayback ?? DEFAULTS.teaserPlayback,
|
teaserPlayback: (data as any).teaserPlayback ?? DEFAULTS.teaserPlayback,
|
||||||
teaserAudio: (data as any).teaserAudio ?? DEFAULTS.teaserAudio,
|
teaserAudio: (data as any).teaserAudio ?? DEFAULTS.teaserAudio,
|
||||||
@ -688,6 +692,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
0,
|
0,
|
||||||
Math.min(100_000, Math.floor(Number(value.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB)))
|
Math.min(100_000, Math.floor(Number(value.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB)))
|
||||||
)
|
)
|
||||||
|
const autoDeleteSmallDownloadsKeepFavorites = !!value.autoDeleteSmallDownloadsKeepFavorites
|
||||||
const blurPreviews = !!value.blurPreviews
|
const blurPreviews = !!value.blurPreviews
|
||||||
const teaserPlayback =
|
const teaserPlayback =
|
||||||
value.teaserPlayback === 'still' || value.teaserPlayback === 'all' || value.teaserPlayback === 'hover'
|
value.teaserPlayback === 'still' || value.teaserPlayback === 'all' || value.teaserPlayback === 'hover'
|
||||||
@ -720,6 +725,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
useMyFreeCamsWatcher,
|
useMyFreeCamsWatcher,
|
||||||
autoDeleteSmallDownloads,
|
autoDeleteSmallDownloads,
|
||||||
autoDeleteSmallDownloadsBelowMB,
|
autoDeleteSmallDownloadsBelowMB,
|
||||||
|
autoDeleteSmallDownloadsKeepFavorites,
|
||||||
blurPreviews,
|
blurPreviews,
|
||||||
teaserPlayback,
|
teaserPlayback,
|
||||||
teaserAudio,
|
teaserAudio,
|
||||||
@ -1321,6 +1327,25 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'mt-3 border-t border-gray-200 pt-3 dark:border-white/10 ' +
|
||||||
|
(!value.autoDeleteSmallDownloads ? 'opacity-50 pointer-events-none' : '')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LabeledSwitch
|
||||||
|
checked={!!value.autoDeleteSmallDownloadsKeepFavorites}
|
||||||
|
onChange={(checked) =>
|
||||||
|
setValue((v) => ({
|
||||||
|
...v,
|
||||||
|
autoDeleteSmallDownloadsKeepFavorites: checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
label="Favoriten nicht automatisch löschen"
|
||||||
|
description="Schützt Downloads von favorisierten Models vor dem automatischen Löschen, auch wenn die Datei kleiner als die Mindestgröße ist."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<LabeledSwitch
|
<LabeledSwitch
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user