This commit is contained in:
Linrador 2026-06-15 09:23:46 +02:00
parent 2311857661
commit f0eef673d9
10 changed files with 440 additions and 69 deletions

View File

@ -44,6 +44,18 @@ func logChaturbateAutoStartError(prefix string, modelURL string, err error) {
appLogln(prefix, modelURL, err) appLogln(prefix, modelURL, err)
} }
func chaturbateAutoStartCandidateIndex(queue []autoStartItem, paused bool) int {
for i, candidate := range queue {
if paused && !candidate.force {
continue
}
return i
}
return -1
}
func normUser(s string) string { func normUser(s string) string {
return strings.ToLower(strings.TrimSpace(s)) return strings.ToLower(strings.TrimSpace(s))
} }
@ -315,7 +327,8 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
} }
const scanInterval = 1 * time.Second const scanInterval = 1 * time.Second
const startInterval = 5 * time.Second const startInterval = 500 * time.Millisecond
const pendingRefreshInterval = 5 * time.Second
const snapshotMaxAge = 90 * time.Second const snapshotMaxAge = 90 * time.Second
@ -593,6 +606,7 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
nextWatchedPending := make([]PendingAutoStartItem, 0, len(watchedOrder)) nextWatchedPending := make([]PendingAutoStartItem, 0, len(watchedOrder))
now := time.Now() now := time.Now()
waitingForPublic := false
resumePendingThisScan := map[string]bool{} resumePendingThisScan := map[string]bool{}
@ -650,6 +664,7 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
pendingAutoStartMu.Unlock() pendingAutoStartMu.Unlock()
resumePendingThisScan[user] = true resumePendingThisScan[user] = true
waitingForPublic = true
if verboseLogs() { if verboseLogs() {
appLogln("⏸️ [autostart] stopped because model is no longer public:", user, show) appLogln("⏸️ [autostart] stopped because model is no longer public:", user, show)
@ -689,6 +704,8 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
queued[user] = true queued[user] = true
case "private", "hidden", "away": case "private", "hidden", "away":
waitingForPublic = true
if resumePendingThisScan[user] { if resumePendingThisScan[user] {
continue continue
} }
@ -711,6 +728,8 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
pendingAutoStartMu.Unlock() pendingAutoStartMu.Unlock()
case "unknown": case "unknown":
waitingForPublic = true
if resumePendingThisScan[user] { if resumePendingThisScan[user] {
continue continue
} }
@ -763,6 +782,8 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
queued[user] = true queued[user] = true
case "private", "hidden", "away": case "private", "hidden", "away":
waitingForPublic = true
// Wenn der User in diesem Scan gerade wegen private/hidden/away // Wenn der User in diesem Scan gerade wegen private/hidden/away
// aus einem laufenden Download gestoppt wurde, existiert bereits // aus einem laufenden Download gestoppt wurde, existiert bereits
// ein echter resume-Eintrag. Dann keinen normalen watched-Eintrag // ein echter resume-Eintrag. Dann keinen normalen watched-Eintrag
@ -831,6 +852,8 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
queued[user] = true queued[user] = true
case "private", "hidden", "away": case "private", "hidden", "away":
waitingForPublic = true
// Manual Pending bleibt wartend, solange das Model nicht offline ist. // Manual Pending bleibt wartend, solange das Model nicht offline ist.
continue continue
@ -842,6 +865,8 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
continue continue
default: default:
waitingForPublic = true
// unknown nicht als offline/wartend speichern. // unknown nicht als offline/wartend speichern.
continue continue
} }
@ -851,9 +876,13 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
_ = saveWatchedPendingAutoStartItemsForProvider("chaturbate", nextWatchedPending) _ = saveWatchedPendingAutoStartItemsForProvider("chaturbate", nextWatchedPending)
pendingAutoStartMu.Unlock() pendingAutoStartMu.Unlock()
if waitingForPublic {
requestChaturbateSnapshotRefresh(pendingRefreshInterval)
}
case <-startTicker.C: case <-startTicker.C:
s := getSettings() s := getSettings()
if !s.UseProviderAPIs || !s.UseChaturbateAPI || !s.AutoStartAddedDownloads || isAutostartPaused() { if !s.UseProviderAPIs || !s.UseChaturbateAPI {
continue continue
} }
@ -879,17 +908,8 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
showByUser[u] = normalizePendingShowServer(r.CurrentShow) showByUser[u] = normalizePendingShowServer(r.CurrentShow)
} }
paused := isAutostartPaused() paused := isAutostartPaused() || !s.AutoStartAddedDownloads
startIdx := chaturbateAutoStartCandidateIndex(queue, paused)
startIdx := -1
for i, candidate := range queue {
if paused && !candidate.force {
continue
}
startIdx = i
break
}
if startIdx < 0 { if startIdx < 0 {
continue continue

View File

@ -0,0 +1,29 @@
package main
import "testing"
func TestChaturbateAutoStartCandidateIndex(t *testing.T) {
queue := []autoStartItem{
{userKey: "watched", source: "watched"},
{userKey: "resume", source: "resume", force: true},
}
if got := chaturbateAutoStartCandidateIndex(queue, false); got != 0 {
t.Fatalf("unpaused candidate index = %d, want 0", got)
}
if got := chaturbateAutoStartCandidateIndex(queue, true); got != 1 {
t.Fatalf("paused candidate index = %d, want forced resume at 1", got)
}
}
func TestChaturbateAutoStartCandidateIndexPausedWithoutResume(t *testing.T) {
queue := []autoStartItem{
{userKey: "watched", source: "watched"},
{userKey: "manual", source: "manual"},
}
if got := chaturbateAutoStartCandidateIndex(queue, true); got != -1 {
t.Fatalf("paused candidate index = %d, want -1", got)
}
}

View File

@ -520,6 +520,54 @@ func refreshChaturbateSnapshotNow(ctx context.Context) (time.Time, error) {
return fetchedAtNow, nil return fetchedAtNow, nil
} }
func requestChaturbateSnapshotRefresh(minInterval time.Duration) bool {
if !chaturbateProviderAPIEnabled() {
return false
}
now := time.Now()
cbRefreshMu.Lock()
if cbRefreshInFlight {
cbRefreshMu.Unlock()
return false
}
cbMu.RLock()
lastAttempt := cb.LastAttempt
cbMu.RUnlock()
if minInterval > 0 && !lastAttempt.IsZero() && now.Sub(lastAttempt) < minInterval {
cbRefreshMu.Unlock()
return false
}
cbRefreshInFlight = true
cbMu.Lock()
cb.LastAttempt = now
cbMu.Unlock()
cbRefreshMu.Unlock()
go func() {
defer func() {
cbRefreshMu.Lock()
cbRefreshInFlight = false
cbRefreshMu.Unlock()
}()
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if _, err := refreshChaturbateSnapshotNow(ctx); err != nil && verboseLogs() {
appLogln("⚠️ [autostart] chaturbate pending refresh failed:", err)
}
}()
return true
}
func triggerChaturbateStoreSync(store *ModelStore, rooms []ChaturbateRoom, fetchedAt time.Time) { func triggerChaturbateStoreSync(store *ModelStore, rooms []ChaturbateRoom, fetchedAt time.Time) {
if store == nil { if store == nil {
return return

View File

@ -45,10 +45,10 @@ var previewFileRe = regexp.MustCompile(`^(index(_hq)?\.m3u8|seg_(low|hq)_\d+\.ts
var cbHlsRefreshMu sync.Mutex var cbHlsRefreshMu sync.Mutex
var cbHlsRefreshAt = map[string]time.Time{} // key=userLower -> last refresh time var cbHlsRefreshAt = map[string]time.Time{} // key=userLower -> last refresh time
func shouldRefreshHLS(userLower string, minInterval time.Duration) bool { func beginHLSRefresh(userLower string, minInterval time.Duration) (time.Time, bool) {
userLower = strings.ToLower(strings.TrimSpace(userLower)) userLower = strings.ToLower(strings.TrimSpace(userLower))
if userLower == "" { if userLower == "" {
return false return time.Time{}, false
} }
cbHlsRefreshMu.Lock() cbHlsRefreshMu.Lock()
@ -56,11 +56,30 @@ func shouldRefreshHLS(userLower string, minInterval time.Duration) bool {
last := cbHlsRefreshAt[userLower] last := cbHlsRefreshAt[userLower]
if !last.IsZero() && time.Since(last) < minInterval { if !last.IsZero() && time.Since(last) < minInterval {
return false return time.Time{}, false
} }
cbHlsRefreshAt[userLower] = time.Now() startedAt := time.Now()
return true cbHlsRefreshAt[userLower] = startedAt
return startedAt, true
}
func finishHLSRefresh(userLower string, startedAt time.Time, success bool) {
if success || startedAt.IsZero() {
return
}
userLower = strings.ToLower(strings.TrimSpace(userLower))
if userLower == "" {
return
}
cbHlsRefreshMu.Lock()
defer cbHlsRefreshMu.Unlock()
if current := cbHlsRefreshAt[userLower]; current.Equal(startedAt) {
delete(cbHlsRefreshAt, userLower)
}
} }
// fetchCurrentBestHLS lädt die Room-Seite und parsed hls_source. // fetchCurrentBestHLS lädt die Room-Seite und parsed hls_source.
@ -193,9 +212,22 @@ func refreshPreviewLiveSourceForJob(r *http.Request, job *RecordJob) error {
} }
// ✅ wichtig: doppelten Refresh verhindern // ✅ wichtig: doppelten Refresh verhindern
if !shouldRefreshHLS(strings.ToLower(username), 10*time.Second) { userLower := strings.ToLower(username)
refreshInterval := 10 * time.Second
forceRefresh := strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("refresh")), "1") ||
strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("refresh")), "true")
if forceRefresh {
refreshInterval = 0
}
refreshStartedAt, shouldRefresh := beginHLSRefresh(userLower, refreshInterval)
if !shouldRefresh {
return nil return nil
} }
refreshSucceeded := false
defer func() {
finishHLSRefresh(userLower, refreshStartedAt, refreshSucceeded)
}()
ctxRefresh, cancelRefresh := context.WithTimeout(r.Context(), 8*time.Second) ctxRefresh, cancelRefresh := context.WithTimeout(r.Context(), 8*time.Second)
defer cancelRefresh() defer cancelRefresh()
@ -225,6 +257,7 @@ func refreshPreviewLiveSourceForJob(r *http.Request, job *RecordJob) error {
stopPreview(job) stopPreview(job)
} }
refreshSucceeded = true
return nil return nil
} }
@ -259,6 +292,7 @@ func recordPreviewLive(w http.ResponseWriter, r *http.Request) {
} }
if err != nil { if err != nil {
w.WriteHeader(http.StatusBadGateway)
_ = json.NewEncoder(w).Encode(resp{ _ = json.NewEncoder(w).Encode(resp{
OK: false, OK: false,
Prepared: false, Prepared: false,

50
backend/live_test.go Normal file
View File

@ -0,0 +1,50 @@
package main
import (
"testing"
"time"
)
func clearHLSRefreshForTest(user string) {
cbHlsRefreshMu.Lock()
delete(cbHlsRefreshAt, user)
cbHlsRefreshMu.Unlock()
}
func TestFailedHLSRefreshCanRetryImmediately(t *testing.T) {
const user = "failed-refresh-test"
clearHLSRefreshForTest(user)
t.Cleanup(func() {
clearHLSRefreshForTest(user)
})
startedAt, ok := beginHLSRefresh(user, 10*time.Second)
if !ok {
t.Fatal("expected first refresh reservation")
}
finishHLSRefresh(user, startedAt, false)
if _, ok := beginHLSRefresh(user, 10*time.Second); !ok {
t.Fatal("failed refresh must not throttle the next attempt")
}
}
func TestSuccessfulHLSRefreshRemainsThrottled(t *testing.T) {
const user = "successful-refresh-test"
clearHLSRefreshForTest(user)
t.Cleanup(func() {
clearHLSRefreshForTest(user)
})
startedAt, ok := beginHLSRefresh(user, 10*time.Second)
if !ok {
t.Fatal("expected first refresh reservation")
}
finishHLSRefresh(user, startedAt, true)
if _, ok := beginHLSRefresh(user, 10*time.Second); ok {
t.Fatal("successful refresh should throttle duplicate attempts")
}
}

View File

@ -3,9 +3,11 @@
import argparse import argparse
import json import json
import shutil import shutil
import time
from pathlib import Path from pathlib import Path
from ultralytics import YOLO from ultralytics import YOLO
from ultralytics.models.yolo.detect.train import DetectionTrainer
def emit_progress(stage, progress, message="", **extra): def emit_progress(stage, progress, message="", **extra):
@ -51,6 +53,70 @@ def safe_int(value, fallback):
return fallback return fallback
def batch_sample_id(batch):
if not isinstance(batch, dict):
return ""
image_paths = batch.get("im_file")
if not image_paths:
return ""
if isinstance(image_paths, (str, Path)):
image_path = image_paths
else:
try:
image_path = image_paths[0]
except (IndexError, KeyError, TypeError):
return ""
return Path(str(image_path)).stem.strip()
def progress_detection_trainer(train_count, val_count, train_device, fallback_epochs):
class ProgressDetectionTrainer(DetectionTrainer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._preview_epoch = 0
self._preview_batch = 0
self._preview_emitted_at = 0.0
def preprocess_batch(self, batch):
epoch = int(getattr(self, "epoch", 0)) + 1
total_epochs = int(getattr(self, "epochs", fallback_epochs) or fallback_epochs)
if epoch != self._preview_epoch:
self._preview_epoch = epoch
self._preview_batch = 0
self._preview_batch += 1
now = time.monotonic()
if self._preview_batch == 1 or now - self._preview_emitted_at >= 0.5:
sample_id = batch_sample_id(batch)
if sample_id:
total_batches = max(1, len(self.train_loader))
completed = (epoch - 1) + min(1.0, self._preview_batch / total_batches)
emit_progress(
"detector",
0.04 + 0.90 * (completed / max(1, total_epochs)),
"Object Detector trainiert…",
epoch=epoch,
epochs=total_epochs,
sampleId=sample_id,
trainSamples=train_count,
valSamples=val_count,
device=str(train_device),
)
self._preview_emitted_at = now
return super().preprocess_batch(batch)
return ProgressDetectionTrainer
def main(): def main():
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--root", required=True) parser.add_argument("--root", required=True)
@ -198,6 +264,12 @@ def main():
) )
result = model.train( result = model.train(
trainer=progress_detection_trainer(
train_count,
val_count,
train_device,
epochs,
),
data=str(yaml_path), data=str(yaml_path),
epochs=epochs, epochs=epochs,
imgsz=imgsz, imgsz=imgsz,

View File

@ -148,6 +148,7 @@ type TrainingJobStatus struct {
StartedAt string `json:"startedAt,omitempty"` StartedAt string `json:"startedAt,omitempty"`
FinishedAt string `json:"finishedAt,omitempty"` FinishedAt string `json:"finishedAt,omitempty"`
DurationMs int64 `json:"durationMs,omitempty"` DurationMs int64 `json:"durationMs,omitempty"`
PreviewURL string `json:"previewUrl,omitempty"`
Stage string `json:"stage,omitempty"` Stage string `json:"stage,omitempty"`
Epoch int `json:"epoch,omitempty"` Epoch int `json:"epoch,omitempty"`
@ -194,6 +195,7 @@ type trainingProgressEvent struct {
Message string `json:"message,omitempty"` Message string `json:"message,omitempty"`
Epoch int `json:"epoch,omitempty"` Epoch int `json:"epoch,omitempty"`
Epochs int `json:"epochs,omitempty"` Epochs int `json:"epochs,omitempty"`
SampleID string `json:"sampleId,omitempty"`
} }
type TrainingFeedbackListResponse struct { type TrainingFeedbackListResponse struct {
@ -686,6 +688,13 @@ func trainingHandleProgressLine(line string, start int, end int, defaultStep str
if ev.Epochs > 0 { if ev.Epochs > 0 {
s.Epochs = ev.Epochs s.Epochs = ev.Epochs
} }
sampleID := strings.TrimSpace(ev.SampleID)
if sampleID != "" &&
!strings.Contains(sampleID, "/") &&
!strings.Contains(sampleID, "\\") {
s.PreviewURL = "/api/training/frame?id=" + url.QueryEscape(sampleID)
}
}) })
return true return true
@ -1059,6 +1068,7 @@ func trainingFinishCancelled(root string) {
s.Error = "" s.Error = ""
s.FinishedAt = finishedAt.Format(time.RFC3339) s.FinishedAt = finishedAt.Format(time.RFC3339)
s.DurationMs = durationMs s.DurationMs = durationMs
s.PreviewURL = ""
if cleanupErr != nil { if cleanupErr != nil {
s.Message = "Training wurde abgebrochen, aber temporäre Trainingsausgaben konnten nicht vollständig gelöscht werden." s.Message = "Training wurde abgebrochen, aber temporäre Trainingsausgaben konnten nicht vollständig gelöscht werden."
@ -2510,6 +2520,7 @@ func trainingRunJob(ctx context.Context, root string, count int) {
s.Error = errorText s.Error = errorText
s.FinishedAt = finishedAt.Format(time.RFC3339) s.FinishedAt = finishedAt.Format(time.RFC3339)
s.DurationMs = durationMs s.DurationMs = durationMs
s.PreviewURL = ""
}) })
trainingClearJobCancel() trainingClearJobCancel()

View File

@ -14,6 +14,7 @@ export default function LiveVideo({
onVolumeChange, onVolumeChange,
onLoadingStart, onLoadingStart,
onReady, onReady,
onUnavailable,
}: { }: {
src: string src: string
muted?: boolean muted?: boolean
@ -23,13 +24,19 @@ export default function LiveVideo({
onVolumeChange?: (volume: number, muted: boolean) => void onVolumeChange?: (volume: number, muted: boolean) => void
onLoadingStart?: () => void onLoadingStart?: () => void
onReady?: () => void onReady?: () => void
onUnavailable?: () => void
}) { }) {
const ref = useRef<HTMLVideoElement>(null) const ref = useRef<HTMLVideoElement>(null)
const [broken, setBroken] = useState(false) const [failure, setFailure] = useState<{
const [brokenReason, setBrokenReason] = useState<'private' | 'hidden' | 'away' | 'offline' | 'unknown' | null>(null) src: string
reason: 'private' | 'hidden' | 'away' | 'offline' | 'unknown'
} | null>(null)
const onLoadingStartRef = useRef(onLoadingStart) const onLoadingStartRef = useRef(onLoadingStart)
const onReadyRef = useRef(onReady) const onReadyRef = useRef(onReady)
const onUnavailableRef = useRef(onUnavailable)
const mutedRef = useRef(muted)
const volumeRef = useRef(volume)
useEffect(() => { useEffect(() => {
onLoadingStartRef.current = onLoadingStart onLoadingStartRef.current = onLoadingStart
@ -39,6 +46,10 @@ export default function LiveVideo({
onReadyRef.current = onReady onReadyRef.current = onReady
}, [onReady]) }, [onReady])
useEffect(() => {
onUnavailableRef.current = onUnavailable
}, [onUnavailable])
const normalizeBrokenReason = ( const normalizeBrokenReason = (
status?: string status?: string
): 'private' | 'hidden' | 'away' | 'offline' | 'unknown' => { ): 'private' | 'hidden' | 'away' | 'offline' | 'unknown' => {
@ -70,38 +81,69 @@ export default function LiveVideo({
} }
} }
const broken = Boolean(src && failure?.src === src)
const brokenReason = broken ? failure?.reason ?? null : null
useEffect(() => { useEffect(() => {
let watchdogTimer: number | null = null let watchdogTimer: number | null = null
let retryTimer: number | null = null
let readySent = false let readySent = false
let stalledRetries = 0 let consecutiveRetries = 0
let loadAttempt = 0
let attemptStartedAt = Date.now() let attemptStartedAt = Date.now()
let lastProgressAt = Date.now() let lastProgressAt = Date.now()
let retryPending = false
let failed = false
const video = ref.current const video = ref.current
if (!video) return if (!video) return
setBroken(false) applyInlineVideoPolicy(video, { muted: mutedRef.current })
setBrokenReason(null) video.volume = Math.max(0, Math.min(1, volumeRef.current))
video.muted = mutedRef.current || volumeRef.current <= 0
applyInlineVideoPolicy(video, { muted })
video.volume = Math.max(0, Math.min(1, volume))
video.muted = muted || volume <= 0
const hardReset = () => { const hardReset = () => {
try { try {
video.pause() video.pause()
video.removeAttribute('src') video.removeAttribute('src')
video.load() video.load()
} catch {} } catch {
// Best effort while the media element is being replaced.
}
} }
const markBroken = () => { const markBroken = () => {
if (failed) return
failed = true
hardReset()
const reason = normalizeBrokenReason(roomStatus) const reason = normalizeBrokenReason(roomStatus)
setBroken(true) setFailure({ src, reason })
setBrokenReason(reason) onUnavailableRef.current?.()
}
const hasDefinitiveRoomFailure = () => {
const reason = normalizeBrokenReason(roomStatus)
return reason === 'private' ||
reason === 'hidden' ||
reason === 'away' ||
reason === 'offline'
}
const attemptUrl = () => {
loadAttempt += 1
const separator = src.includes('?') ? '&' : '?'
const forceRefresh = loadAttempt > 1 ? '&refresh=1' : ''
return `${src}${separator}liveRetry=${Date.now()}-${loadAttempt}${forceRefresh}`
} }
const beginLoad = () => { const beginLoad = () => {
if (failed) return
retryPending = false
readySent = false readySent = false
attemptStartedAt = Date.now() attemptStartedAt = Date.now()
lastProgressAt = Date.now() lastProgressAt = Date.now()
@ -109,7 +151,7 @@ export default function LiveVideo({
onLoadingStartRef.current?.() onLoadingStartRef.current?.()
hardReset() hardReset()
video.src = src video.src = attemptUrl()
video.load() video.load()
const playPromise = video.play() const playPromise = video.play()
@ -120,11 +162,27 @@ export default function LiveVideo({
} }
} }
const retryOrBreak = () => {
if (failed || retryPending) return
if (hasDefinitiveRoomFailure() || consecutiveRetries >= 2) {
markBroken()
return
}
consecutiveRetries += 1
retryPending = true
onLoadingStartRef.current?.()
retryTimer = window.setTimeout(() => {
retryTimer = null
beginLoad()
}, Math.min(1_500, consecutiveRetries * 400))
}
hardReset() hardReset()
if (!src) { if (!src) {
setBroken(false)
setBrokenReason(null)
return return
} }
@ -138,6 +196,7 @@ export default function LiveVideo({
const onTime = () => { const onTime = () => {
lastProgressAt = Date.now() lastProgressAt = Date.now()
consecutiveRetries = 0
markReady() markReady()
} }
@ -153,11 +212,12 @@ export default function LiveVideo({
const onPlaying = () => { const onPlaying = () => {
lastProgressAt = Date.now() lastProgressAt = Date.now()
consecutiveRetries = 0
markReady() markReady()
} }
const onError = () => { const onError = () => {
markBroken() retryOrBreak()
} }
video.addEventListener('timeupdate', onTime) video.addEventListener('timeupdate', onTime)
@ -173,31 +233,21 @@ export default function LiveVideo({
// Stream wurde nie ready -> nicht ewig Spinner zeigen // Stream wurde nie ready -> nicht ewig Spinner zeigen
if (!readySent && now - attemptStartedAt > 12_000) { if (!readySent && now - attemptStartedAt > 12_000) {
if (stalledRetries < 1) { retryOrBreak()
stalledRetries += 1
beginLoad()
return
}
markBroken()
return return
} }
// Stream war ready, hängt aber später // Stream war ready, hängt aber später
if (readySent && !video.paused && now - lastProgressAt > 12_000) { if (readySent && !video.paused && now - lastProgressAt > 12_000) {
if (stalledRetries < 1) { retryOrBreak()
stalledRetries += 1
beginLoad()
return
}
markBroken()
} }
}, 1_000) }, 1_000)
return () => { return () => {
if (watchdogTimer) window.clearInterval(watchdogTimer) if (watchdogTimer) window.clearInterval(watchdogTimer)
watchdogTimer = null watchdogTimer = null
if (retryTimer) window.clearTimeout(retryTimer)
retryTimer = null
video.removeEventListener('timeupdate', onTime) video.removeEventListener('timeupdate', onTime)
video.removeEventListener('loadeddata', onLoadedData) video.removeEventListener('loadeddata', onLoadedData)
@ -211,9 +261,13 @@ export default function LiveVideo({
useEffect(() => { useEffect(() => {
const video = ref.current const video = ref.current
if (!video) return
const safeVolume = Math.max(0, Math.min(1, volume)) const safeVolume = Math.max(0, Math.min(1, volume))
volumeRef.current = safeVolume
mutedRef.current = muted
if (!video) return
video.volume = safeVolume video.volume = safeVolume
video.muted = muted || safeVolume <= 0 video.muted = muted || safeVolume <= 0
}, [volume, muted]) }, [volume, muted])

View File

@ -68,16 +68,23 @@ export default function ModelPreview({
const [livePreparing, setLivePreparing] = useState(false) const [livePreparing, setLivePreparing] = useState(false)
const [liveReady, setLiveReady] = useState(false) const [liveReady, setLiveReady] = useState(false)
const [liveUnavailable, setLiveUnavailable] = useState(false)
const [popupMuted, setPopupMuted] = useState(true) const [popupMuted, setPopupMuted] = useState(true)
const [popupVolume, setPopupVolume] = useState(1) const [popupVolume, setPopupVolume] = useState(1)
const handleLiveLoadingStart = useCallback(() => { const handleLiveLoadingStart = useCallback(() => {
setLiveReady(false) setLiveReady(false)
setLiveUnavailable(false)
}, []) }, [])
const handleLiveReady = useCallback(() => { const handleLiveReady = useCallback(() => {
setLiveReady(true) setLiveReady(true)
setLiveUnavailable(false)
}, [])
const handleLiveUnavailable = useCallback(() => {
setLiveUnavailable(true)
}, []) }, [])
const handleLiveVolumeChange = useCallback((nextVolume: number, nextMuted: boolean) => { const handleLiveVolumeChange = useCallback((nextVolume: number, nextMuted: boolean) => {
@ -151,31 +158,52 @@ export default function ModelPreview({
setLiveSrc('') setLiveSrc('')
setLivePreparing(false) setLivePreparing(false)
setLiveReady(false) setLiveReady(false)
setLiveUnavailable(false)
return return
} }
setLivePreparing(true) setLivePreparing(true)
setLiveReady(false) setLiveReady(false)
setLiveUnavailable(false)
const cookieHeader = buildChaturbateCookieHeader() const cookieHeader = buildChaturbateCookieHeader()
try { try {
const prepareUrl = apiUrl( let prepared = false
`/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&play=1&prepare=1`
)
const prepareRes = await apiFetch(prepareUrl, { for (let attempt = 0; attempt < 2; attempt += 1) {
method: 'GET', const prepareUrl = apiUrl(
cache: 'no-store', `/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&play=1&prepare=1&attempt=${attempt + 1}`
signal: ac.signal, )
headers: cookieHeader
? { 'X-Chaturbate-Cookie': cookieHeader }
: undefined,
})
if (ac.signal.aborted) return const prepareRes = await apiFetch(prepareUrl, {
await prepareRes.json().catch(() => null) method: 'GET',
if (ac.signal.aborted) return cache: 'no-store',
signal: ac.signal,
headers: cookieHeader
? { 'X-Chaturbate-Cookie': cookieHeader }
: undefined,
})
if (ac.signal.aborted) return
const prepareData = await prepareRes.json().catch(() => null)
if (ac.signal.aborted) return
if (prepareRes.ok && prepareData?.ok !== false) {
prepared = true
break
}
if (attempt === 0) {
await new Promise((resolve) => window.setTimeout(resolve, 500))
if (ac.signal.aborted) return
}
}
if (!prepared) {
throw new Error('Live preview preparation failed')
}
setLiveSrc( setLiveSrc(
apiUrl(`/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&play=1&ts=${Date.now()}`) apiUrl(`/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&play=1&ts=${Date.now()}`)
@ -226,6 +254,7 @@ export default function ModelPreview({
setLiveSrc('') setLiveSrc('')
setLivePreparing(false) setLivePreparing(false)
setLiveReady(false) setLiveReady(false)
setLiveUnavailable(false)
}, [jobId]) }, [jobId])
useEffect(() => { useEffect(() => {
@ -333,6 +362,7 @@ export default function ModelPreview({
setLiveSrc('') setLiveSrc('')
setLivePreparing(false) setLivePreparing(false)
setLiveReady(false) setLiveReady(false)
setLiveUnavailable(false)
} }
}} }}
content={(open, { close }) => content={(open, { close }) =>
@ -350,11 +380,12 @@ export default function ModelPreview({
roomStatus={roomStatus} roomStatus={roomStatus}
onLoadingStart={handleLiveLoadingStart} onLoadingStart={handleLiveLoadingStart}
onReady={handleLiveReady} onReady={handleLiveReady}
onUnavailable={handleLiveUnavailable}
onVolumeChange={handleLiveVolumeChange} onVolumeChange={handleLiveVolumeChange}
className="w-full h-full object-contain object-center relative z-0" className="w-full h-full object-contain object-center relative z-0"
/> />
{livePreparing || !liveReady ? ( {!liveUnavailable && (livePreparing || !liveReady) ? (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-black/85 text-white"> <div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-black/85 text-white">
<LoadingSpinner /> <LoadingSpinner />
<div className="text-sm font-medium"> <div className="text-sm font-medium">

View File

@ -67,6 +67,7 @@ type TrainingJobStatus = {
stage?: string stage?: string
epoch?: number epoch?: number
epochs?: number epochs?: number
previewUrl?: string
} }
type TrainingPrediction = { type TrainingPrediction = {
@ -2032,6 +2033,7 @@ function annotationToCorrectionState(item: TrainingAnnotation): CorrectionState
const FEEDBACK_PAGE_SIZE = 10 const FEEDBACK_PAGE_SIZE = 10
const TRAINING_INFO_DISMISSED_STORAGE_KEY = 'training:info-dismissed-key' const TRAINING_INFO_DISMISSED_STORAGE_KEY = 'training:info-dismissed-key'
const TRAINING_IMAGE_EXPANDED_STORAGE_KEY = 'training:image-expanded'
export default function TrainingTab(props: { export default function TrainingTab(props: {
onTrainingRunningChange?: (running: boolean) => void onTrainingRunningChange?: (running: boolean) => void
@ -2095,7 +2097,15 @@ export default function TrainingTab(props: {
const finishingGestureRef = useRef(false) const finishingGestureRef = useRef(false)
const [frameImageLoaded, setFrameImageLoaded] = useState(false) const [frameImageLoaded, setFrameImageLoaded] = useState(false)
const [imageExpanded, setImageExpanded] = useState(false) const [imageExpanded, setImageExpanded] = useState(() => {
if (typeof window === 'undefined') return false
try {
return window.localStorage.getItem(TRAINING_IMAGE_EXPANDED_STORAGE_KEY) === '1'
} catch {
return false
}
})
const [frameNaturalSize, setFrameNaturalSize] = useState<{ const [frameNaturalSize, setFrameNaturalSize] = useState<{
width: number width: number
@ -2706,6 +2716,7 @@ export default function TrainingTab(props: {
stage: job.stage, stage: job.stage,
epoch: Number(job.epoch ?? 0), epoch: Number(job.epoch ?? 0),
epochs: Number(job.epochs ?? 0), epochs: Number(job.epochs ?? 0),
previewUrl: String(job.previewUrl ?? ''),
} }
: prev?.training, : prev?.training,
})) }))
@ -3271,6 +3282,17 @@ export default function TrainingTab(props: {
} }
}, [imageExpanded, props.onImageExpandedChange]) }, [imageExpanded, props.onImageExpandedChange])
useEffect(() => {
try {
window.localStorage.setItem(
TRAINING_IMAGE_EXPANDED_STORAGE_KEY,
imageExpanded ? '1' : '0'
)
} catch {
// ignore
}
}, [imageExpanded])
useEffect(() => { useEffect(() => {
if (!trainingRunning) { if (!trainingRunning) {
etaSmoothingRef.current = { etaSmoothingRef.current = {
@ -5780,7 +5802,7 @@ export default function TrainingTab(props: {
visible={stageOverlayIsVisible} visible={stageOverlayIsVisible}
backgroundUrl={ backgroundUrl={
stageOverlayMode === 'training' stageOverlayMode === 'training'
? imageSrc ? trainingStatus?.training?.previewUrl || imageSrc
: loadingPreviewBackgroundUrl : loadingPreviewBackgroundUrl
} }
/> />