diff --git a/backend/chaturbate_autostart.go b/backend/chaturbate_autostart.go index d345348..f727af8 100644 --- a/backend/chaturbate_autostart.go +++ b/backend/chaturbate_autostart.go @@ -44,6 +44,18 @@ func logChaturbateAutoStartError(prefix string, modelURL string, err error) { 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 { return strings.ToLower(strings.TrimSpace(s)) } @@ -315,7 +327,8 @@ func startChaturbateAutoStartWorker(store *ModelStore) { } 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 @@ -593,6 +606,7 @@ func startChaturbateAutoStartWorker(store *ModelStore) { nextWatchedPending := make([]PendingAutoStartItem, 0, len(watchedOrder)) now := time.Now() + waitingForPublic := false resumePendingThisScan := map[string]bool{} @@ -650,6 +664,7 @@ func startChaturbateAutoStartWorker(store *ModelStore) { pendingAutoStartMu.Unlock() resumePendingThisScan[user] = true + waitingForPublic = true if verboseLogs() { appLogln("⏸️ [autostart] stopped because model is no longer public:", user, show) @@ -689,6 +704,8 @@ func startChaturbateAutoStartWorker(store *ModelStore) { queued[user] = true case "private", "hidden", "away": + waitingForPublic = true + if resumePendingThisScan[user] { continue } @@ -711,6 +728,8 @@ func startChaturbateAutoStartWorker(store *ModelStore) { pendingAutoStartMu.Unlock() case "unknown": + waitingForPublic = true + if resumePendingThisScan[user] { continue } @@ -763,6 +782,8 @@ func startChaturbateAutoStartWorker(store *ModelStore) { queued[user] = true case "private", "hidden", "away": + waitingForPublic = true + // Wenn der User in diesem Scan gerade wegen private/hidden/away // aus einem laufenden Download gestoppt wurde, existiert bereits // ein echter resume-Eintrag. Dann keinen normalen watched-Eintrag @@ -831,6 +852,8 @@ func startChaturbateAutoStartWorker(store *ModelStore) { queued[user] = true case "private", "hidden", "away": + waitingForPublic = true + // Manual Pending bleibt wartend, solange das Model nicht offline ist. continue @@ -842,6 +865,8 @@ func startChaturbateAutoStartWorker(store *ModelStore) { continue default: + waitingForPublic = true + // unknown nicht als offline/wartend speichern. continue } @@ -851,9 +876,13 @@ func startChaturbateAutoStartWorker(store *ModelStore) { _ = saveWatchedPendingAutoStartItemsForProvider("chaturbate", nextWatchedPending) pendingAutoStartMu.Unlock() + if waitingForPublic { + requestChaturbateSnapshotRefresh(pendingRefreshInterval) + } + case <-startTicker.C: s := getSettings() - if !s.UseProviderAPIs || !s.UseChaturbateAPI || !s.AutoStartAddedDownloads || isAutostartPaused() { + if !s.UseProviderAPIs || !s.UseChaturbateAPI { continue } @@ -879,17 +908,8 @@ func startChaturbateAutoStartWorker(store *ModelStore) { showByUser[u] = normalizePendingShowServer(r.CurrentShow) } - paused := isAutostartPaused() - - startIdx := -1 - for i, candidate := range queue { - if paused && !candidate.force { - continue - } - - startIdx = i - break - } + paused := isAutostartPaused() || !s.AutoStartAddedDownloads + startIdx := chaturbateAutoStartCandidateIndex(queue, paused) if startIdx < 0 { continue diff --git a/backend/chaturbate_autostart_test.go b/backend/chaturbate_autostart_test.go new file mode 100644 index 0000000..c6d6c2d --- /dev/null +++ b/backend/chaturbate_autostart_test.go @@ -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) + } +} diff --git a/backend/chaturbate_online.go b/backend/chaturbate_online.go index c1a0db3..fab2075 100644 --- a/backend/chaturbate_online.go +++ b/backend/chaturbate_online.go @@ -520,6 +520,54 @@ func refreshChaturbateSnapshotNow(ctx context.Context) (time.Time, error) { 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) { if store == nil { return diff --git a/backend/live.go b/backend/live.go index d29f82a..47af5d8 100644 --- a/backend/live.go +++ b/backend/live.go @@ -45,10 +45,10 @@ var previewFileRe = regexp.MustCompile(`^(index(_hq)?\.m3u8|seg_(low|hq)_\d+\.ts var cbHlsRefreshMu sync.Mutex 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)) if userLower == "" { - return false + return time.Time{}, false } cbHlsRefreshMu.Lock() @@ -56,11 +56,30 @@ func shouldRefreshHLS(userLower string, minInterval time.Duration) bool { last := cbHlsRefreshAt[userLower] if !last.IsZero() && time.Since(last) < minInterval { - return false + return time.Time{}, false } - cbHlsRefreshAt[userLower] = time.Now() - return true + startedAt := time.Now() + 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. @@ -193,9 +212,22 @@ func refreshPreviewLiveSourceForJob(r *http.Request, job *RecordJob) error { } // ✅ 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 } + refreshSucceeded := false + defer func() { + finishHLSRefresh(userLower, refreshStartedAt, refreshSucceeded) + }() ctxRefresh, cancelRefresh := context.WithTimeout(r.Context(), 8*time.Second) defer cancelRefresh() @@ -225,6 +257,7 @@ func refreshPreviewLiveSourceForJob(r *http.Request, job *RecordJob) error { stopPreview(job) } + refreshSucceeded = true return nil } @@ -259,6 +292,7 @@ func recordPreviewLive(w http.ResponseWriter, r *http.Request) { } if err != nil { + w.WriteHeader(http.StatusBadGateway) _ = json.NewEncoder(w).Encode(resp{ OK: false, Prepared: false, diff --git a/backend/live_test.go b/backend/live_test.go new file mode 100644 index 0000000..27bc93d --- /dev/null +++ b/backend/live_test.go @@ -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") + } +} diff --git a/backend/ml/train_detector_model.py b/backend/ml/train_detector_model.py index 08cf5c9..a6bb1d5 100644 --- a/backend/ml/train_detector_model.py +++ b/backend/ml/train_detector_model.py @@ -3,9 +3,11 @@ import argparse import json import shutil +import time from pathlib import Path from ultralytics import YOLO +from ultralytics.models.yolo.detect.train import DetectionTrainer def emit_progress(stage, progress, message="", **extra): @@ -51,6 +53,70 @@ def safe_int(value, 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(): parser = argparse.ArgumentParser() parser.add_argument("--root", required=True) @@ -198,6 +264,12 @@ def main(): ) result = model.train( + trainer=progress_detection_trainer( + train_count, + val_count, + train_device, + epochs, + ), data=str(yaml_path), epochs=epochs, imgsz=imgsz, diff --git a/backend/training.go b/backend/training.go index 17f641a..7d72334 100644 --- a/backend/training.go +++ b/backend/training.go @@ -148,6 +148,7 @@ type TrainingJobStatus struct { StartedAt string `json:"startedAt,omitempty"` FinishedAt string `json:"finishedAt,omitempty"` DurationMs int64 `json:"durationMs,omitempty"` + PreviewURL string `json:"previewUrl,omitempty"` Stage string `json:"stage,omitempty"` Epoch int `json:"epoch,omitempty"` @@ -194,6 +195,7 @@ type trainingProgressEvent struct { Message string `json:"message,omitempty"` Epoch int `json:"epoch,omitempty"` Epochs int `json:"epochs,omitempty"` + SampleID string `json:"sampleId,omitempty"` } type TrainingFeedbackListResponse struct { @@ -686,6 +688,13 @@ func trainingHandleProgressLine(line string, start int, end int, defaultStep str if ev.Epochs > 0 { 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 @@ -1059,6 +1068,7 @@ func trainingFinishCancelled(root string) { s.Error = "" s.FinishedAt = finishedAt.Format(time.RFC3339) s.DurationMs = durationMs + s.PreviewURL = "" if cleanupErr != nil { 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.FinishedAt = finishedAt.Format(time.RFC3339) s.DurationMs = durationMs + s.PreviewURL = "" }) trainingClearJobCancel() diff --git a/frontend/src/components/ui/LiveVideo.tsx b/frontend/src/components/ui/LiveVideo.tsx index 6542773..62126c7 100644 --- a/frontend/src/components/ui/LiveVideo.tsx +++ b/frontend/src/components/ui/LiveVideo.tsx @@ -14,6 +14,7 @@ export default function LiveVideo({ onVolumeChange, onLoadingStart, onReady, + onUnavailable, }: { src: string muted?: boolean @@ -23,13 +24,19 @@ export default function LiveVideo({ onVolumeChange?: (volume: number, muted: boolean) => void onLoadingStart?: () => void onReady?: () => void + onUnavailable?: () => void }) { const ref = useRef(null) - const [broken, setBroken] = useState(false) - const [brokenReason, setBrokenReason] = useState<'private' | 'hidden' | 'away' | 'offline' | 'unknown' | null>(null) + const [failure, setFailure] = useState<{ + src: string + reason: 'private' | 'hidden' | 'away' | 'offline' | 'unknown' + } | null>(null) const onLoadingStartRef = useRef(onLoadingStart) const onReadyRef = useRef(onReady) + const onUnavailableRef = useRef(onUnavailable) + const mutedRef = useRef(muted) + const volumeRef = useRef(volume) useEffect(() => { onLoadingStartRef.current = onLoadingStart @@ -39,6 +46,10 @@ export default function LiveVideo({ onReadyRef.current = onReady }, [onReady]) + useEffect(() => { + onUnavailableRef.current = onUnavailable + }, [onUnavailable]) + const normalizeBrokenReason = ( status?: string ): '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(() => { let watchdogTimer: number | null = null + let retryTimer: number | null = null let readySent = false - let stalledRetries = 0 + let consecutiveRetries = 0 + let loadAttempt = 0 let attemptStartedAt = Date.now() let lastProgressAt = Date.now() + let retryPending = false + let failed = false const video = ref.current if (!video) return - setBroken(false) - setBrokenReason(null) - - applyInlineVideoPolicy(video, { muted }) - video.volume = Math.max(0, Math.min(1, volume)) - video.muted = muted || volume <= 0 + applyInlineVideoPolicy(video, { muted: mutedRef.current }) + video.volume = Math.max(0, Math.min(1, volumeRef.current)) + video.muted = mutedRef.current || volumeRef.current <= 0 const hardReset = () => { try { video.pause() video.removeAttribute('src') video.load() - } catch {} + } catch { + // Best effort while the media element is being replaced. + } } const markBroken = () => { + if (failed) return + failed = true + hardReset() + const reason = normalizeBrokenReason(roomStatus) - setBroken(true) - setBrokenReason(reason) + setFailure({ src, 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 = () => { + if (failed) return + + retryPending = false readySent = false attemptStartedAt = Date.now() lastProgressAt = Date.now() @@ -109,7 +151,7 @@ export default function LiveVideo({ onLoadingStartRef.current?.() hardReset() - video.src = src + video.src = attemptUrl() video.load() 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() if (!src) { - setBroken(false) - setBrokenReason(null) return } @@ -138,6 +196,7 @@ export default function LiveVideo({ const onTime = () => { lastProgressAt = Date.now() + consecutiveRetries = 0 markReady() } @@ -153,11 +212,12 @@ export default function LiveVideo({ const onPlaying = () => { lastProgressAt = Date.now() + consecutiveRetries = 0 markReady() } const onError = () => { - markBroken() + retryOrBreak() } video.addEventListener('timeupdate', onTime) @@ -173,31 +233,21 @@ export default function LiveVideo({ // Stream wurde nie ready -> nicht ewig Spinner zeigen if (!readySent && now - attemptStartedAt > 12_000) { - if (stalledRetries < 1) { - stalledRetries += 1 - beginLoad() - return - } - - markBroken() + retryOrBreak() return } // Stream war ready, hängt aber später if (readySent && !video.paused && now - lastProgressAt > 12_000) { - if (stalledRetries < 1) { - stalledRetries += 1 - beginLoad() - return - } - - markBroken() + retryOrBreak() } }, 1_000) return () => { if (watchdogTimer) window.clearInterval(watchdogTimer) watchdogTimer = null + if (retryTimer) window.clearTimeout(retryTimer) + retryTimer = null video.removeEventListener('timeupdate', onTime) video.removeEventListener('loadeddata', onLoadedData) @@ -211,9 +261,13 @@ export default function LiveVideo({ useEffect(() => { const video = ref.current - if (!video) return const safeVolume = Math.max(0, Math.min(1, volume)) + volumeRef.current = safeVolume + mutedRef.current = muted + + if (!video) return + video.volume = safeVolume video.muted = muted || safeVolume <= 0 }, [volume, muted]) @@ -257,4 +311,4 @@ export default function LiveVideo({ }} /> ) -} \ No newline at end of file +} diff --git a/frontend/src/components/ui/ModelPreview.tsx b/frontend/src/components/ui/ModelPreview.tsx index 590a6f0..c7cd98d 100644 --- a/frontend/src/components/ui/ModelPreview.tsx +++ b/frontend/src/components/ui/ModelPreview.tsx @@ -68,16 +68,23 @@ export default function ModelPreview({ const [livePreparing, setLivePreparing] = useState(false) const [liveReady, setLiveReady] = useState(false) + const [liveUnavailable, setLiveUnavailable] = useState(false) const [popupMuted, setPopupMuted] = useState(true) const [popupVolume, setPopupVolume] = useState(1) const handleLiveLoadingStart = useCallback(() => { setLiveReady(false) + setLiveUnavailable(false) }, []) const handleLiveReady = useCallback(() => { setLiveReady(true) + setLiveUnavailable(false) + }, []) + + const handleLiveUnavailable = useCallback(() => { + setLiveUnavailable(true) }, []) const handleLiveVolumeChange = useCallback((nextVolume: number, nextMuted: boolean) => { @@ -151,31 +158,52 @@ export default function ModelPreview({ setLiveSrc('') setLivePreparing(false) setLiveReady(false) + setLiveUnavailable(false) return } setLivePreparing(true) setLiveReady(false) + setLiveUnavailable(false) const cookieHeader = buildChaturbateCookieHeader() try { - const prepareUrl = apiUrl( - `/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&play=1&prepare=1` - ) + let prepared = false - const prepareRes = await apiFetch(prepareUrl, { - method: 'GET', - cache: 'no-store', - signal: ac.signal, - headers: cookieHeader - ? { 'X-Chaturbate-Cookie': cookieHeader } - : undefined, - }) + for (let attempt = 0; attempt < 2; attempt += 1) { + const prepareUrl = apiUrl( + `/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&play=1&prepare=1&attempt=${attempt + 1}` + ) - if (ac.signal.aborted) return - await prepareRes.json().catch(() => null) - if (ac.signal.aborted) return + const prepareRes = await apiFetch(prepareUrl, { + method: 'GET', + 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( apiUrl(`/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&play=1&ts=${Date.now()}`) @@ -226,6 +254,7 @@ export default function ModelPreview({ setLiveSrc('') setLivePreparing(false) setLiveReady(false) + setLiveUnavailable(false) }, [jobId]) useEffect(() => { @@ -333,6 +362,7 @@ export default function ModelPreview({ setLiveSrc('') setLivePreparing(false) setLiveReady(false) + setLiveUnavailable(false) } }} content={(open, { close }) => @@ -350,11 +380,12 @@ export default function ModelPreview({ roomStatus={roomStatus} onLoadingStart={handleLiveLoadingStart} onReady={handleLiveReady} + onUnavailable={handleLiveUnavailable} onVolumeChange={handleLiveVolumeChange} className="w-full h-full object-contain object-center relative z-0" /> - {livePreparing || !liveReady ? ( + {!liveUnavailable && (livePreparing || !liveReady) ? (
@@ -479,4 +510,4 @@ export default function ModelPreview({
) -} \ No newline at end of file +} diff --git a/frontend/src/components/ui/TrainingTab.tsx b/frontend/src/components/ui/TrainingTab.tsx index 543d413..baa4b27 100644 --- a/frontend/src/components/ui/TrainingTab.tsx +++ b/frontend/src/components/ui/TrainingTab.tsx @@ -67,6 +67,7 @@ type TrainingJobStatus = { stage?: string epoch?: number epochs?: number + previewUrl?: string } type TrainingPrediction = { @@ -2032,6 +2033,7 @@ function annotationToCorrectionState(item: TrainingAnnotation): CorrectionState const FEEDBACK_PAGE_SIZE = 10 const TRAINING_INFO_DISMISSED_STORAGE_KEY = 'training:info-dismissed-key' +const TRAINING_IMAGE_EXPANDED_STORAGE_KEY = 'training:image-expanded' export default function TrainingTab(props: { onTrainingRunningChange?: (running: boolean) => void @@ -2095,7 +2097,15 @@ export default function TrainingTab(props: { const finishingGestureRef = useRef(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<{ width: number @@ -2706,6 +2716,7 @@ export default function TrainingTab(props: { stage: job.stage, epoch: Number(job.epoch ?? 0), epochs: Number(job.epochs ?? 0), + previewUrl: String(job.previewUrl ?? ''), } : prev?.training, })) @@ -3271,6 +3282,17 @@ export default function TrainingTab(props: { } }, [imageExpanded, props.onImageExpandedChange]) + useEffect(() => { + try { + window.localStorage.setItem( + TRAINING_IMAGE_EXPANDED_STORAGE_KEY, + imageExpanded ? '1' : '0' + ) + } catch { + // ignore + } + }, [imageExpanded]) + useEffect(() => { if (!trainingRunning) { etaSmoothingRef.current = { @@ -5780,7 +5802,7 @@ export default function TrainingTab(props: { visible={stageOverlayIsVisible} backgroundUrl={ stageOverlayMode === 'training' - ? imageSrc + ? trainingStatus?.training?.previewUrl || imageSrc : loadingPreviewBackgroundUrl } />