bugfixes and optimizations
This commit is contained in:
parent
bd34f95ec2
commit
ae957af7b2
@ -992,7 +992,7 @@ func enqueueDeferredAssetsAndAI(job *RecordJob, outPath string, sourceURL string
|
|||||||
|
|
||||||
sortBucket, sortName := postWorkSortForVideoPath(outPath)
|
sortBucket, sortName := postWorkSortForVideoPath(outPath)
|
||||||
|
|
||||||
ok := enrichQ.Enqueue(PostWorkTask{
|
ok := enqueueEnrichPostwork(PostWorkTask{
|
||||||
Key: "enrich:" + assetID,
|
Key: "enrich:" + assetID,
|
||||||
Added: time.Now(),
|
Added: time.Now(),
|
||||||
SortBucket: sortBucket,
|
SortBucket: sortBucket,
|
||||||
|
|||||||
@ -252,6 +252,45 @@ func (pq *PostWorkQueue) RemoveQueued(key string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (pq *PostWorkQueue) CancelAll() (queuedRemoved int, runningCancelled int) {
|
||||||
|
pq.mu.Lock()
|
||||||
|
|
||||||
|
waiting := append([]string(nil), pq.waitingKeys...)
|
||||||
|
runningCancels := make([]context.CancelFunc, 0, len(pq.cancelByKey))
|
||||||
|
|
||||||
|
for key, cancel := range pq.cancelByKey {
|
||||||
|
if _, running := pq.runningKeys[key]; running && cancel != nil {
|
||||||
|
runningCancels = append(runningCancels, cancel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range waiting {
|
||||||
|
delete(pq.inflight, key)
|
||||||
|
pq.unlockFileLocked(key)
|
||||||
|
|
||||||
|
if pq.queued > 0 {
|
||||||
|
pq.queued--
|
||||||
|
}
|
||||||
|
|
||||||
|
queuedRemoved++
|
||||||
|
}
|
||||||
|
|
||||||
|
pq.waitingKeys = nil
|
||||||
|
pq.queue = nil
|
||||||
|
|
||||||
|
runningCancelled = len(runningCancels)
|
||||||
|
|
||||||
|
pq.mu.Unlock()
|
||||||
|
|
||||||
|
for _, cancel := range runningCancels {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
publishTaskState()
|
||||||
|
|
||||||
|
return queuedRemoved, runningCancelled
|
||||||
|
}
|
||||||
|
|
||||||
func (pq *PostWorkQueue) workerLoop(id int) {
|
func (pq *PostWorkQueue) workerLoop(id int) {
|
||||||
for {
|
for {
|
||||||
pq.mu.Lock()
|
pq.mu.Lock()
|
||||||
@ -393,6 +432,19 @@ func (pq *PostWorkQueue) StatusForKey(key string) PostWorkKeyStatus {
|
|||||||
var postWorkQ = NewPostWorkQueue(1024, 1) // schneller kritischer Pfad
|
var postWorkQ = NewPostWorkQueue(1024, 1) // schneller kritischer Pfad
|
||||||
var enrichQ = NewPostWorkQueue(128, 1) // langsame Hintergrundaufgaben
|
var enrichQ = NewPostWorkQueue(128, 1) // langsame Hintergrundaufgaben
|
||||||
|
|
||||||
|
func enrichPostworkEnabled() bool {
|
||||||
|
return getSettings().EnrichPostworkEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
func enqueueEnrichPostwork(task PostWorkTask) bool {
|
||||||
|
if !enrichPostworkEnabled() {
|
||||||
|
appLogln("⏭️ enrich postwork deaktiviert, überspringe:", task.Key)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return enrichQ.Enqueue(task)
|
||||||
|
}
|
||||||
|
|
||||||
// --- Status Refresher (ehemals postwork_refresh.go) ---
|
// --- Status Refresher (ehemals postwork_refresh.go) ---
|
||||||
|
|
||||||
type enrichStatusSnapshot struct {
|
type enrichStatusSnapshot struct {
|
||||||
|
|||||||
@ -1274,9 +1274,6 @@ func extractLastFrameJPG(path string) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
b := out.Bytes()
|
b := out.Bytes()
|
||||||
if len(b) == 0 {
|
|
||||||
return nil, appErrorf("ffmpeg last-frame jpg: empty output")
|
|
||||||
}
|
|
||||||
|
|
||||||
return b, nil
|
return b, nil
|
||||||
}
|
}
|
||||||
@ -1314,9 +1311,6 @@ func extractFrameAtTimeJPG(path string, seconds float64) ([]byte, error) {
|
|||||||
return nil, ffmpegPreviewError("ffmpeg frame-at-time jpg", err, stderr.String())
|
return nil, ffmpegPreviewError("ffmpeg frame-at-time jpg", err, stderr.String())
|
||||||
}
|
}
|
||||||
b := out.Bytes()
|
b := out.Bytes()
|
||||||
if len(b) == 0 {
|
|
||||||
return nil, appErrorf("ffmpeg frame-at-time jpg: empty output")
|
|
||||||
}
|
|
||||||
return b, nil
|
return b, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -22,6 +22,7 @@
|
|||||||
"generateAssetsTeaser": true,
|
"generateAssetsTeaser": true,
|
||||||
"generateAssetsSprites": false,
|
"generateAssetsSprites": false,
|
||||||
"generateAssetsAnalyze": false,
|
"generateAssetsAnalyze": false,
|
||||||
|
"enrichPostworkEnabled": false,
|
||||||
"trainingRecognitionEnabled": true,
|
"trainingRecognitionEnabled": true,
|
||||||
"trainingDetectorEpochs": 60,
|
"trainingDetectorEpochs": 60,
|
||||||
"encryptedCookies": "FEPj/igIPaDy8xr709QjHKOqXWVGesegmwzhTP1Whnusetrl1WenN7t0V1Rm/lw2nLkyis78kz+4yjsDtVgO3MXLU4Uq85AhAJQOE7SMVD+YMNenCS8Qr0JLkmI6cr/kR6sKrx7Jm6x6DY1BolFFMX1Ii3EO+8b4Re2GYaFi1iuh97oI8Zg4r5sMYmt7FIMRkr1uy0u0ToH8hsO0iDoExcxubQNHIZTQ07kkWP70Up78tYs59INxzosijATbiVhOT1H0YwS23iWC3bwLgeo2lOzMejJWpEG16CN55CoEAymFd2ktlQZ4Lmv0FlnBJ48H27Cj0TKR1yRbBxpGjgch9x0421C5qYGeXNB9jgczEKh28oYdt8ljXejoDsV7/oKYNaAFfIlICsC/Zz97ZpPRrKiRTSTkTLmIXjnWtVdwGRfCvJrYjJpoe3ZOaubulqCQs6Ug501b6JTzdZujKAl68tL5stZD7hH9XGQSpquZWGeSUGOrI5jU8dULbxCLpkGtiOEmxGb93OLYdHQ8JJg6zHeExVtPSqANHoKX3NpFc33fqOX1vOQVeg8Ei4ymxntIMOwQWs8xnbuKXmnNx6GTBSqHiD7X/a8oeEkMSw1+bCODvwuXlNBcdxHlf3/wkvHb9lhnh231BD1+Ufrnij65Apko8A=="
|
"encryptedCookies": "FEPj/igIPaDy8xr709QjHKOqXWVGesegmwzhTP1Whnusetrl1WenN7t0V1Rm/lw2nLkyis78kz+4yjsDtVgO3MXLU4Uq85AhAJQOE7SMVD+YMNenCS8Qr0JLkmI6cr/kR6sKrx7Jm6x6DY1BolFFMX1Ii3EO+8b4Re2GYaFi1iuh97oI8Zg4r5sMYmt7FIMRkr1uy0u0ToH8hsO0iDoExcxubQNHIZTQ07kkWP70Up78tYs59INxzosijATbiVhOT1H0YwS23iWC3bwLgeo2lOzMejJWpEG16CN55CoEAymFd2ktlQZ4Lmv0FlnBJ48H27Cj0TKR1yRbBxpGjgch9x0421C5qYGeXNB9jgczEKh28oYdt8ljXejoDsV7/oKYNaAFfIlICsC/Zz97ZpPRrKiRTSTkTLmIXjnWtVdwGRfCvJrYjJpoe3ZOaubulqCQs6Ug501b6JTzdZujKAl68tL5stZD7hH9XGQSpquZWGeSUGOrI5jU8dULbxCLpkGtiOEmxGb93OLYdHQ8JJg6zHeExVtPSqANHoKX3NpFc33fqOX1vOQVeg8Ei4ymxntIMOwQWs8xnbuKXmnNx6GTBSqHiD7X/a8oeEkMSw1+bCODvwuXlNBcdxHlf3/wkvHb9lhnh231BD1+Ufrnij65Apko8A=="
|
||||||
|
|||||||
@ -44,6 +44,7 @@ type RecorderSettings struct {
|
|||||||
GenerateAssetsTeaser bool `json:"generateAssetsTeaser"`
|
GenerateAssetsTeaser bool `json:"generateAssetsTeaser"`
|
||||||
GenerateAssetsSprites bool `json:"generateAssetsSprites"`
|
GenerateAssetsSprites bool `json:"generateAssetsSprites"`
|
||||||
GenerateAssetsAnalyze bool `json:"generateAssetsAnalyze"`
|
GenerateAssetsAnalyze bool `json:"generateAssetsAnalyze"`
|
||||||
|
EnrichPostworkEnabled bool `json:"enrichPostworkEnabled"`
|
||||||
|
|
||||||
TrainingRecognitionEnabled bool `json:"trainingRecognitionEnabled"`
|
TrainingRecognitionEnabled bool `json:"trainingRecognitionEnabled"`
|
||||||
TrainingDetectorEpochs int `json:"trainingDetectorEpochs"`
|
TrainingDetectorEpochs int `json:"trainingDetectorEpochs"`
|
||||||
@ -84,6 +85,7 @@ var (
|
|||||||
GenerateAssetsTeaser: true,
|
GenerateAssetsTeaser: true,
|
||||||
GenerateAssetsSprites: false,
|
GenerateAssetsSprites: false,
|
||||||
GenerateAssetsAnalyze: false,
|
GenerateAssetsAnalyze: false,
|
||||||
|
EnrichPostworkEnabled: true,
|
||||||
|
|
||||||
TrainingRecognitionEnabled: true,
|
TrainingRecognitionEnabled: true,
|
||||||
TrainingDetectorEpochs: 60,
|
TrainingDetectorEpochs: 60,
|
||||||
@ -256,6 +258,7 @@ type RecorderSettingsPublic struct {
|
|||||||
GenerateAssetsTeaser bool `json:"generateAssetsTeaser"`
|
GenerateAssetsTeaser bool `json:"generateAssetsTeaser"`
|
||||||
GenerateAssetsSprites bool `json:"generateAssetsSprites"`
|
GenerateAssetsSprites bool `json:"generateAssetsSprites"`
|
||||||
GenerateAssetsAnalyze bool `json:"generateAssetsAnalyze"`
|
GenerateAssetsAnalyze bool `json:"generateAssetsAnalyze"`
|
||||||
|
EnrichPostworkEnabled bool `json:"enrichPostworkEnabled"`
|
||||||
|
|
||||||
TrainingRecognitionEnabled bool `json:"trainingRecognitionEnabled"`
|
TrainingRecognitionEnabled bool `json:"trainingRecognitionEnabled"`
|
||||||
TrainingDetectorEpochs int `json:"trainingDetectorEpochs"`
|
TrainingDetectorEpochs int `json:"trainingDetectorEpochs"`
|
||||||
@ -293,6 +296,7 @@ func toPublicSettings(s RecorderSettings) RecorderSettingsPublic {
|
|||||||
GenerateAssetsTeaser: s.GenerateAssetsTeaser,
|
GenerateAssetsTeaser: s.GenerateAssetsTeaser,
|
||||||
GenerateAssetsSprites: s.GenerateAssetsSprites,
|
GenerateAssetsSprites: s.GenerateAssetsSprites,
|
||||||
GenerateAssetsAnalyze: s.GenerateAssetsAnalyze,
|
GenerateAssetsAnalyze: s.GenerateAssetsAnalyze,
|
||||||
|
EnrichPostworkEnabled: s.EnrichPostworkEnabled,
|
||||||
|
|
||||||
TrainingRecognitionEnabled: s.TrainingRecognitionEnabled,
|
TrainingRecognitionEnabled: s.TrainingRecognitionEnabled,
|
||||||
TrainingDetectorEpochs: s.TrainingDetectorEpochs,
|
TrainingDetectorEpochs: s.TrainingDetectorEpochs,
|
||||||
@ -455,11 +459,14 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
next.EnableNotifications = in.EnableNotifications
|
next.EnableNotifications = in.EnableNotifications
|
||||||
|
|
||||||
|
oldEnrichPostworkEnabled := current.EnrichPostworkEnabled
|
||||||
|
|
||||||
next.GenerateAssetsMeta = in.GenerateAssetsMeta
|
next.GenerateAssetsMeta = in.GenerateAssetsMeta
|
||||||
next.GenerateAssetsThumb = in.GenerateAssetsThumb
|
next.GenerateAssetsThumb = in.GenerateAssetsThumb
|
||||||
next.GenerateAssetsTeaser = in.GenerateAssetsTeaser
|
next.GenerateAssetsTeaser = in.GenerateAssetsTeaser
|
||||||
next.GenerateAssetsSprites = in.GenerateAssetsSprites
|
next.GenerateAssetsSprites = in.GenerateAssetsSprites
|
||||||
next.GenerateAssetsAnalyze = in.GenerateAssetsAnalyze
|
next.GenerateAssetsAnalyze = in.GenerateAssetsAnalyze
|
||||||
|
next.EnrichPostworkEnabled = in.EnrichPostworkEnabled
|
||||||
|
|
||||||
next.TrainingRecognitionEnabled = in.TrainingRecognitionEnabled
|
next.TrainingRecognitionEnabled = in.TrainingRecognitionEnabled
|
||||||
next.TrainingDetectorEpochs = in.TrainingDetectorEpochs
|
next.TrainingDetectorEpochs = in.TrainingDetectorEpochs
|
||||||
@ -492,6 +499,18 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
saveSettingsToDisk()
|
saveSettingsToDisk()
|
||||||
|
|
||||||
|
if oldEnrichPostworkEnabled && !next.EnrichPostworkEnabled {
|
||||||
|
cancelledQueued, cancelledRunning := enrichQ.CancelAll()
|
||||||
|
|
||||||
|
if cancelledQueued > 0 || cancelledRunning > 0 {
|
||||||
|
appLogln(
|
||||||
|
"🛑 enrich postwork deaktiviert:",
|
||||||
|
"queuedRemoved=", cancelledQueued,
|
||||||
|
"runningCancelled=", cancelledRunning,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if newStore != nil {
|
if newStore != nil {
|
||||||
setModelStore(newStore)
|
setModelStore(newStore)
|
||||||
setChaturbateOnlineModelStore(newStore)
|
setChaturbateOnlineModelStore(newStore)
|
||||||
|
|||||||
@ -11,24 +11,185 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
const startupLeftoverMinAge = 60 * time.Second
|
const (
|
||||||
|
startupLeftoverMinAge = 60 * time.Second
|
||||||
|
|
||||||
|
// .muxing.ts sind temporäre Zwischenstände.
|
||||||
|
// Nur alte Dateien löschen, damit ein sehr frischer/noch schreibender Prozess
|
||||||
|
// nicht versehentlich getroffen wird.
|
||||||
|
startupMuxingTempMinAge = 10 * time.Minute
|
||||||
|
|
||||||
|
postworkLeftoverInitialDelay = 8 * time.Second
|
||||||
|
postworkLeftoverScanInterval = 1 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
func startPostworkLeftoverScanOnStartup() {
|
func startPostworkLeftoverScanOnStartup() {
|
||||||
go func() {
|
go func() {
|
||||||
// Kurz warten, bis Settings, Queues und UI/SSE bereit sind.
|
// Kurz warten, bis Settings, Queues und UI/SSE bereit sind.
|
||||||
time.Sleep(8 * time.Second)
|
time.Sleep(postworkLeftoverInitialDelay)
|
||||||
|
|
||||||
queued, skipped := enqueuePostworkLeftoversFromRecordDir()
|
runPostworkLeftoverScan := func(reason string) {
|
||||||
if queued > 0 || skipped > 0 {
|
removedMuxing, skippedMuxing := cleanupStaleMuxingTempFilesFromRecordDir()
|
||||||
appLogln(
|
|
||||||
"🧩 [startup-postwork] leftover scan fertig:",
|
queued, skipped := enqueuePostworkLeftoversFromRecordDir()
|
||||||
"queued=", queued,
|
if queued > 0 || skipped > 0 || removedMuxing > 0 || skippedMuxing > 0 {
|
||||||
"skipped=", skipped,
|
appLogln(
|
||||||
)
|
"🧩 [startup-postwork] leftover scan fertig:",
|
||||||
|
"reason=", reason,
|
||||||
|
"queued=", queued,
|
||||||
|
"skipped=", skipped,
|
||||||
|
"removedMuxing=", removedMuxing,
|
||||||
|
"skippedMuxing=", skippedMuxing,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runPostworkLeftoverScan("startup")
|
||||||
|
|
||||||
|
ticker := time.NewTicker(postworkLeftoverScanInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for range ticker.C {
|
||||||
|
runPostworkLeftoverScan("interval")
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cleanupStaleMuxingTempFilesFromRecordDir() (removed int, skipped int) {
|
||||||
|
s := getSettings()
|
||||||
|
|
||||||
|
recordAbs, err := resolvePathRelativeToApp(s.RecordDir)
|
||||||
|
if err != nil || strings.TrimSpace(recordAbs) == "" {
|
||||||
|
recordAbs = strings.TrimSpace(s.RecordDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
recordAbs = strings.TrimSpace(recordAbs)
|
||||||
|
if recordAbs == "" {
|
||||||
|
appLogln("⚠️ [startup-postwork] recordDir ist leer")
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(recordAbs)
|
||||||
|
if err != nil {
|
||||||
|
appLogln("⚠️ [startup-postwork] recordDir lesen fehlgeschlagen:", err)
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
for _, e := range entries {
|
||||||
|
if e == nil || e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimSpace(e.Name())
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isMuxingTempTSName(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(recordAbs, name)
|
||||||
|
|
||||||
|
fi, err := os.Stat(path)
|
||||||
|
if err != nil || fi == nil || fi.IsDir() {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if fi.Size() <= 0 {
|
||||||
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||||
|
skipped++
|
||||||
|
appLogln("⚠️ [startup-postwork] .muxing.ts leer, aber löschen fehlgeschlagen:", name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
removed++
|
||||||
|
appLogln("🗑️ [startup-postwork] leere .muxing.ts gelöscht:", name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
age := now.Sub(fi.ModTime())
|
||||||
|
if age < startupMuxingTempMinAge {
|
||||||
|
skipped++
|
||||||
|
appLogln(
|
||||||
|
"⏭️ [startup-postwork] .muxing.ts zu frisch:",
|
||||||
|
name,
|
||||||
|
"age=", age.Round(time.Second),
|
||||||
|
"min=", startupMuxingTempMinAge,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !singleFileStable(path) {
|
||||||
|
skipped++
|
||||||
|
appLogln("⏭️ [startup-postwork] .muxing.ts nicht stabil:", name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Falls für diesen temporären Namen ein Audio-Sidecar existiert, direkt mit entfernen.
|
||||||
|
removedAudioSidecars := removePostworkAudioSidecarsForOutput(path)
|
||||||
|
|
||||||
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||||
|
skipped++
|
||||||
|
appLogln("⚠️ [startup-postwork] .muxing.ts löschen fehlgeschlagen:", name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
removed++
|
||||||
|
appLogln(
|
||||||
|
"🗑️ [startup-postwork] alte .muxing.ts gelöscht:",
|
||||||
|
name,
|
||||||
|
"audioSidecars=", len(removedAudioSidecars),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return removed, skipped
|
||||||
|
}
|
||||||
|
|
||||||
|
func isMuxingTempTSName(name string) bool {
|
||||||
|
name = strings.TrimSpace(filepath.Base(name))
|
||||||
|
if name == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
lowerName := strings.ToLower(name)
|
||||||
|
|
||||||
|
return strings.HasSuffix(lowerName, ".muxing.ts")
|
||||||
|
}
|
||||||
|
|
||||||
|
func singleFileStable(path string) bool {
|
||||||
|
path = strings.TrimSpace(path)
|
||||||
|
if path == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
sizeOf := func(p string) (int64, bool) {
|
||||||
|
fi, err := os.Stat(p)
|
||||||
|
if err != nil || fi == nil || fi.IsDir() {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return fi.Size(), true
|
||||||
|
}
|
||||||
|
|
||||||
|
size1, ok1 := sizeOf(path)
|
||||||
|
if !ok1 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(1500 * time.Millisecond)
|
||||||
|
|
||||||
|
size2, ok2 := sizeOf(path)
|
||||||
|
if !ok2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return size1 == size2
|
||||||
|
}
|
||||||
|
|
||||||
func enqueuePostworkLeftoversFromRecordDir() (queued int, skipped int) {
|
func enqueuePostworkLeftoversFromRecordDir() (queued int, skipped int) {
|
||||||
s := getSettings()
|
s := getSettings()
|
||||||
|
|
||||||
|
|||||||
@ -33,7 +33,12 @@ type cleanupResp struct {
|
|||||||
DeletedBytesHuman string `json:"deletedBytesHuman"`
|
DeletedBytesHuman string `json:"deletedBytesHuman"`
|
||||||
DeletedRecordOrphans int `json:"deletedRecordOrphans"`
|
DeletedRecordOrphans int `json:"deletedRecordOrphans"`
|
||||||
DeletedRecordOrphanBytes int64 `json:"deletedRecordOrphanBytes"`
|
DeletedRecordOrphanBytes int64 `json:"deletedRecordOrphanBytes"`
|
||||||
ErrorCount int `json:"errorCount"`
|
|
||||||
|
DeletedMuxingTemps int `json:"deletedMuxingTemps"`
|
||||||
|
DeletedAudioOrphans int `json:"deletedAudioOrphans"`
|
||||||
|
DeletedTSOrphans int `json:"deletedTSOrphans"`
|
||||||
|
|
||||||
|
ErrorCount int `json:"errorCount"`
|
||||||
|
|
||||||
// Generated-GC
|
// Generated-GC
|
||||||
GeneratedOrphansChecked int `json:"generatedOrphansChecked"`
|
GeneratedOrphansChecked int `json:"generatedOrphansChecked"`
|
||||||
@ -246,7 +251,7 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
finishCleanupJob(
|
finishCleanupJob(
|
||||||
jobID,
|
jobID,
|
||||||
cleanupProgressText(resp.DeletedFiles, resp.DeletedBrokenFiles, resp.ScannedFiles, resp.DeletedBytes),
|
cleanupProgressText(resp),
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
}(jobID, ctx, doneAbs, recordAbs, mb)
|
}(jobID, ctx, doneAbs, recordAbs, mb)
|
||||||
@ -278,23 +283,42 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func cleanupProgressText(deleted int, broken int, total int, freedBytes int64) string {
|
func cleanupProgressText(resp cleanupResp) string {
|
||||||
if broken > 0 {
|
parts := []string{
|
||||||
return fmt.Sprintf(
|
fmt.Sprintf("gelöscht: %d/%d", resp.DeletedFiles, resp.ScannedFiles),
|
||||||
"gelöscht: %d/%d · fehlerhaft: %d · freigegeben: %s",
|
|
||||||
deleted,
|
|
||||||
total,
|
|
||||||
broken,
|
|
||||||
formatBytesSI(freedBytes),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf(
|
if resp.DeletedBrokenFiles > 0 {
|
||||||
"gelöscht: %d/%d · freigegeben: %s",
|
parts = append(parts, fmt.Sprintf("defekt: %d", resp.DeletedBrokenFiles))
|
||||||
deleted,
|
}
|
||||||
total,
|
|
||||||
formatBytesSI(freedBytes),
|
if resp.DeletedRecordOrphans > 0 {
|
||||||
)
|
parts = append(parts, fmt.Sprintf("Records: %d", resp.DeletedRecordOrphans))
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.DeletedTSOrphans > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("TS ohne Audio: %d", resp.DeletedTSOrphans))
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.DeletedAudioOrphans > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("Audio ohne TS: %d", resp.DeletedAudioOrphans))
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.DeletedMuxingTemps > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("Muxing-Temp: %d", resp.DeletedMuxingTemps))
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.GeneratedOrphansRemoved > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("Generated: %d", resp.GeneratedOrphansRemoved))
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.ErrorCount > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("Fehler: %d", resp.ErrorCount))
|
||||||
|
}
|
||||||
|
|
||||||
|
parts = append(parts, fmt.Sprintf("freigegeben: %s", formatBytesSI(resp.DeletedBytes)))
|
||||||
|
|
||||||
|
return strings.Join(parts, " · ")
|
||||||
}
|
}
|
||||||
|
|
||||||
func cleanupVideoLooksBroken(ctx context.Context, path string) (bool, string) {
|
func cleanupVideoLooksBroken(ctx context.Context, path string) (bool, string) {
|
||||||
@ -444,10 +468,14 @@ func cleanupDeleteRecordOrphanFile(ctx context.Context, jobID string, path strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
fi, err := os.Stat(path)
|
fi, err := os.Stat(path)
|
||||||
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
|
if err != nil || fi == nil || fi.IsDir() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
size := fi.Size()
|
||||||
|
name := filepath.Base(path)
|
||||||
|
lowName := strings.ToLower(name)
|
||||||
|
|
||||||
if time.Since(fi.ModTime()) < cleanupRecordOrphanMinAge {
|
if time.Since(fi.ModTime()) < cleanupRecordOrphanMinAge {
|
||||||
resp.SkippedFiles++
|
resp.SkippedFiles++
|
||||||
return
|
return
|
||||||
@ -458,38 +486,49 @@ func cleanupDeleteRecordOrphanFile(ctx context.Context, jobID string, path strin
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !cleanupFileStable(path) {
|
// 0-KB-Dateien sind stabil genug zum Löschen.
|
||||||
|
// Für >0 Bytes weiter die Stabilitätsprüfung behalten.
|
||||||
|
if size > 0 && !cleanupFileStable(path) {
|
||||||
resp.SkippedFiles++
|
resp.SkippedFiles++
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
updateCleanupJobState(jobID, func(st *CleanupTaskState) {
|
updateCleanupJobState(jobID, func(st *CleanupTaskState) {
|
||||||
st.CurrentFile = filepath.Base(path)
|
st.CurrentFile = name
|
||||||
st.Text = "Räume verwaiste Record-Dateien auf…"
|
st.Text = "Räume Record-Reste auf…"
|
||||||
})
|
})
|
||||||
|
|
||||||
if err := removeWithRetry(path); err != nil && !os.IsNotExist(err) {
|
if err := removeWithRetry(path); err != nil && !os.IsNotExist(err) {
|
||||||
resp.ErrorCount++
|
resp.ErrorCount++
|
||||||
appLogln("⚠️ cleanup record orphan konnte nicht gelöscht werden:", filepath.Base(path), reason, err)
|
appLogln("⚠️ cleanup record orphan konnte nicht gelöscht werden:", name, reason, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resp.DeletedFiles++
|
resp.DeletedFiles++
|
||||||
resp.DeletedRecordOrphans++
|
resp.DeletedRecordOrphans++
|
||||||
resp.DeletedBytes += fi.Size()
|
resp.DeletedBytes += size
|
||||||
resp.DeletedRecordOrphanBytes += fi.Size()
|
resp.DeletedRecordOrphanBytes += size
|
||||||
|
|
||||||
|
if strings.HasSuffix(lowName, ".muxing.ts") {
|
||||||
|
resp.DeletedMuxingTemps++
|
||||||
|
} else if strings.HasPrefix(name, ".") && strings.HasSuffix(lowName, ".audio.m4s") {
|
||||||
|
resp.DeletedAudioOrphans++
|
||||||
|
} else if !strings.HasPrefix(name, ".") && strings.EqualFold(filepath.Ext(name), ".ts") {
|
||||||
|
resp.DeletedTSOrphans++
|
||||||
|
}
|
||||||
|
|
||||||
purgeDurationCacheForPath(path)
|
purgeDurationCacheForPath(path)
|
||||||
|
|
||||||
base := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
base := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
||||||
base = strings.TrimPrefix(base, ".")
|
base = strings.TrimPrefix(base, ".")
|
||||||
base = strings.TrimSuffix(base, ".audio")
|
base = strings.TrimSuffix(base, ".audio")
|
||||||
|
base = strings.TrimSuffix(base, ".muxing")
|
||||||
|
|
||||||
if strings.TrimSpace(base) != "" {
|
if strings.TrimSpace(base) != "" {
|
||||||
removeGeneratedForID(stripHotPrefix(base))
|
removeGeneratedForID(stripHotPrefix(base))
|
||||||
}
|
}
|
||||||
|
|
||||||
appLogln("🧹 cleanup deleted record orphan:", filepath.Base(path), "reason:", reason)
|
appLogln("🧹 cleanup deleted record orphan:", name, "reason:", reason, "size=", size)
|
||||||
}
|
}
|
||||||
|
|
||||||
func cleanupRecordDirOrphanAVFiles(ctx context.Context, jobID string, recordAbs string, resp *cleanupResp) error {
|
func cleanupRecordDirOrphanAVFiles(ctx context.Context, jobID string, recordAbs string, resp *cleanupResp) error {
|
||||||
@ -522,6 +561,14 @@ func cleanupRecordDirOrphanAVFiles(ctx context.Context, jobID string, recordAbs
|
|||||||
full := filepath.Join(recordAbs, name)
|
full := filepath.Join(recordAbs, name)
|
||||||
low := strings.ToLower(name)
|
low := strings.ToLower(name)
|
||||||
|
|
||||||
|
// Fall 0:
|
||||||
|
// alte .muxing.ts sind temporäre Remux-/Muxing-Reste.
|
||||||
|
if strings.HasSuffix(low, ".muxing.ts") {
|
||||||
|
resp.ScannedFiles++
|
||||||
|
cleanupDeleteRecordOrphanFile(ctx, jobID, full, resp, "stale muxing temp")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Fall 1:
|
// Fall 1:
|
||||||
// foo.ts existiert, aber .foo.audio.m4s fehlt.
|
// foo.ts existiert, aber .foo.audio.m4s fehlt.
|
||||||
if !strings.HasPrefix(name, ".") && strings.EqualFold(filepath.Ext(name), ".ts") {
|
if !strings.HasPrefix(name, ".") && strings.EqualFold(filepath.Ext(name), ".ts") {
|
||||||
@ -658,7 +705,7 @@ func cleanupSmallFiles(ctx context.Context, jobID string, doneAbs string, thresh
|
|||||||
st.Done = 0
|
st.Done = 0
|
||||||
st.Total = resp.ScannedFiles
|
st.Total = resp.ScannedFiles
|
||||||
st.CurrentFile = ""
|
st.CurrentFile = ""
|
||||||
st.Text = cleanupProgressText(resp.DeletedFiles, resp.DeletedBrokenFiles, resp.ScannedFiles, resp.DeletedBytes)
|
st.Text = cleanupProgressText(*resp)
|
||||||
st.Error = ""
|
st.Error = ""
|
||||||
st.FinishedAt = nil
|
st.FinishedAt = nil
|
||||||
})
|
})
|
||||||
@ -716,7 +763,7 @@ func cleanupSmallFiles(ctx context.Context, jobID string, doneAbs string, thresh
|
|||||||
st.Done = i + 1
|
st.Done = i + 1
|
||||||
st.Total = resp.ScannedFiles
|
st.Total = resp.ScannedFiles
|
||||||
st.CurrentFile = c.name
|
st.CurrentFile = c.name
|
||||||
st.Text = cleanupProgressText(resp.DeletedFiles, resp.DeletedBrokenFiles, resp.ScannedFiles, resp.DeletedBytes)
|
st.Text = cleanupProgressText(*resp)
|
||||||
st.Error = ""
|
st.Error = ""
|
||||||
st.FinishedAt = nil
|
st.FinishedAt = nil
|
||||||
})
|
})
|
||||||
|
|||||||
@ -69,11 +69,23 @@ const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = {
|
|||||||
useMyFreeCamsWatcher: false,
|
useMyFreeCamsWatcher: false,
|
||||||
autoDeleteSmallDownloads: false,
|
autoDeleteSmallDownloads: false,
|
||||||
autoDeleteSmallDownloadsBelowMB: 200,
|
autoDeleteSmallDownloadsBelowMB: 200,
|
||||||
|
autoDeleteSmallDownloadsKeepFavorites: true,
|
||||||
blurPreviews: false,
|
blurPreviews: false,
|
||||||
teaserPlayback: 'hover',
|
teaserPlayback: 'hover',
|
||||||
teaserAudio: false,
|
teaserAudio: false,
|
||||||
lowDiskPauseBelowGB: 5,
|
lowDiskPauseBelowGB: 5,
|
||||||
enableNotifications: true,
|
enableNotifications: true,
|
||||||
|
|
||||||
|
generateAssetsMeta: true,
|
||||||
|
generateAssetsThumb: true,
|
||||||
|
generateAssetsTeaser: true,
|
||||||
|
generateAssetsSprites: true,
|
||||||
|
generateAssetsAnalyze: true,
|
||||||
|
|
||||||
|
enrichPostworkEnabled: true,
|
||||||
|
|
||||||
|
trainingRecognitionEnabled: true,
|
||||||
|
trainingDetectorEpochs: 60,
|
||||||
}
|
}
|
||||||
|
|
||||||
type StoredModel = {
|
type StoredModel = {
|
||||||
@ -3812,14 +3824,30 @@ export default function App() {
|
|||||||
return <LoginPage onLoggedIn={checkAuth} />
|
return <LoginPage onLoggedIn={checkAuth} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatHeaderStatValue = (value: unknown) => {
|
||||||
|
const n = Number(value)
|
||||||
|
|
||||||
|
if (!Number.isFinite(n)) return '0'
|
||||||
|
if (n >= 10000) return `${Math.round(n / 1000)}k`
|
||||||
|
if (n >= 1000) return `${Number((n / 1000).toFixed(1))}k`
|
||||||
|
|
||||||
|
return String(Math.max(0, Math.floor(n)))
|
||||||
|
}
|
||||||
|
|
||||||
const headerShellClass =
|
const headerShellClass =
|
||||||
'rounded-2xl border border-gray-200/70 bg-white/65 p-3 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-white/[0.06]'
|
'rounded-2xl border border-gray-200/70 bg-white/65 p-3 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-white/[0.06] max-lg:[@media_(orientation:landscape)]:rounded-xl max-lg:[@media_(orientation:landscape)]:p-2'
|
||||||
|
|
||||||
const headerStatClass =
|
const headerStatClass =
|
||||||
'inline-flex h-8 items-center gap-1.5 rounded-xl border border-gray-200/70 bg-white/75 px-2.5 text-[11px] font-semibold text-gray-700 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/10 dark:text-gray-200'
|
'inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-xl border border-gray-200/70 bg-white/75 px-2.5 text-[11px] font-semibold text-gray-700 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/10 dark:text-gray-200'
|
||||||
|
|
||||||
|
const mobileHeaderStatClass =
|
||||||
|
'flex h-8 min-w-0 items-center justify-center gap-1 rounded-lg border border-gray-200/70 bg-white/75 px-1.5 text-[10px] font-semibold text-gray-700 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/10 dark:text-gray-200'
|
||||||
|
|
||||||
|
const headerStatValueClass =
|
||||||
|
'inline-block min-w-[3ch] text-right tabular-nums text-gray-950 dark:text-white'
|
||||||
|
|
||||||
const headerActionClass =
|
const headerActionClass =
|
||||||
'h-9 shrink-0 rounded-xl px-3 text-sm'
|
'h-9 shrink-0 rounded-xl px-3 text-sm max-lg:[@media_(orientation:landscape)]:h-8 max-lg:[@media_(orientation:landscape)]:rounded-lg max-lg:[@media_(orientation:landscape)]:px-2.5 max-lg:[@media_(orientation:landscape)]:text-xs'
|
||||||
|
|
||||||
const headerStatItems = [
|
const headerStatItems = [
|
||||||
{
|
{
|
||||||
@ -3858,60 +3886,79 @@ export default function App() {
|
|||||||
|
|
||||||
<div className="relative lg:flex lg:h-full lg:min-h-0 lg:flex-col">
|
<div className="relative lg:flex lg:h-full lg:min-h-0 lg:flex-col">
|
||||||
<header className="z-[70] shrink-0 border-b border-gray-200/70 bg-white/75 backdrop-blur-xl dark:border-white/10 dark:bg-gray-950/70 sm:sticky sm:top-0">
|
<header className="z-[70] shrink-0 border-b border-gray-200/70 bg-white/75 backdrop-blur-xl dark:border-white/10 dark:bg-gray-950/70 sm:sticky sm:top-0">
|
||||||
<div className="mx-auto max-w-7xl p-3">
|
<div className="mx-auto max-w-7xl p-3 max-lg:[@media_(orientation:landscape)]:p-1.5">
|
||||||
<div className={headerShellClass}>
|
<div className={headerShellClass}>
|
||||||
<div className="grid gap-2.5">
|
<div className="grid gap-2.5 max-lg:[@media_(orientation:landscape)]:gap-1.5">
|
||||||
<div className="flex flex-col gap-2.5 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex flex-col gap-2.5 max-lg:[@media_(orientation:landscape)]:flex-row max-lg:[@media_(orientation:landscape)]:items-start max-lg:[@media_(orientation:landscape)]:justify-between max-lg:[@media_(orientation:landscape)]:gap-2 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="min-w-0">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-indigo-600 text-white shadow-sm shadow-indigo-600/20 max-lg:[@media_(orientation:landscape)]:h-8 max-lg:[@media_(orientation:landscape)]:w-8 max-lg:[@media_(orientation:landscape)]:rounded-lg">
|
||||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-indigo-600 text-white shadow-sm shadow-indigo-600/20">
|
<ArrowDownTrayIcon className="h-5 w-5" aria-hidden="true" />
|
||||||
<ArrowDownTrayIcon className="h-5 w-5" aria-hidden="true" />
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 className="shrink-0 text-base font-bold tracking-tight text-gray-950 dark:text-white sm:text-lg">
|
<h1 className="shrink-0 text-base font-bold tracking-tight text-gray-950 dark:text-white sm:text-lg max-lg:[@media_(orientation:landscape)]:text-base">
|
||||||
Recorder
|
Recorder
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
<div className="hidden min-w-0 flex-wrap items-center gap-1.5 lg:flex">
|
||||||
{headerStatItems.map((item) => {
|
{headerStatItems.map((item) => {
|
||||||
const Icon = item.icon
|
const Icon = item.icon
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={item.label}
|
key={item.label}
|
||||||
className={headerStatClass}
|
className={headerStatClass}
|
||||||
>
|
title={item.label}
|
||||||
<Icon className="h-4 w-4 opacity-80" aria-hidden="true" />
|
>
|
||||||
|
<Icon className="h-4 w-4 opacity-80" aria-hidden="true" />
|
||||||
|
|
||||||
<span className="tabular-nums text-gray-950 dark:text-white">
|
<span className={headerStatValueClass}>
|
||||||
{item.value}
|
{formatHeaderStatValue(item.value)}
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
)
|
</span>
|
||||||
})}
|
)
|
||||||
</div>
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-1 text-[10px] leading-tight text-gray-500 dark:text-gray-400 sm:text-[11px]">
|
<div className="mt-2 grid grid-cols-4 gap-1.5 lg:hidden max-lg:[@media_(orientation:landscape)]:hidden">
|
||||||
<LastUpdatedText
|
{headerStatItems.map((item) => {
|
||||||
enabled={Boolean(recSettings.useChaturbateApi)}
|
const Icon = item.icon
|
||||||
fetchedAt={cbOnlineFetchedAt}
|
|
||||||
/>
|
return (
|
||||||
</div>
|
<span
|
||||||
|
key={item.label}
|
||||||
|
className={mobileHeaderStatClass}
|
||||||
|
title={item.label}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 shrink-0 opacity-80" aria-hidden="true" />
|
||||||
|
|
||||||
|
<span className={headerStatValueClass}>
|
||||||
|
{formatHeaderStatValue(item.value)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1 text-[10px] leading-tight text-gray-500 dark:text-gray-400 sm:text-[11px] max-lg:[@media_(orientation:landscape)]:hidden">
|
||||||
|
<LastUpdatedText
|
||||||
|
enabled={Boolean(recSettings.useChaturbateApi)}
|
||||||
|
fetchedAt={cbOnlineFetchedAt}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center lg:justify-end">
|
<div className="flex flex-col gap-2 max-lg:[@media_(orientation:landscape)]:flex-row max-lg:[@media_(orientation:landscape)]:items-center max-lg:[@media_(orientation:landscape)]:justify-end sm:flex-row sm:items-center lg:justify-end">
|
||||||
{showPerfMon ? (
|
{showPerfMon ? (
|
||||||
<PerformanceMonitor
|
<PerformanceMonitor
|
||||||
mode="inline"
|
mode="inline"
|
||||||
compact
|
compact
|
||||||
className="w-full sm:w-[300px] lg:w-[360px]"
|
className="w-full max-lg:[@media_(orientation:landscape)]:w-[300px] sm:w-[300px] lg:w-[360px]"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-1.5 sm:flex sm:items-center sm:gap-2">
|
<div className="grid grid-cols-2 gap-1.5 max-lg:[@media_(orientation:landscape)]:flex max-lg:[@media_(orientation:landscape)]:items-center max-lg:[@media_(orientation:landscape)]:gap-2 sm:flex sm:items-center sm:gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => setCookieModalOpen(true)}
|
onClick={() => setCookieModalOpen(true)}
|
||||||
@ -3938,8 +3985,8 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
|
<div className="grid min-w-0 gap-2 max-lg:[@media_(orientation:landscape)]:grid-cols-[minmax(0,1fr)_7.5rem] max-lg:[@media_(orientation:landscape)]:items-center lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
|
||||||
<div className="relative">
|
<div className="relative min-w-0">
|
||||||
<label className="sr-only">Source URL</label>
|
<label className="sr-only">Source URL</label>
|
||||||
<TextInput
|
<TextInput
|
||||||
ref={sourceUrlInputRef}
|
ref={sourceUrlInputRef}
|
||||||
@ -4002,6 +4049,7 @@ export default function App() {
|
|||||||
onChange={setSelectedTab}
|
onChange={setSelectedTab}
|
||||||
ariaLabel="Tabs"
|
ariaLabel="Tabs"
|
||||||
variant="barUnderline"
|
variant="barUnderline"
|
||||||
|
desktopBreakpoint="lg"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -640,7 +640,7 @@ export default function Downloads({
|
|||||||
enableConcurrentDownloadsLimit = false,
|
enableConcurrentDownloadsLimit = false,
|
||||||
maxConcurrentDownloads = 20,
|
maxConcurrentDownloads = 20,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const isDesktop = useMediaQuery('(min-width: 640px)', true)
|
const isDesktop = useMediaQuery('(min-width: 1024px)', true)
|
||||||
|
|
||||||
const [stopAllBusy, setStopAllBusy] = useState(false)
|
const [stopAllBusy, setStopAllBusy] = useState(false)
|
||||||
|
|
||||||
|
|||||||
@ -4725,7 +4725,7 @@ export default function FinishedDownloads({
|
|||||||
<>
|
<>
|
||||||
<div className="grid w-full gap-4 px-4 sm:px-6 lg:grid-cols-[18rem_minmax(0,1fr)] lg:items-start lg:pl-8 lg:pr-[max(2rem,calc((100vw-80rem)/2+2rem))] xl:grid-cols-[19rem_minmax(0,1fr)]">
|
<div className="grid w-full gap-4 px-4 sm:px-6 lg:grid-cols-[18rem_minmax(0,1fr)] lg:items-start lg:pl-8 lg:pr-[max(2rem,calc((100vw-80rem)/2+2rem))] xl:grid-cols-[19rem_minmax(0,1fr)]">
|
||||||
{/* Toolbar links neben dem Content, nicht per Transform aus dem Container geschoben */}
|
{/* Toolbar links neben dem Content, nicht per Transform aus dem Container geschoben */}
|
||||||
<aside className="hidden min-w-0 lg:sticky lg:top-0 lg:col-start-1 lg:row-start-1 lg:z-[60] lg:block lg:w-[18rem] lg:justify-self-end lg:self-start lg:max-h-[calc(100dvh-14rem)] lg:overflow-y-auto lg:overflow-x-hidden xl:w-[19rem]">
|
<aside className="relative z-40 min-w-0 lg:sticky lg:top-0 lg:col-start-1 lg:row-start-1 lg:z-[60] lg:w-[18rem] lg:justify-self-end lg:self-start lg:max-h-[calc(100dvh-14rem)] lg:overflow-y-auto lg:overflow-x-hidden xl:w-[19rem]">
|
||||||
<div
|
<div
|
||||||
className="
|
className="
|
||||||
rounded-xl border border-gray-200/70 bg-white/95 shadow-sm
|
rounded-xl border border-gray-200/70 bg-white/95 shadow-sm
|
||||||
@ -4735,7 +4735,7 @@ export default function FinishedDownloads({
|
|||||||
>
|
>
|
||||||
{/* Desktop */}
|
{/* Desktop */}
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<div className="hidden sm:block px-3 py-2.5">
|
<div className="hidden lg:block px-3 py-2.5">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@ -4786,7 +4786,7 @@ export default function FinishedDownloads({
|
|||||||
: '-translate-y-1 pointer-events-none',
|
: '-translate-y-1 pointer-events-none',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
<div className="sm:hidden">
|
<div className="lg:hidden">
|
||||||
{bulkBusy ? (
|
{bulkBusy ? (
|
||||||
<div className="mb-2 flex items-center gap-1.5 text-[11px] text-gray-600 dark:text-gray-300">
|
<div className="mb-2 flex items-center gap-1.5 text-[11px] text-gray-600 dark:text-gray-300">
|
||||||
<LoadingSpinner size="sm" srLabel="Bulk-Aktion läuft…" />
|
<LoadingSpinner size="sm" srLabel="Bulk-Aktion läuft…" />
|
||||||
@ -4851,7 +4851,7 @@ export default function FinishedDownloads({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden sm:grid sm:grid-cols-2 sm:gap-2">
|
<div className="hidden lg:grid lg:grid-cols-2 lg:gap-2">
|
||||||
{bulkBusy ? (
|
{bulkBusy ? (
|
||||||
<div className="mr-2 flex items-center gap-2 text-xs text-gray-600 dark:text-gray-300">
|
<div className="mr-2 flex items-center gap-2 text-xs text-gray-600 dark:text-gray-300">
|
||||||
<LoadingSpinner size="sm" srLabel="Bulk-Aktion läuft…" />
|
<LoadingSpinner size="sm" srLabel="Bulk-Aktion läuft…" />
|
||||||
@ -5079,7 +5079,7 @@ export default function FinishedDownloads({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile */}
|
{/* Mobile */}
|
||||||
<div className="sm:hidden">
|
<div className="lg:hidden">
|
||||||
<div className="flex items-center gap-2 px-3 pt-2.5 pb-1.5">
|
<div className="flex items-center gap-2 px-3 pt-2.5 pb-1.5">
|
||||||
<div className="min-w-0 flex flex-1 items-center gap-2">
|
<div className="min-w-0 flex flex-1 items-center gap-2">
|
||||||
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
|
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
@ -5185,7 +5185,7 @@ export default function FinishedDownloads({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(allKnownTags.length > 0 || tagFilter.length > 0) ? (
|
{(allKnownTags.length > 0 || tagFilter.length > 0) ? (
|
||||||
<div className="hidden sm:block border-t border-gray-200/60 dark:border-white/10 px-3 py-2.5">
|
<div className="hidden lg:block border-t border-gray-200/60 dark:border-white/10 px-3 py-2.5">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="text-xs font-medium text-gray-600 dark:text-gray-300">
|
<div className="text-xs font-medium text-gray-600 dark:text-gray-300">
|
||||||
Tag-Filter
|
Tag-Filter
|
||||||
@ -5201,7 +5201,7 @@ export default function FinishedDownloads({
|
|||||||
<div
|
<div
|
||||||
id="finished-mobile-options"
|
id="finished-mobile-options"
|
||||||
className={[
|
className={[
|
||||||
'sm:hidden overflow-hidden transition-[max-height,opacity] duration-200 ease-in-out',
|
'lg:hidden overflow-hidden transition-[max-height,opacity] duration-200 ease-in-out',
|
||||||
mobileOptionsOpen ? 'max-h-[720px] opacity-100' : 'max-h-0 opacity-0',
|
mobileOptionsOpen ? 'max-h-[720px] opacity-100' : 'max-h-0 opacity-0',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -43,9 +43,7 @@ import {
|
|||||||
type FinishedSelectionStore,
|
type FinishedSelectionStore,
|
||||||
} from './finishedSelectionStore'
|
} from './finishedSelectionStore'
|
||||||
import SpritePreloader from './SpritePreloader'
|
import SpritePreloader from './SpritePreloader'
|
||||||
import FinishedDownloadsDesktopCard, {
|
import FinishedDownloadsDesktopCard from './FinishedDownloadsDesktopCard'
|
||||||
SelectionCheckboxOverlay,
|
|
||||||
} from './FinishedDownloadsDesktopCard'
|
|
||||||
import PreviewRatingOverlay from './PreviewRatingOverlay'
|
import PreviewRatingOverlay from './PreviewRatingOverlay'
|
||||||
import PreviewMetaOverlay from './PreviewMetaOverlay'
|
import PreviewMetaOverlay from './PreviewMetaOverlay'
|
||||||
|
|
||||||
@ -641,15 +639,6 @@ function FinishedDownloadsCardsView({
|
|||||||
blurred={opts?.blur}
|
blurred={opts?.blur}
|
||||||
animateUnblurOnMount={opts?.animateUnblurOnMount}
|
animateUnblurOnMount={opts?.animateUnblurOnMount}
|
||||||
>
|
>
|
||||||
{!isSmall ? (
|
|
||||||
<SelectionCheckboxOverlay
|
|
||||||
selectionStore={selectionStore}
|
|
||||||
rowKey={k}
|
|
||||||
label={`Auswählen: ${baseName(j.output || '') || 'Download'}`}
|
|
||||||
onToggle={() => onToggleSelected(j)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Card shell keeps backgrounds consistent */}
|
{/* Card shell keeps backgrounds consistent */}
|
||||||
<Card noBodyPadding className="overflow-hidden bg-transparent">
|
<Card noBodyPadding className="overflow-hidden bg-transparent">
|
||||||
{/* Preview */}
|
{/* Preview */}
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { memo, useCallback, type ReactNode } from 'react'
|
import { memo, useCallback, useRef, type ReactNode } from 'react'
|
||||||
import Card from './Card'
|
import Card from './Card'
|
||||||
import Checkbox from './Checkbox'
|
import Checkbox from './Checkbox'
|
||||||
import CountryFlag, { resolveFinishedCountry } from './CountryFlag'
|
import CountryFlag, { resolveFinishedCountry } from './CountryFlag'
|
||||||
@ -116,37 +116,6 @@ function inlineStateForKey(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SelectionCheckboxOverlay({
|
|
||||||
selectionStore,
|
|
||||||
rowKey,
|
|
||||||
label,
|
|
||||||
onToggle,
|
|
||||||
}: {
|
|
||||||
selectionStore: FinishedSelectionStore
|
|
||||||
rowKey: string
|
|
||||||
label: string
|
|
||||||
onToggle: () => void
|
|
||||||
}) {
|
|
||||||
const checked = useSelectionChecked(selectionStore, rowKey)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={[
|
|
||||||
'absolute left-3 top-3 z-20 transition-opacity duration-150',
|
|
||||||
checked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
|
|
||||||
].join(' ')}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
checked={checked}
|
|
||||||
ariaLabel={label}
|
|
||||||
onChange={onToggle}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const FinishedDownloadsDesktopCard = memo(
|
const FinishedDownloadsDesktopCard = memo(
|
||||||
function FinishedDownloadsDesktopCard({
|
function FinishedDownloadsDesktopCard({
|
||||||
job: j,
|
job: j,
|
||||||
@ -195,6 +164,9 @@ const FinishedDownloadsDesktopCard = memo(
|
|||||||
}: FinishedDownloadsDesktopCardProps) {
|
}: FinishedDownloadsDesktopCardProps) {
|
||||||
const k = keyFor(j)
|
const k = keyFor(j)
|
||||||
const busy = isDeleting || isKeeping || isRemoving
|
const busy = isDeleting || isKeeping || isRemoving
|
||||||
|
const checked = useSelectionChecked(selectionStore, k)
|
||||||
|
const longPressTimerRef = useRef<number | null>(null)
|
||||||
|
const longPressTriggeredRef = useRef(false)
|
||||||
const inlineActive = inlinePlay?.key === k
|
const inlineActive = inlinePlay?.key === k
|
||||||
const inlineNonce = inlineActive ? inlinePlay?.nonce ?? 0 : 0
|
const inlineNonce = inlineActive ? inlinePlay?.nonce ?? 0 : 0
|
||||||
|
|
||||||
@ -213,6 +185,25 @@ const FinishedDownloadsDesktopCard = memo(
|
|||||||
return false
|
return false
|
||||||
}, [selectionStore, onToggleSelected, j])
|
}, [selectionStore, onToggleSelected, j])
|
||||||
|
|
||||||
|
const clearLongPressTimer = useCallback(() => {
|
||||||
|
if (longPressTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(longPressTimerRef.current)
|
||||||
|
longPressTimerRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const startLongPressSelect = useCallback(() => {
|
||||||
|
clearLongPressTimer()
|
||||||
|
|
||||||
|
if (selectionStore.hasAnySelection()) return
|
||||||
|
|
||||||
|
longPressTimerRef.current = window.setTimeout(() => {
|
||||||
|
longPressTimerRef.current = null
|
||||||
|
longPressTriggeredRef.current = true
|
||||||
|
onToggleSelected(j)
|
||||||
|
}, 450)
|
||||||
|
}, [clearLongPressTimer, selectionStore, onToggleSelected, j])
|
||||||
|
|
||||||
const previewMuted = forcePreviewMuted ? true : !allowSound
|
const previewMuted = forcePreviewMuted ? true : !allowSound
|
||||||
|
|
||||||
const model = modelNameFromOutput(j.output)
|
const model = modelNameFromOutput(j.output)
|
||||||
@ -263,6 +254,7 @@ const FinishedDownloadsDesktopCard = memo(
|
|||||||
'transition-all duration-200',
|
'transition-all duration-200',
|
||||||
'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none',
|
'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none',
|
||||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500',
|
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500',
|
||||||
|
checked && 'ring-2 ring-indigo-500 bg-indigo-50/60 dark:bg-indigo-500/10 dark:ring-indigo-400',
|
||||||
busy && 'pointer-events-none opacity-70',
|
busy && 'pointer-events-none opacity-70',
|
||||||
isDeleting && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
|
isDeleting && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
|
||||||
isKeeping && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
|
isKeeping && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
|
||||||
@ -272,11 +264,19 @@ const FinishedDownloadsDesktopCard = memo(
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|
||||||
|
if (longPressTriggeredRef.current) {
|
||||||
|
longPressTriggeredRef.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (handleSelectOrOpen()) return
|
if (handleSelectOrOpen()) return
|
||||||
openPlayer(j)
|
openPlayer(j)
|
||||||
}}
|
}}
|
||||||
onMouseEnter={() => onHoverPreviewKeyChange?.(k)}
|
onMouseEnter={() => onHoverPreviewKeyChange?.(k)}
|
||||||
onMouseLeave={() => onHoverPreviewKeyChange?.(null)}
|
onMouseLeave={() => {
|
||||||
|
clearLongPressTimer()
|
||||||
|
onHoverPreviewKeyChange?.(null)
|
||||||
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@ -285,14 +285,34 @@ const FinishedDownloadsDesktopCard = memo(
|
|||||||
onOpenPlayer(j)
|
onOpenPlayer(j)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
if (e.pointerType === 'mouse') return
|
||||||
|
startLongPressSelect()
|
||||||
|
}}
|
||||||
|
onPointerUp={clearLongPressTimer}
|
||||||
|
onPointerCancel={clearLongPressTimer}
|
||||||
|
onPointerLeave={clearLongPressTimer}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
if (longPressTriggeredRef.current) {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SelectionCheckboxOverlay
|
<div
|
||||||
selectionStore={selectionStore}
|
className={[
|
||||||
rowKey={k}
|
'absolute left-3 top-3 z-40 hidden transition-opacity duration-150 lg:block',
|
||||||
label={`Auswählen: ${baseName(j.output || '') || 'Download'}`}
|
checked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
|
||||||
onToggle={() => onToggleSelected(j)}
|
].join(' ')}
|
||||||
/>
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={checked}
|
||||||
|
ariaLabel={`${checked ? 'Abwählen' : 'Auswählen'}: ${baseName(j.output || '') || 'Download'}`}
|
||||||
|
onChange={() => onToggleSelected(j)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<Card noBodyPadding className="overflow-hidden bg-transparent">
|
<Card noBodyPadding className="overflow-hidden bg-transparent">
|
||||||
<div
|
<div
|
||||||
id={inlineDomId}
|
id={inlineDomId}
|
||||||
@ -348,7 +368,18 @@ const FinishedDownloadsDesktopCard = memo(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="relative min-h-[112px] overflow-visible border-t border-black/5 bg-white px-4 py-3 dark:border-white/10 dark:bg-gray-900"
|
className={[
|
||||||
|
'relative min-h-[112px] overflow-visible border-t px-4 py-3 transition-colors',
|
||||||
|
checked
|
||||||
|
? [
|
||||||
|
'border-indigo-200 bg-indigo-50/90',
|
||||||
|
'dark:border-indigo-400/30 dark:bg-indigo-500/15',
|
||||||
|
].join(' ')
|
||||||
|
: [
|
||||||
|
'border-black/5 bg-white',
|
||||||
|
'dark:border-white/10 dark:bg-gray-900',
|
||||||
|
].join(' '),
|
||||||
|
].join(' ')}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|||||||
@ -13,12 +13,12 @@ import RecordJobActions from './RecordJobActions'
|
|||||||
import TagOverflowRow from './TagOverflowRow'
|
import TagOverflowRow from './TagOverflowRow'
|
||||||
import { isHotName, stripHotPrefix } from './hotName'
|
import { isHotName, stripHotPrefix } from './hotName'
|
||||||
import PreviewScrubber from './PreviewScrubber'
|
import PreviewScrubber from './PreviewScrubber'
|
||||||
|
import Checkbox from './Checkbox'
|
||||||
import type { FinishedDownloadsTeaserState } from './teaserPlayback'
|
import type { FinishedDownloadsTeaserState } from './teaserPlayback'
|
||||||
import {
|
import {
|
||||||
shouldAnimateTeaser,
|
shouldAnimateTeaser,
|
||||||
shouldEnableTeaserAudio,
|
shouldEnableTeaserAudio,
|
||||||
} from './teaserPlayback'
|
} from './teaserPlayback'
|
||||||
import Checkbox from './Checkbox'
|
|
||||||
import { SpeakerWaveIcon } from '@heroicons/react/24/solid'
|
import { SpeakerWaveIcon } from '@heroicons/react/24/solid'
|
||||||
import {
|
import {
|
||||||
buildSpriteFrameStyle,
|
buildSpriteFrameStyle,
|
||||||
@ -158,6 +158,8 @@ function FinishedDownloadsGalleryCardInner({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const k = rowKey
|
const k = rowKey
|
||||||
const checked = selectedKeys.has(rowKey)
|
const checked = selectedKeys.has(rowKey)
|
||||||
|
const longPressTimerRef = useRef<number | null>(null)
|
||||||
|
const longPressTriggeredRef = useRef(false)
|
||||||
|
|
||||||
const [scrubIndexByKey, setScrubIndexByKey] = useState<Record<string, number | undefined>>({})
|
const [scrubIndexByKey, setScrubIndexByKey] = useState<Record<string, number | undefined>>({})
|
||||||
const [hoveredThumbKey, setHoveredThumbKey] = useState<string | null>(null)
|
const [hoveredThumbKey, setHoveredThumbKey] = useState<string | null>(null)
|
||||||
@ -180,6 +182,11 @@ function FinishedDownloadsGalleryCardInner({
|
|||||||
e?.preventDefault()
|
e?.preventDefault()
|
||||||
e?.stopPropagation()
|
e?.stopPropagation()
|
||||||
|
|
||||||
|
if (longPressTriggeredRef.current) {
|
||||||
|
longPressTriggeredRef.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (selectionModeActive) {
|
if (selectionModeActive) {
|
||||||
onToggleSelected(j)
|
onToggleSelected(j)
|
||||||
return
|
return
|
||||||
@ -190,6 +197,25 @@ function FinishedDownloadsGalleryCardInner({
|
|||||||
[selectionModeActive, onToggleSelected, onOpenPlayer, j]
|
[selectionModeActive, onToggleSelected, onOpenPlayer, j]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const clearLongPressTimer = useCallback(() => {
|
||||||
|
if (longPressTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(longPressTimerRef.current)
|
||||||
|
longPressTimerRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const startLongPressSelect = useCallback(() => {
|
||||||
|
clearLongPressTimer()
|
||||||
|
|
||||||
|
if (selectionModeActive) return
|
||||||
|
|
||||||
|
longPressTimerRef.current = window.setTimeout(() => {
|
||||||
|
longPressTimerRef.current = null
|
||||||
|
longPressTriggeredRef.current = true
|
||||||
|
onToggleSelected(j)
|
||||||
|
}, 450)
|
||||||
|
}, [clearLongPressTimer, selectionModeActive, onToggleSelected, j])
|
||||||
|
|
||||||
const setScrubIndexForKey = useCallback((key: string, index: number | undefined) => {
|
const setScrubIndexForKey = useCallback((key: string, index: number | undefined) => {
|
||||||
setScrubIndexByKey((prev) => {
|
setScrubIndexByKey((prev) => {
|
||||||
if (index === undefined) {
|
if (index === undefined) {
|
||||||
@ -336,8 +362,21 @@ function FinishedDownloadsGalleryCardInner({
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
handlePrimaryClick(e)
|
handlePrimaryClick(e)
|
||||||
}}
|
}}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
if (e.pointerType === 'mouse') return
|
||||||
|
startLongPressSelect()
|
||||||
|
}}
|
||||||
|
onPointerUp={clearLongPressTimer}
|
||||||
|
onPointerCancel={clearLongPressTimer}
|
||||||
|
onPointerLeave={clearLongPressTimer}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
if (longPressTriggeredRef.current) {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
}}
|
||||||
onMouseEnter={() => onHoverPreviewKeyChange?.(k)}
|
onMouseEnter={() => onHoverPreviewKeyChange?.(k)}
|
||||||
onMouseLeave={() => {
|
onMouseLeave={() => {
|
||||||
|
clearLongPressTimer()
|
||||||
onHoverPreviewKeyChange?.(null)
|
onHoverPreviewKeyChange?.(null)
|
||||||
setHoveredThumbKey((prev) => (prev === k ? null : prev))
|
setHoveredThumbKey((prev) => (prev === k ? null : prev))
|
||||||
clearScrubIndex(k)
|
clearScrubIndex(k)
|
||||||
@ -354,6 +393,7 @@ function FinishedDownloadsGalleryCardInner({
|
|||||||
'transition-[transform,opacity,box-shadow,background-color] duration-180 ease-out',
|
'transition-[transform,opacity,box-shadow,background-color] duration-180 ease-out',
|
||||||
'will-change-[transform,opacity]',
|
'will-change-[transform,opacity]',
|
||||||
!busy && 'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none',
|
!busy && 'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none',
|
||||||
|
checked && 'ring-2 ring-indigo-500 bg-indigo-50/60 dark:bg-indigo-500/10 dark:ring-indigo-400',
|
||||||
busy && !isRemoving && 'pointer-events-none opacity-70',
|
busy && !isRemoving && 'pointer-events-none opacity-70',
|
||||||
isDeleting && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
|
isDeleting && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
|
||||||
isKeeping && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
|
isKeeping && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
|
||||||
@ -362,10 +402,11 @@ function FinishedDownloadsGalleryCardInner({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
'absolute left-3 top-3 z-20 transition-opacity duration-150',
|
'absolute left-3 top-3 z-40 hidden transition-opacity duration-150 lg:block',
|
||||||
checked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
|
checked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@ -374,7 +415,6 @@ function FinishedDownloadsGalleryCardInner({
|
|||||||
onChange={() => onToggleSelected(j)}
|
onChange={() => onToggleSelected(j)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="group/thumb relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
|
className="group/thumb relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
|
||||||
ref={registerTeaserHost(k)}
|
ref={registerTeaserHost(k)}
|
||||||
@ -513,7 +553,16 @@ function FinishedDownloadsGalleryCardInner({
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
'relative flex flex-col rounded-b-lg border-t border-black/5 bg-white dark:border-white/10 dark:bg-gray-900',
|
'relative flex flex-col rounded-b-lg border-t transition-colors',
|
||||||
|
checked
|
||||||
|
? [
|
||||||
|
'border-indigo-200 bg-indigo-50/90',
|
||||||
|
'dark:border-indigo-400/30 dark:bg-indigo-500/15',
|
||||||
|
].join(' ')
|
||||||
|
: [
|
||||||
|
'border-black/5 bg-white',
|
||||||
|
'dark:border-white/10 dark:bg-gray-900',
|
||||||
|
].join(' '),
|
||||||
footerMinHeightClass,
|
footerMinHeightClass,
|
||||||
contentPaddingClass,
|
contentPaddingClass,
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
|
|||||||
@ -280,18 +280,18 @@ export default function PerformanceMonitor({
|
|||||||
return (
|
return (
|
||||||
<div className={`${wrapperClass} ${className ?? ''}`}>
|
<div className={`${wrapperClass} ${className ?? ''}`}>
|
||||||
<div className={panelClass}>
|
<div className={panelClass}>
|
||||||
<div className="grid grid-cols-2 gap-1 md:grid-cols-4 sm:h-full sm:w-full sm:items-center">
|
<div className="grid w-full grid-cols-4 gap-1">
|
||||||
{metrics.map((item) => (
|
{metrics.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item.key}
|
key={item.key}
|
||||||
title={item.title}
|
title={item.title}
|
||||||
className="min-w-0 rounded-md px-1.5 py-0.5 sm:rounded-lg sm:px-2 sm:py-1"
|
className="min-w-0 rounded-md px-1 py-0.5 sm:rounded-lg sm:px-2 sm:py-1"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-1.5 whitespace-nowrap">
|
<div className="flex items-center justify-between gap-1 whitespace-nowrap">
|
||||||
<span className="min-w-0 truncate text-[9px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400 sm:text-[10px]">
|
<span className="min-w-0 truncate text-[8px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400 sm:text-[10px]">
|
||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
<span className="shrink-0 whitespace-nowrap text-[10px] font-semibold leading-none tabular-nums text-gray-900 dark:text-gray-100 sm:text-[11px]">
|
<span className="shrink-0 whitespace-nowrap text-[9px] font-semibold leading-none tabular-nums text-gray-900 dark:text-gray-100 sm:text-[11px]">
|
||||||
{item.value}
|
{item.value}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -38,6 +38,7 @@ type RecorderSettings = {
|
|||||||
generateAssetsTeaser?: boolean
|
generateAssetsTeaser?: boolean
|
||||||
generateAssetsSprites?: boolean
|
generateAssetsSprites?: boolean
|
||||||
generateAssetsAnalyze?: boolean
|
generateAssetsAnalyze?: boolean
|
||||||
|
enrichPostworkEnabled?: boolean
|
||||||
|
|
||||||
trainingRecognitionEnabled?: boolean
|
trainingRecognitionEnabled?: boolean
|
||||||
trainingDetectorEpochs?: number
|
trainingDetectorEpochs?: number
|
||||||
@ -203,6 +204,7 @@ const DEFAULTS: RecorderSettings = {
|
|||||||
generateAssetsTeaser: true,
|
generateAssetsTeaser: true,
|
||||||
generateAssetsSprites: true,
|
generateAssetsSprites: true,
|
||||||
generateAssetsAnalyze: true,
|
generateAssetsAnalyze: true,
|
||||||
|
enrichPostworkEnabled: true,
|
||||||
|
|
||||||
trainingRecognitionEnabled: true,
|
trainingRecognitionEnabled: true,
|
||||||
trainingDetectorEpochs: 60,
|
trainingDetectorEpochs: 60,
|
||||||
@ -592,6 +594,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
generateAssetsTeaser: (data as any).generateAssetsTeaser ?? DEFAULTS.generateAssetsTeaser,
|
generateAssetsTeaser: (data as any).generateAssetsTeaser ?? DEFAULTS.generateAssetsTeaser,
|
||||||
generateAssetsSprites: (data as any).generateAssetsSprites ?? DEFAULTS.generateAssetsSprites,
|
generateAssetsSprites: (data as any).generateAssetsSprites ?? DEFAULTS.generateAssetsSprites,
|
||||||
generateAssetsAnalyze: (data as any).generateAssetsAnalyze ?? DEFAULTS.generateAssetsAnalyze,
|
generateAssetsAnalyze: (data as any).generateAssetsAnalyze ?? DEFAULTS.generateAssetsAnalyze,
|
||||||
|
enrichPostworkEnabled: (data as any).enrichPostworkEnabled ?? DEFAULTS.enrichPostworkEnabled,
|
||||||
|
|
||||||
trainingRecognitionEnabled:
|
trainingRecognitionEnabled:
|
||||||
(data as any).trainingRecognitionEnabled ?? DEFAULTS.trainingRecognitionEnabled,
|
(data as any).trainingRecognitionEnabled ?? DEFAULTS.trainingRecognitionEnabled,
|
||||||
@ -989,6 +992,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
const generateAssetsTeaser = !!value.generateAssetsTeaser
|
const generateAssetsTeaser = !!value.generateAssetsTeaser
|
||||||
const generateAssetsSprites = !!value.generateAssetsSprites
|
const generateAssetsSprites = !!value.generateAssetsSprites
|
||||||
const generateAssetsAnalyze = !!value.generateAssetsAnalyze
|
const generateAssetsAnalyze = !!value.generateAssetsAnalyze
|
||||||
|
const enrichPostworkEnabled = !!value.enrichPostworkEnabled
|
||||||
|
|
||||||
const trainingRecognitionEnabled = !!value.trainingRecognitionEnabled
|
const trainingRecognitionEnabled = !!value.trainingRecognitionEnabled
|
||||||
const trainingDetectorEpochs = Math.max(
|
const trainingDetectorEpochs = Math.max(
|
||||||
@ -1026,6 +1030,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
generateAssetsTeaser,
|
generateAssetsTeaser,
|
||||||
generateAssetsSprites,
|
generateAssetsSprites,
|
||||||
generateAssetsAnalyze,
|
generateAssetsAnalyze,
|
||||||
|
enrichPostworkEnabled,
|
||||||
trainingRecognitionEnabled,
|
trainingRecognitionEnabled,
|
||||||
trainingDetectorEpochs,
|
trainingDetectorEpochs,
|
||||||
}),
|
}),
|
||||||
@ -1104,6 +1109,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
`Aufräumen:\n` +
|
`Aufräumen:\n` +
|
||||||
`• Löscht Dateien in "${doneDir}" < ${mb} MB (Ordner "keep" wird übersprungen)\n` +
|
`• Löscht Dateien in "${doneDir}" < ${mb} MB (Ordner "keep" wird übersprungen)\n` +
|
||||||
`• Löscht fehlerhafte/defekte MP4/TS-Dateien, wenn sie nicht lesbar sind\n` +
|
`• Löscht fehlerhafte/defekte MP4/TS-Dateien, wenn sie nicht lesbar sind\n` +
|
||||||
|
`• Räumt Record-Reste auf: .muxing.ts, verwaiste .ts und verwaiste/0-KB .audio.m4s\n` +
|
||||||
`• Entfernt verwaiste Previews/Thumbs/Generated-Assets ohne passende Datei\n\n` +
|
`• Entfernt verwaiste Previews/Thumbs/Generated-Assets ohne passende Datei\n\n` +
|
||||||
`Fortfahren?`
|
`Fortfahren?`
|
||||||
)
|
)
|
||||||
@ -1871,6 +1877,18 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<LabeledSwitch
|
||||||
|
checked={!!value.enrichPostworkEnabled}
|
||||||
|
onChange={(checked) =>
|
||||||
|
setValue((v) => ({
|
||||||
|
...v,
|
||||||
|
enrichPostworkEnabled: checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
label="EnrichQ-Postwork im Hintergrund"
|
||||||
|
description="Wenn aktiv, werden fehlende Analyse-/Enrich-Assets automatisch im Hintergrund nachgeholt. Wenn deaktiviert, werden keine neuen EnrichQ-Hintergrundjobs gestartet; manuelle Asset-Generierung bleibt möglich."
|
||||||
|
/>
|
||||||
|
|
||||||
<LabeledSwitch
|
<LabeledSwitch
|
||||||
checked={!!value.autoAddToDownloadList}
|
checked={!!value.autoAddToDownloadList}
|
||||||
onChange={(checked) =>
|
onChange={(checked) =>
|
||||||
|
|||||||
@ -33,15 +33,10 @@ type TabsProps = {
|
|||||||
onChange: (id: string) => void
|
onChange: (id: string) => void
|
||||||
className?: string
|
className?: string
|
||||||
ariaLabel?: string
|
ariaLabel?: string
|
||||||
|
|
||||||
/** Siehe Variants aus pasted.txt */
|
|
||||||
variant?: TabsVariant
|
variant?: TabsVariant
|
||||||
|
|
||||||
/**
|
|
||||||
* Optional: In der pasted.txt sind Badges teils erst ab md sichtbar.
|
|
||||||
* Default: false (wie deine bisherige Komponente: immer sichtbar auf Desktop).
|
|
||||||
*/
|
|
||||||
hideCountUntilMd?: boolean
|
hideCountUntilMd?: boolean
|
||||||
|
|
||||||
|
desktopBreakpoint?: 'sm' | 'md' | 'lg'
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Tabs({
|
export default function Tabs({
|
||||||
@ -52,11 +47,27 @@ export default function Tabs({
|
|||||||
ariaLabel = 'Ansicht auswählen',
|
ariaLabel = 'Ansicht auswählen',
|
||||||
variant = 'underline',
|
variant = 'underline',
|
||||||
hideCountUntilMd = false,
|
hideCountUntilMd = false,
|
||||||
|
desktopBreakpoint = 'sm',
|
||||||
}: TabsProps) {
|
}: TabsProps) {
|
||||||
if (!tabs?.length) return null
|
if (!tabs?.length) return null
|
||||||
|
|
||||||
const current = tabs.find((t) => t.id === value) ?? tabs[0]
|
const current = tabs.find((t) => t.id === value) ?? tabs[0]
|
||||||
|
|
||||||
|
const mobileClassByBreakpoint = {
|
||||||
|
sm: 'sm:hidden',
|
||||||
|
md: 'md:hidden',
|
||||||
|
lg: 'lg:hidden',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const desktopClassByBreakpoint = {
|
||||||
|
sm: 'hidden sm:block',
|
||||||
|
md: 'hidden md:block',
|
||||||
|
lg: 'hidden lg:block',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const mobileWrapperClass = mobileClassByBreakpoint[desktopBreakpoint]
|
||||||
|
const desktopWrapperClass = desktopClassByBreakpoint[desktopBreakpoint]
|
||||||
|
|
||||||
const mobileSelectClass = clsx(
|
const mobileSelectClass = clsx(
|
||||||
// entspricht den Beispielen (outline-1 + -outline-offset-1)
|
// entspricht den Beispielen (outline-1 + -outline-offset-1)
|
||||||
'col-start-1 row-start-1 w-full appearance-none rounded-md bg-white py-2 pr-8 pl-3 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:text-gray-100 dark:outline-white/10 dark:*:bg-gray-800 dark:focus:outline-indigo-500',
|
'col-start-1 row-start-1 w-full appearance-none rounded-md bg-white py-2 pr-8 pl-3 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:text-gray-100 dark:outline-white/10 dark:*:bg-gray-800 dark:focus:outline-indigo-500',
|
||||||
@ -317,7 +328,7 @@ export default function Tabs({
|
|||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
{/* Mobile: Select + Chevron (wie Beispiele) */}
|
{/* Mobile: Select + Chevron (wie Beispiele) */}
|
||||||
<div className="grid grid-cols-1 sm:hidden">
|
<div className={clsx('grid grid-cols-1', mobileWrapperClass)}>
|
||||||
<select value={current.id} onChange={(e) => onChange(e.target.value)} aria-label={ariaLabel} className={mobileSelectClass}>
|
<select value={current.id} onChange={(e) => onChange(e.target.value)} aria-label={ariaLabel} className={mobileSelectClass}>
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<option key={tab.id} value={tab.id}>
|
<option key={tab.id} value={tab.id}>
|
||||||
@ -332,7 +343,7 @@ export default function Tabs({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop */}
|
{/* Desktop */}
|
||||||
<div className="hidden sm:block">{renderDesktop()}</div>
|
<div className={desktopWrapperClass}>{renderDesktop()}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4817,7 +4817,7 @@ export default function TrainingTab(props: {
|
|||||||
const stageOverlayIsVisible = stageBusy && stageOverlayVisible
|
const stageOverlayIsVisible = stageBusy && stageOverlayVisible
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-full lg:h-full lg:min-h-0 lg:overflow-hidden">
|
<div className="min-h-full lg:h-[calc(100dvh-15rem)] lg:min-h-0 lg:max-h-[calc(100dvh-15rem)] lg:overflow-hidden">
|
||||||
{loadingPreviewUrl ? (
|
{loadingPreviewUrl ? (
|
||||||
<img
|
<img
|
||||||
src={loadingPreviewUrl}
|
src={loadingPreviewUrl}
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
/* frontend\src\index.css */
|
||||||
|
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import 'flag-icons/css/flag-icons.min.css';
|
@import 'flag-icons/css/flag-icons.min.css';
|
||||||
|
|
||||||
@ -16,6 +18,35 @@
|
|||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile: Textauswahl/Callout bei Long-Press global verhindern */
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root,
|
||||||
|
button,
|
||||||
|
a,
|
||||||
|
[role="button"],
|
||||||
|
.select-none-mobile {
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Eingaben und explizit markierbarer Text bleiben auswählbar */
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select,
|
||||||
|
[contenteditable="true"],
|
||||||
|
.user-select-text {
|
||||||
|
-webkit-user-select: text;
|
||||||
|
user-select: text;
|
||||||
|
-webkit-touch-callout: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
/* Video.js Popups (Playback-Rate Menü, Volume-Slider) über eigene Overlays legen */
|
/* Video.js Popups (Playback-Rate Menü, Volume-Slider) über eigene Overlays legen */
|
||||||
.video-js {
|
.video-js {
|
||||||
position: relative; /* schafft eine stabile Basis */
|
position: relative; /* schafft eine stabile Basis */
|
||||||
|
|||||||
@ -257,11 +257,23 @@ export type RecorderSettingsState = {
|
|||||||
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
|
||||||
lowDiskPauseBelowGB?: number
|
lowDiskPauseBelowGB?: number
|
||||||
enableNotifications?: boolean
|
enableNotifications?: boolean
|
||||||
|
|
||||||
|
generateAssetsMeta?: boolean
|
||||||
|
generateAssetsThumb?: boolean
|
||||||
|
generateAssetsTeaser?: boolean
|
||||||
|
generateAssetsSprites?: boolean
|
||||||
|
generateAssetsAnalyze?: boolean
|
||||||
|
|
||||||
|
enrichPostworkEnabled?: boolean
|
||||||
|
|
||||||
|
trainingRecognitionEnabled?: boolean
|
||||||
|
trainingDetectorEpochs?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type JobEvent = {
|
export type JobEvent = {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user