improved rating and added small favorite download protection

This commit is contained in:
Linrador 2026-04-28 15:02:57 +02:00
parent 68013e7b42
commit 590b113190
17 changed files with 1818 additions and 473 deletions

1
.gitignore vendored
View File

@ -3,3 +3,4 @@ backend/generated
backend/nsfwapp.exe
backend/web/dist
backend/recorder_settings.json
backend/data/pending-autostart/__global__.json

View File

@ -44,25 +44,13 @@ type analyzeVideoResp struct {
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
}
const (
analyzeSegmentMergeGapSeconds = 8.0
nsfwThresholdModerate = 0.35
nsfwThresholdStrong = 0.60
)
@ -633,8 +621,9 @@ func mergeAnalyzeHits(in []analyzeHit) []analyzeHit {
// Direkt aufeinanderfolgende Treffer mit gleichem Label immer zusammenfassen.
// Sobald ein anderes Label dazwischen liegt, wird automatisch nicht gemergt.
sameLabel := strings.EqualFold(cur.Label, n.Label)
gap := n.Start - cur.End
if sameLabel {
if sameLabel && gap <= analyzeSegmentMergeGapSeconds {
if n.Start < cur.Start {
cur.Start = n.Start
}
@ -656,119 +645,116 @@ 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 inferAnalyzePointSpanSeconds(hits []analyzeHit, duration float64) float64 {
const fallback = 3.0
if len(hits) < 2 {
return fallback
}
times := make([]float64, 0, len(hits))
for _, h := range hits {
t := h.Time
if t < 0 {
if h.Start >= 0 {
t = h.Start
} else if h.End >= 0 {
t = h.End
}
}
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 {
if t < 0 {
continue
}
sev := nsfwSegmentSeverityWeight(s.Label)
conf := clamp01(s.Score)
if duration > 0 {
t = math.Max(0, math.Min(t, duration))
}
// Confidence beeinflusst, dominiert aber nicht.
weight := sev * (0.65 + 0.35*conf)
times = append(times, t)
}
totalFlagged += segDur
totalWeighted += segDur * weight
confSum += conf
n++
if len(times) < 2 {
return fallback
}
if segDur > longest {
longest = segDur
sort.Float64s(times)
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 {
return r
if len(gaps) == 0 {
return fallback
}
coverageRatio := clamp01(totalFlagged / durationSec)
weightedCoverageRatio := clamp01(totalWeighted / durationSec)
segmentsPerMinute := float64(n) / videoMinutes
avgConfidence := confSum / float64(n)
sort.Float64s(gaps)
// 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)
median := gaps[len(gaps)/2]
if len(gaps)%2 == 0 {
median = (gaps[len(gaps)/2-1] + gaps[len(gaps)/2]) / 2
}
raw := 0.55*coverageNorm +
0.20*frequencyNorm +
0.15*longestNorm +
0.10*confNorm
// Ein einzelner Frame repräsentiert ungefähr seinen Sample-Abstand,
// aber wir deckeln, damit Sparse-Hits nicht riesig werden.
span := median * 0.90
score := math.Round(clamp01(raw)*1000) / 10 // 0..100 mit 1 Nachkommastelle
stars := starsFromNSFWScore(score)
if span < 2 {
span = 2
}
if span > 12 {
span = 12
}
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 span
}
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 {
@ -776,6 +762,8 @@ func buildSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegme
return []aiSegmentMeta{}
}
pointSpan := inferAnalyzePointSpanSeconds(hits, duration)
out := make([]aiSegmentMeta, 0, len(hits))
for _, hit := range hits {
@ -797,6 +785,7 @@ func buildSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegme
end = hit.Time
}
}
if start > end {
start, end = end, start
}
@ -804,6 +793,18 @@ func buildSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegme
start = math.Max(0, math.Min(start, 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 {
continue
}
@ -838,10 +839,12 @@ func buildSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegme
for i := 1; i < len(out); i++ {
n := out[i]
// Direkt aufeinanderfolgende Segmente mit gleichem Label immer mergen,
// unabhängig von der Lücke. Sobald ein anderes Label dazwischen liegt,
// wird automatisch nicht gemergt, weil wir nur mit dem direkten Nachfolger arbeiten.
if strings.EqualFold(cur.Label, n.Label) {
gap := n.StartSeconds - cur.EndSeconds
if gap < 0 {
gap = 0
}
if strings.EqualFold(cur.Label, n.Label) && gap <= analyzeSegmentMergeGapSeconds {
if n.StartSeconds < cur.StartSeconds {
cur.StartSeconds = n.StartSeconds
}

View File

@ -389,8 +389,6 @@ var startedAtFromFilenameRe = regexp.MustCompile(
)
func shouldAutoDeleteSmallDownload(filePath string) (bool, int64, int64) {
// returns: (shouldDelete, sizeBytes, thresholdBytes)
s := getSettings()
if !s.AutoDeleteSmallDownloads {
return false, 0, 0
@ -406,7 +404,6 @@ func shouldAutoDeleteSmallDownload(filePath string) (bool, int64, int64) {
return false, 0, 0
}
// relativ -> absolut versuchen (best effort)
if !filepath.IsAbs(p) {
if abs, err := resolvePathRelativeToApp(p); err == nil && strings.TrimSpace(abs) != "" {
p = abs
@ -420,9 +417,15 @@ func shouldAutoDeleteSmallDownload(filePath string) (bool, int64, int64) {
size := fi.Size()
thr := int64(mb) * 1024 * 1024
if size > 0 && size < thr {
if isFavoriteProtectedDownload(p, "") {
return false, size, thr
}
return true, size, thr
}
return false, size, thr
}

View File

@ -293,6 +293,46 @@ LIMIT 1;
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 {
h := strings.ToLower(strings.TrimSpace(host))
h = strings.TrimPrefix(h, "www.")

186
backend/rating.go Normal file
View 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 24 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
}

View File

@ -985,6 +985,48 @@ func ensureUsableRecordedOutput(job *RecordJob, target JobStatus, out string) bo
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 {
fi, err := os.Stat(out)
if err != nil || fi == nil || fi.IsDir() {
@ -1006,6 +1048,11 @@ func maybeDeleteSmallRecordedOutput(job *RecordJob, out string) bool {
return false
}
if isFavoriteProtectedDownload(out, job.SourceURL) {
fmt.Println("⭐ auto-delete skipped for favorite:", filepath.Base(out))
return false
}
base := filepath.Base(out)
id := stripHotPrefix(strings.TrimSuffix(base, filepath.Ext(base)))

View File

@ -31,6 +31,7 @@ type RecorderSettings struct {
// Wenn aktiv, werden fertige Downloads automatisch gelöscht, wenn sie kleiner als der Grenzwert sind.
AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"`
AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"`
AutoDeleteSmallDownloadsKeepFavorites bool `json:"autoDeleteSmallDownloadsKeepFavorites"`
LowDiskPauseBelowGB int `json:"lowDiskPauseBelowGB"`
BlurPreviews bool `json:"blurPreviews"`
@ -67,6 +68,7 @@ var (
UseMyFreeCamsWatcher: false,
AutoDeleteSmallDownloads: false,
AutoDeleteSmallDownloadsBelowMB: 50,
AutoDeleteSmallDownloadsKeepFavorites: true,
LowDiskPauseBelowGB: 5,
BlurPreviews: false,
@ -228,6 +230,7 @@ type RecorderSettingsPublic struct {
AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"`
AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"`
AutoDeleteSmallDownloadsKeepFavorites bool `json:"autoDeleteSmallDownloadsKeepFavorites"`
LowDiskPauseBelowGB int `json:"lowDiskPauseBelowGB"`
BlurPreviews bool `json:"blurPreviews"`
@ -261,6 +264,7 @@ func toPublicSettings(s RecorderSettings) RecorderSettingsPublic {
AutoDeleteSmallDownloads: s.AutoDeleteSmallDownloads,
AutoDeleteSmallDownloadsBelowMB: s.AutoDeleteSmallDownloadsBelowMB,
AutoDeleteSmallDownloadsKeepFavorites: s.AutoDeleteSmallDownloadsKeepFavorites,
LowDiskPauseBelowGB: s.LowDiskPauseBelowGB,
BlurPreviews: s.BlurPreviews,
@ -417,6 +421,7 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
next.AutoDeleteSmallDownloads = in.AutoDeleteSmallDownloads
next.AutoDeleteSmallDownloadsBelowMB = in.AutoDeleteSmallDownloadsBelowMB
next.AutoDeleteSmallDownloadsKeepFavorites = in.AutoDeleteSmallDownloadsKeepFavorites
next.LowDiskPauseBelowGB = in.LowDiskPauseBelowGB
next.BlurPreviews = in.BlurPreviews

View File

@ -461,6 +461,34 @@ func dirSizeBytes(root string) int64 {
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).
func triggerGeneratedGarbageCollectorSync() generatedGCStats {
// nur 1 GC gleichzeitig
@ -603,7 +631,9 @@ func runGeneratedGarbageCollector() generatedGCStats {
collectLiveIDsFromTrash(filepath.Join(doneAbs, ".trash"), live)
// 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()
for _, j := range jobs {
if j == nil {
@ -616,6 +646,7 @@ func runGeneratedGarbageCollector() generatedGCStats {
}
live[id] = struct{}{}
activeJobIDs[id] = struct{}{}
}
jobsMu.RUnlock()
@ -637,21 +668,38 @@ func runGeneratedGarbageCollector() generatedGCStats {
if !e.IsDir() {
continue
}
id := strings.TrimSpace(e.Name())
if id == "" || strings.HasPrefix(id, ".") {
continue
}
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 {
continue
}
orphanDir := filepath.Join(metaRoot, id)
orphanBytes := dirSizeBytes(orphanDir)
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) {
removedMeta++
removedBytes += orphanBytes

View File

@ -3949,7 +3949,7 @@ export default function App() {
blurPreviews={Boolean(recSettings.blurPreviews)}
teaserPlayback={recSettings.teaserPlayback ?? 'hover'}
teaserAudio={Boolean(recSettings.teaserAudio)}
pauseTeasers={Boolean(detailsModelKey) || splitModalOpen}
pauseTeasers={Boolean(detailsModelKey) || splitModalOpen || Boolean(playerJob)}
assetNonce={assetNonce}
sortMode={doneSort}
onSortModeChange={(m) => {

View File

@ -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>
)
}

View File

@ -59,7 +59,7 @@ import {
type FinishedSelectionStore,
} from './finishedSelectionStore'
import { formatResolution, formatDuration, formatBytes } from './formatters'
import FinishedDownloadRating from './FinishedDownloadRating'
import RatingOverlay from './RatingOverlay'
type Props = {
jobs: RecordJob[]
@ -1614,8 +1614,7 @@ export default function FinishedDownloads({
const LAST_UNDO_KEY = 'finishedDownloads_lastUndo_v1'
const requestedTeaserPlaybackMode: TeaserPlaybackMode = teaserPlayback ?? 'hover'
const teaserPlaybackMode: TeaserPlaybackMode =
pauseTeasers ? 'still' : requestedTeaserPlaybackMode
const teaserPlaybackMode: TeaserPlaybackMode = requestedTeaserPlaybackMode
const notify = useNotify()
@ -1631,7 +1630,6 @@ export default function FinishedDownloads({
!canHover && teaserPlaybackMode === 'hover' ? 'still' : teaserPlaybackMode
const mobileNeedsTapForAudio = isCoarsePointer || isTabletOrSmaller
const previewMuted = !Boolean(teaserAudio)
const cardsMobileOffsetTopClass = 'mt-10'
const cardsMobileOffsetBottomClass = 'mb-2'
@ -1805,22 +1803,39 @@ export default function FinishedDownloads({
return renderDownloadIntegrityBadge((job as any)?.meta)
}, [])
const openPlayerAndPauseTeasers = useCallback((job: RecordJob, startAtSec?: number) => {
onOpenPlayer(job, startAtSec)
}, [onOpenPlayer])
const renderRatingOverlay = useCallback((job: RecordJob) => {
return (
<FinishedDownloadRating
<RatingOverlay
metaRaw={(job as any)?.meta}
variant="overlay"
onOpenAt={(seconds) => {
openPlayerAndPauseTeasers(job, seconds)
}}
/>
)
}, [])
}, [openPlayerAndPauseTeasers])
// -----------------------------------------------------------------------------
// memoized derived data
// -----------------------------------------------------------------------------
const forcePreviewMuted =
mobileNeedsTapForAudio && teaserAudio
Boolean(pauseTeasers) ||
(mobileNeedsTapForAudio && teaserAudio
? !mobileTeaserAudioUnlocked
: false
: false)
const previewMuted = !Boolean(teaserAudio) || forcePreviewMuted
const audibleTeaserKey =
previewMuted
? null
: canHover
? hoverTeaserKey
: inlinePlay?.key ?? teaserKey
const deferredSearchQuery = useDeferredValue(searchQuery)
@ -1848,9 +1863,9 @@ export default function FinishedDownloads({
mode: effectiveTeaserPlaybackMode,
activeKey: teaserKey,
hoverKey: hoverTeaserKey,
audioEnabled: Boolean(teaserAudio),
audioEnabled: Boolean(teaserAudio) && !forcePreviewMuted && Boolean(audibleTeaserKey),
}),
[effectiveTeaserPlaybackMode, teaserKey, hoverTeaserKey, teaserAudio]
[effectiveTeaserPlaybackMode, teaserKey, hoverTeaserKey, teaserAudio, forcePreviewMuted, audibleTeaserKey]
)
const modelTags = useMemo(() => {
@ -2347,16 +2362,26 @@ export default function FinishedDownloads({
const tryAutoplayInline = useCallback((domId: string) => {
const host = document.getElementById(domId)
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?.()
if (p && typeof (p as any).catch === 'function') {
(p as Promise<void>).catch(() => {})
;(p as Promise<void>).catch(() => {})
}
return true
}, [previewMuted])
}, [audibleTeaserKey])
const startInline = useCallback((key: string) => {
setInlinePlay((prev) => (
@ -2382,7 +2407,11 @@ export default function FinishedDownloads({
return
}
applyInlineVideoPolicy(v, { muted: previewMuted })
const muted = !audibleTeaserKey || key !== audibleTeaserKey
applyInlineVideoPolicy(v, { muted })
v.defaultMuted = muted
v.muted = muted
const applySeek = () => {
try {
@ -2420,18 +2449,16 @@ export default function FinishedDownloads({
}
requestAnimationFrame(() => trySeekAndPlay(8))
}, [previewMuted])
}, [audibleTeaserKey])
const openPlayer = useCallback((job: RecordJob) => {
setInlinePlay(null)
onOpenPlayer(job)
}, [onOpenPlayer])
openPlayerAndPauseTeasers(job)
}, [openPlayerAndPauseTeasers])
const openPlayerAt = useCallback((job: RecordJob, seconds: number) => {
const s = Number.isFinite(seconds) && seconds >= 0 ? seconds : 0
setInlinePlay(null)
onOpenPlayer(job, s)
}, [onOpenPlayer])
openPlayerAndPauseTeasers(job, s)
}, [openPlayerAndPauseTeasers])
const handleScrubberClickIndex = useCallback(
(job: RecordJob, segmentIndex: number, segmentCount: number) => {
@ -3548,12 +3575,17 @@ export default function FinishedDownloads({
}, [])
useEffect(() => {
if (!pauseTeasers) return
for (const [key, host] of teaserHostsRef.current.entries()) {
const muted = !audibleTeaserKey || key !== audibleTeaserKey
const videos = host.querySelectorAll('video')
setTeaserKey(null)
setHoverTeaserKey(null)
setInlinePlay(null)
}, [pauseTeasers])
videos.forEach((video) => {
applyInlineVideoPolicy(video, { muted })
video.defaultMuted = muted
video.muted = muted
})
}
}, [audibleTeaserKey, view, pageRows])
const refreshPostworkState = useCallback(async () => {
try {
@ -3774,13 +3806,73 @@ export default function FinishedDownloads({
}, [refreshPostworkState])
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) => {
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 })
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(() => {
try {
@ -4849,7 +4941,7 @@ export default function FinishedDownloads({
sizeBytesOf={sizeBytesOf}
formatBytes={formatBytes}
lower={lowerTag}
onOpenPlayer={onOpenPlayer}
onOpenPlayer={openPlayerAndPauseTeasers}
openPlayer={openPlayer}
onOpenPlayerAt={openPlayerAt}
handleScrubberClickIndex={handleScrubberClickIndex}
@ -4921,7 +5013,7 @@ export default function FinishedDownloads({
modelsByKey={modelsByKey}
activeTagSet={activeTagSet}
onToggleTagFilter={toggleTagFilter}
onOpenPlayer={onOpenPlayer}
onOpenPlayer={openPlayerAndPauseTeasers}
handleScrubberClickIndex={handleScrubberClickIndex}
onSortModeChange={onSortModeChange}
page={page}
@ -4968,7 +5060,7 @@ export default function FinishedDownloads({
keepingKeys={keepingKeys}
removingKeys={removingKeys}
registerTeaserHost={registerTeaserHost}
onOpenPlayer={onOpenPlayer}
onOpenPlayer={openPlayerAndPauseTeasers}
handleScrubberClickIndex={handleScrubberClickIndex}
deleteVideo={deleteVideo}
keepVideo={keepVideo}

View File

@ -683,10 +683,7 @@ function FinishedDownloadsCardsView({
>
{/* media */}
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
<PreviewRatingOverlay
hidden={inlineActive}
raised={!inlineActive && scrubberCount > 1 && hoveredThumbKey === k}
>
<PreviewRatingOverlay hidden={inlineActive}>
{renderRatingOverlay ? renderRatingOverlay(j) : null}
</PreviewRatingOverlay>

View File

@ -385,9 +385,7 @@ function FinishedDownloadsGalleryCardInner({
/>
</div>
<PreviewRatingOverlay
raised={hasScrubber && hoveredThumbKey === k}
>
<PreviewRatingOverlay>
{renderRatingOverlay ? renderRatingOverlay(j) : null}
</PreviewRatingOverlay>

View File

@ -1,5 +1,7 @@
// frontend\src\components\ui\LoadingSpinner.tsx
// frontend/src/components/ui/LoadingSpinner.tsx
'use client'
import { type ReactNode } from 'react'
@ -44,36 +46,45 @@ export default function LoadingSpinner({
<span
className={cn(
'inline-flex items-center gap-2',
center && 'justify-center w-full',
center && 'w-full justify-center',
)}
role="status"
aria-live="polite"
aria-label={label ? undefined : srLabel}
>
<svg
width={px}
height={px}
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"
>
{/* Hintergrund-Ring (leicht transparent) */}
{/* Hintergrund-Ring */}
<circle
cx="12"
cy="12"
r="9"
r="8.5"
fill="none"
stroke="currentColor"
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"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
d="M21 12a9 9 0 0 0-9-9"
opacity="0.95"
strokeDasharray="22 54"
strokeDashoffset="0"
opacity="0.98"
/>
</svg>

View File

@ -3,33 +3,21 @@
'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">
<div className="absolute right-1.5 top-1.5 z-40">
<div className="pointer-events-auto flex items-start justify-end">
{children}
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@ -24,6 +24,7 @@ type RecorderSettings = {
useMyFreeCamsWatcher?: boolean
autoDeleteSmallDownloads?: boolean
autoDeleteSmallDownloadsBelowMB?: number
autoDeleteSmallDownloadsKeepFavorites?: boolean
blurPreviews?: boolean
teaserPlayback?: 'still' | 'hover' | 'all'
teaserAudio?: boolean
@ -60,6 +61,7 @@ const DEFAULTS: RecorderSettings = {
useMyFreeCamsWatcher: false,
autoDeleteSmallDownloads: true,
autoDeleteSmallDownloadsBelowMB: 200,
autoDeleteSmallDownloadsKeepFavorites: true,
blurPreviews: false,
teaserPlayback: 'hover',
teaserAudio: false,
@ -397,6 +399,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
useMyFreeCamsWatcher: data.useMyFreeCamsWatcher ?? DEFAULTS.useMyFreeCamsWatcher,
autoDeleteSmallDownloads: data.autoDeleteSmallDownloads ?? DEFAULTS.autoDeleteSmallDownloads,
autoDeleteSmallDownloadsBelowMB: data.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB,
autoDeleteSmallDownloadsKeepFavorites:
(data as any).autoDeleteSmallDownloadsKeepFavorites ?? DEFAULTS.autoDeleteSmallDownloadsKeepFavorites,
blurPreviews: data.blurPreviews ?? DEFAULTS.blurPreviews,
teaserPlayback: (data as any).teaserPlayback ?? DEFAULTS.teaserPlayback,
teaserAudio: (data as any).teaserAudio ?? DEFAULTS.teaserAudio,
@ -688,6 +692,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
0,
Math.min(100_000, Math.floor(Number(value.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB)))
)
const autoDeleteSmallDownloadsKeepFavorites = !!value.autoDeleteSmallDownloadsKeepFavorites
const blurPreviews = !!value.blurPreviews
const teaserPlayback =
value.teaserPlayback === 'still' || value.teaserPlayback === 'all' || value.teaserPlayback === 'hover'
@ -720,6 +725,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
useMyFreeCamsWatcher,
autoDeleteSmallDownloads,
autoDeleteSmallDownloadsBelowMB,
autoDeleteSmallDownloadsKeepFavorites,
blurPreviews,
teaserPlayback,
teaserAudio,
@ -1321,6 +1327,25 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
</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>
<LabeledSwitch