bugfixes
This commit is contained in:
parent
2311857661
commit
f0eef673d9
@ -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
|
||||
|
||||
29
backend/chaturbate_autostart_test.go
Normal file
29
backend/chaturbate_autostart_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
50
backend/live_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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) ? (
|
||||
<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">
|
||||
|
||||
@ -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
|
||||
}
|
||||
/>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user