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

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

View File

@ -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,

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 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,

View File

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

View File

@ -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<HTMLVideoElement>(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])

View File

@ -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,17 +158,22 @@ export default function ModelPreview({
setLiveSrc('')
setLivePreparing(false)
setLiveReady(false)
setLiveUnavailable(false)
return
}
setLivePreparing(true)
setLiveReady(false)
setLiveUnavailable(false)
const cookieHeader = buildChaturbateCookieHeader()
try {
let prepared = false
for (let attempt = 0; attempt < 2; attempt += 1) {
const prepareUrl = apiUrl(
`/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&play=1&prepare=1`
`/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&play=1&prepare=1&attempt=${attempt + 1}`
)
const prepareRes = await apiFetch(prepareUrl, {
@ -174,9 +186,25 @@ export default function ModelPreview({
})
if (ac.signal.aborted) return
await prepareRes.json().catch(() => null)
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) ? (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-black/85 text-white">
<LoadingSpinner />
<div className="text-sm font-medium">

View File

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