This commit is contained in:
Linrador 2026-05-03 15:06:47 +02:00
parent 19faed7a40
commit 34df62aa13
7 changed files with 562 additions and 184 deletions

View File

@ -5,7 +5,6 @@
],
"sexPositions": [
"unknown",
"solo",
"missionary",
"doggy",
"cowgirl",
@ -33,13 +32,11 @@
"pussy"
],
"objects": [
"bath",
"blindfold",
"buttplug",
"collar",
"dildo",
"handcuffs",
"rope",
"shower",
"strapon",
"towel",
@ -50,11 +47,11 @@
"bra",
"dress",
"fishnet",
"harness",
"heels",
"hotpants",
"lingerie",
"panties",
"shirt",
"skirt",
"stockings",
"croptop"

View File

@ -405,28 +405,6 @@ func appendFileAndRemove(dstPath, srcPath string) error {
return nil
}
func cleanupHLSRetryChunks(outFile string) {
dir := filepath.Dir(outFile)
ext := filepath.Ext(outFile)
base := strings.TrimSuffix(filepath.Base(outFile), ext)
canonical := canonicalAssetIDFromName(base)
if canonical == "" {
canonical = base
}
pattern := filepath.Join(dir, fmt.Sprintf(".%s.retry.*%s", canonical, ext))
matches, err := filepath.Glob(pattern)
if err != nil {
return
}
for _, p := range matches {
_ = removeWithRetry(p)
}
}
func handleM3U8Mode(
ctx context.Context,
stream *selectedHLSStream,
@ -460,20 +438,15 @@ func handleM3U8Mode(
return errors.New("output file path leer")
}
cleanupHLSRetryChunks(outFile)
defer cleanupHLSRetryChunks(outFile)
ext := strings.ToLower(filepath.Ext(outFile))
if ext != ".ts" && ext != ".mp4" {
return fmt.Errorf("nicht unterstützte output-endung: %q", ext)
}
// Wichtig: ffmpeg schreibt in temporäre Retry-Datei.
// Danach wird sie an outFile angehängt und gelöscht.
retryOut := hlsRetryChunkPath(outFile, 1)
defer func() {
_ = removeWithRetry(retryOut)
}()
// Wichtig:
// handleM3U8Mode schreibt immer in den übergebenen outFile.
// Wenn der Caller einen Retry-Chunk möchte, muss er bereits
// einen Retry-Pfad als outFile übergeben.
args := []string{
"-y",
@ -534,12 +507,12 @@ func handleM3U8Mode(
case ".ts":
args = append(args,
"-f", "mpegts",
retryOut,
outFile,
)
case ".mp4":
args = append(args,
"-movflags", "+faststart",
retryOut,
outFile,
)
}
@ -568,9 +541,7 @@ func handleM3U8Mode(
case <-stopStat:
return
case <-t.C:
mainSize := safeFileSize(outFile)
retrySize := safeFileSize(retryOut)
sz := mainSize + retrySize
sz := safeFileSize(outFile)
if sz > 0 && sz != last {
jobsMu.Lock()
@ -596,10 +567,6 @@ func handleM3U8Mode(
return fmt.Errorf("ffmpeg m3u8 failed: %w", err)
}
if err := appendFileAndRemove(outFile, retryOut); err != nil {
return fmt.Errorf("retry chunk übernehmen fehlgeschlagen: %w", err)
}
if job != nil {
if fi, statErr := os.Stat(outFile); statErr == nil && fi != nil && !fi.IsDir() {
jobsMu.Lock()

View File

@ -171,7 +171,11 @@ func publishTaskState() {
TS: time.Now().UnixMilli(),
}
b, _ := json.Marshal(ev)
b, err := json.Marshal(ev)
if err != nil {
return
}
publishSSE("taskState", b)
}

View File

@ -127,6 +127,11 @@ type TrainingJobStatus struct {
Error string `json:"error,omitempty"`
StartedAt string `json:"startedAt,omitempty"`
FinishedAt string `json:"finishedAt,omitempty"`
DurationMs int64 `json:"durationMs,omitempty"`
Stage string `json:"stage,omitempty"`
Epoch int `json:"epoch,omitempty"`
Epochs int `json:"epochs,omitempty"`
}
type TrainingConfidence struct {
@ -209,12 +214,38 @@ func trainingHandleProgressLine(line string, start int, end int, defaultStep str
if progress > s.Progress {
s.Progress = progress
}
s.Step = step
if strings.TrimSpace(ev.Stage) != "" {
s.Stage = strings.TrimSpace(ev.Stage)
}
if ev.Epoch > 0 {
s.Epoch = ev.Epoch
}
if ev.Epochs > 0 {
s.Epochs = ev.Epochs
}
})
return true
}
func trainingPublishJobStatus(status TrainingJobStatus) {
b, err := json.Marshal(map[string]any{
"type": "training_status",
"training": status,
"ts": time.Now().UnixMilli(),
})
if err != nil {
return
}
publishSSE("training", b)
}
func trainingRunCommandStreaming(
python string,
script string,
@ -306,8 +337,11 @@ var trainingJob = struct {
func trainingSetJobStatus(update func(*TrainingJobStatus)) {
trainingJob.mu.Lock()
defer trainingJob.mu.Unlock()
update(&trainingJob.status)
snapshot := trainingJob.status
trainingJob.mu.Unlock()
trainingPublishJobStatus(snapshot)
}
func trainingGetJobStatus() TrainingJobStatus {
@ -901,12 +935,23 @@ func trainingRunJob(root string, count int) {
errorText := strings.Join(errorParts, " ")
trainingSetJobStatus(func(s *TrainingJobStatus) {
finishedAt := time.Now().UTC()
var durationMs int64
if startedAt, err := time.Parse(time.RFC3339, strings.TrimSpace(s.StartedAt)); err == nil {
durationMs = finishedAt.Sub(startedAt).Milliseconds()
if durationMs < 0 {
durationMs = 0
}
}
s.Running = false
s.Progress = 100
s.Step = "Training abgeschlossen."
s.Message = message
s.Error = errorText
s.FinishedAt = time.Now().UTC().Format(time.RFC3339)
s.FinishedAt = finishedAt.Format(time.RFC3339)
s.DurationMs = durationMs
})
}
@ -1528,6 +1573,10 @@ func trainingDeleteAllHandler(w http.ResponseWriter, r *http.Request) {
return
}
trainingSetJobStatus(func(s *TrainingJobStatus) {
*s = TrainingJobStatus{}
})
trainingWriteJSON(w, http.StatusOK, map[string]any{
"ok": true,
"message": "Alle Trainingsdaten wurden gelöscht.",

View File

@ -121,6 +121,7 @@ type TaskStateEvent = {
type?: 'task_state'
generateAssetsRunning?: boolean
regenerateAssetsRunning?: boolean
checkVideosRunning?: boolean
cleanupRunning?: boolean
anyRunning?: boolean
ts?: number
@ -1109,13 +1110,6 @@ export default function App() {
void loadTrainingStatus()
const timer = window.setInterval(
() => {
void loadTrainingStatus()
},
trainingTabRunning ? 1500 : 5000
)
const onVisibilityChange = () => {
if (!document.hidden) {
void loadTrainingStatus()
@ -1126,10 +1120,9 @@ export default function App() {
return () => {
cancelled = true
window.clearInterval(timer)
document.removeEventListener('visibilitychange', onVisibilityChange)
}
}, [authed, trainingTabRunning])
}, [authed])
const loadPendingAutoStarts = useCallback(async (opts?: { force?: boolean }) => {
const force = Boolean(opts?.force)
@ -2837,12 +2830,25 @@ export default function App() {
msg?.anyRunning ||
msg?.generateAssetsRunning ||
msg?.regenerateAssetsRunning ||
msg?.checkVideosRunning ||
msg?.cleanupRunning
)
)
} catch {}
}
const onTraining = (ev: MessageEvent) => {
try {
const data = JSON.parse(String(ev.data ?? 'null'))
if (data?.type !== 'training_status') return
setTrainingTabRunning(Boolean(data?.training?.running))
} catch {
// ignore
}
}
void loadJobs()
void loadDoneCount()
void loadTaskStateOnce()
@ -2880,6 +2886,7 @@ export default function App() {
es.addEventListener('autostart', onAutostart as any)
es.addEventListener('finishedPostwork', onFinishedPostwork as any)
es.addEventListener('taskState', onTaskState as any)
es.addEventListener('training', onTraining as any)
const onVis = () => {
if (document.hidden) return
@ -2904,6 +2911,7 @@ export default function App() {
es.removeEventListener('autostart', onAutostart as any)
es.removeEventListener('finishedPostwork', onFinishedPostwork as any)
es.removeEventListener('taskState', onTaskState as any)
es.removeEventListener('training', onTraining as any)
const handler = onModelJobEventRef.current
if (handler) {

View File

@ -512,6 +512,39 @@ export function DressIcon(props: IconProps) {
)
}
export function HarnessIcon(props: IconProps) {
return (
<IconBase {...props}>
{/* Hals-/Brustband */}
<path d="M8.2 5.2C9.3 4.5 10.6 4.1 12 4.1C13.4 4.1 14.7 4.5 15.8 5.2" />
<path d="M8.6 5.4L7.2 9.2" />
<path d="M15.4 5.4L16.8 9.2" />
{/* Schulter-/Brustgurte */}
<path d="M7.2 9.2C8.5 8.4 10.1 8 12 8C13.9 8 15.5 8.4 16.8 9.2" />
<path d="M7.2 9.2L5.8 15.2" />
<path d="M16.8 9.2L18.2 15.2" />
{/* Mittelgurt */}
<path d="M12 8V19.8" />
{/* Taillen-/Hüftband */}
<path d="M5.8 15.2C7.5 16.2 9.6 16.7 12 16.7C14.4 16.7 16.5 16.2 18.2 15.2" />
<path d="M7.2 19.8H16.8" />
{/* untere Verbindungsgurte */}
<path d="M5.8 15.2L7.2 19.8" />
<path d="M18.2 15.2L16.8 19.8" />
{/* Ringe / Metallösen */}
<circle cx="12" cy="8" r="0.9" />
<circle cx="12" cy="16.7" r="0.9" />
<circle cx="7.2" cy="9.2" r="0.7" />
<circle cx="16.8" cy="9.2" r="0.7" />
</IconBase>
)
}
export function ShoesIcon(props: IconProps) {
return (
<IconBase {...props}>
@ -663,22 +696,6 @@ export function TongueIcon(props: IconProps) {
)
}
export function BathIcon(props: IconProps) {
return (
<IconBase {...props}>
<path d="M5 11.5H20" />
<path d="M6 11.5V14.5C6 17.3 8.2 19.5 11 19.5H14C16.8 19.5 19 17.3 19 14.5V11.5" />
<path d="M8 19.5L7 21" />
<path d="M17 19.5L18 21" />
<path d="M5 11.5V7.5C5 5.6 6.4 4.2 8.2 4.2C9.8 4.2 11 5.4 11 7" />
<path d="M9.8 7H12.2" />
<circle cx="9" cy="9" r="0.5" fill="currentColor" stroke="none" />
<circle cx="12" cy="9.5" r="0.5" fill="currentColor" stroke="none" />
<circle cx="15" cy="9" r="0.5" fill="currentColor" stroke="none" />
</IconBase>
)
}
export function ShowerIcon(props: IconProps) {
return (
<IconBase {...props}>
@ -883,11 +900,6 @@ const SEGMENT_LABEL_META: SegmentLabelMeta[] = [
text: 'Fingering',
icon: FingeringIcon,
},
{
match: ['solo', 'masturbation'],
text: 'Solo',
icon: HandIcon,
},
{
match: ['standing_doggy', 'standing-doggy', 'standing doggy'],
text: 'Standing Doggy',
@ -983,11 +995,6 @@ const SEGMENT_LABEL_META: SegmentLabelMeta[] = [
},
// Objects
{
match: ['bath', 'bathtub', 'tub'],
text: 'Badewanne',
icon: BathIcon,
},
{
match: ['blindfold'],
text: 'Augenbinde',
@ -1013,11 +1020,6 @@ const SEGMENT_LABEL_META: SegmentLabelMeta[] = [
text: 'Handschellen',
icon: RopeIcon,
},
{
match: ['rope', 'tie', 'restraint', 'restraints'],
text: 'Seil',
icon: RopeIcon,
},
{
match: ['shower'],
text: 'Dusche',
@ -1095,6 +1097,11 @@ const SEGMENT_LABEL_META: SegmentLabelMeta[] = [
text: 'Fishnet',
icon: FishnetIcon,
},
{
match: ['harness', 'body_harness', 'body-harness'],
text: 'Harness',
icon: HarnessIcon,
},
{
match: ['heels', 'heel', 'high_heels', 'high-heels'],
text: 'High Heels',
@ -1115,11 +1122,6 @@ const SEGMENT_LABEL_META: SegmentLabelMeta[] = [
text: 'Panties',
icon: UnderwearIcon,
},
{
match: ['shirt', 'tshirt', 't-shirt', 'blouse', 'hoodie', 'sweater', 'jacket', 'coat'],
text: 'Shirt',
icon: ClothingIcon,
},
{
match: ['skirt'],
text: 'Rock',
@ -1262,10 +1264,6 @@ function findSegmentLabelMeta(label?: string): SegmentLabelMeta | null {
return { match: [], text: 'Mund', icon: MouthIcon }
}
if (normalized.includes('bath') || normalized.includes('tub')) {
return { match: [], text: 'Badewanne', icon: BathIcon }
}
if (normalized.includes('shower')) {
return { match: [], text: 'Dusche', icon: ShowerIcon }
}
@ -1302,10 +1300,6 @@ function findSegmentLabelMeta(label?: string): SegmentLabelMeta | null {
return { match: [], text: 'Handschellen', icon: RopeIcon }
}
if (normalized.includes('rope') || normalized.includes('restraint') || normalized.includes('tie')) {
return { match: [], text: 'Seil', icon: RopeIcon }
}
if (normalized.includes('towel')) {
return { match: [], text: 'Handtuch', icon: TowelIcon }
}
@ -1362,17 +1356,17 @@ function findSegmentLabelMeta(label?: string): SegmentLabelMeta | null {
}
if (
normalized.includes('shirt') ||
normalized.includes('tshirt') ||
normalized.includes('t-shirt') ||
normalized.includes('blouse') ||
normalized.includes('hoodie') ||
normalized.includes('sweater') ||
normalized.includes('jacket') ||
normalized.includes('coat') ||
normalized.includes('clothing')
normalized.includes('harness') ||
normalized.includes('clothing') ||
normalized.includes('clothes') ||
normalized.includes('garment') ||
normalized.includes('outfit')
) {
return { match: [], text: 'Oberteil', icon: ClothingIcon }
return {
match: [],
text: normalized.includes('harness') ? 'Harness' : 'Kleidung',
icon: HarnessIcon,
}
}
if (
@ -1446,14 +1440,12 @@ function prettifyUnknownLabel(label?: string): string {
.replace(/\bpussy\b/g, 'Vagina')
.replace(/\bpenis\b/g, 'Penis')
.replace(/\btongue\b/g, 'Zunge')
.replace(/\bbath\b/g, 'Badewanne')
.replace(/\bblindfold\b/g, 'Augenbinde')
.replace(/\bbuttplug\b/g, 'Buttplug')
.replace(/\bbutt plug\b/g, 'Buttplug')
.replace(/\bcollar\b/g, 'Halsband')
.replace(/\bdildo\b/g, 'Dildo')
.replace(/\bhandcuffs\b/g, 'Handschellen')
.replace(/\brope\b/g, 'Seil')
.replace(/\bshower\b/g, 'Dusche')
.replace(/\bstrapon\b/g, 'Strap-on')
.replace(/\bstrap on\b/g, 'Strap-on')
@ -1463,12 +1455,12 @@ function prettifyUnknownLabel(label?: string): string {
.replace(/\bbra\b/g, 'BH')
.replace(/\bdress\b/g, 'Kleid')
.replace(/\bfishnet\b/g, 'Fishnet')
.replace(/\bharness\b/g, 'Harness')
.replace(/\bheels\b/g, 'High Heels')
.replace(/\bheel\b/g, 'High Heel')
.replace(/\bhotpants\b/g, 'Hotpants')
.replace(/\blingerie\b/g, 'Lingerie')
.replace(/\bpanties\b/g, 'Panties')
.replace(/\bshirt\b/g, 'Shirt')
.replace(/\bskirt\b/g, 'Rock')
.replace(/\bstockings\b/g, 'Stockings')
.replace(/\bstocking\b/g, 'Stocking')

View File

@ -22,6 +22,10 @@ type TrainingJobStatus = {
error?: string
startedAt?: string
finishedAt?: string
durationMs?: number
stage?: string
epoch?: number
epochs?: number
}
type TrainingStatus = {
@ -148,6 +152,7 @@ type TrainingNotice = {
title: string
message: string
detail?: string
progress?: number
}
function trainingNoticeClass(kind: TrainingNoticeKind) {
@ -215,8 +220,14 @@ function TrainingNoticeCard(props: {
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold">
{props.notice.title}
<div className="flex flex-wrap items-center gap-2 text-sm font-semibold">
<span>{props.notice.title}</span>
{typeof props.notice.progress === 'number' ? (
<span className="rounded-full bg-black/5 px-2 py-0.5 text-[11px] font-bold ring-1 ring-black/10 dark:bg-white/10 dark:ring-white/10">
{Math.round(clampPercent(props.notice.progress))}%
</span>
) : null}
</div>
<div className="mt-0.5 break-words text-xs leading-snug">
@ -266,6 +277,23 @@ function backendText(data: any, fallback: string) {
).trim()
}
function trainingDurationMs(job?: TrainingJobStatus | null) {
const direct = Number(job?.durationMs)
if (Number.isFinite(direct) && direct > 0) {
return direct
}
const started = job?.startedAt ? Date.parse(job.startedAt) : NaN
const finished = job?.finishedAt ? Date.parse(job.finishedAt) : NaN
if (!Number.isFinite(started) || !Number.isFinite(finished)) {
return 0
}
return Math.max(0, finished - started)
}
function countPercent(count: number, total: number) {
if (!Number.isFinite(count) || !Number.isFinite(total) || total <= 0) return '0%'
return `${Math.round((count / total) * 100)}%`
@ -1883,6 +1911,7 @@ export default function TrainingTab(props: {
const [labels, setLabels] = useState<TrainingLabels>(emptyLabels)
const [sample, setSample] = useState<TrainingSample | null>(null)
const [correction, setCorrection] = useState<CorrectionState>(() => predictionToCorrection(null))
const [hasManualCorrection, setHasManualCorrection] = useState(false)
const [loading, setLoading] = useState(false)
const [analysisProgress, setAnalysisProgress] = useState(0)
const [analysisStep, setAnalysisStep] = useState('')
@ -1899,12 +1928,32 @@ export default function TrainingTab(props: {
const [trainingStatsLoading, setTrainingStatsLoading] = useState(false)
const [trainingStatsError, setTrainingStatsError] = useState<string | null>(null)
const wasTrainingRunningRef = useRef(false)
const shownTrainingCompletionRef = useRef<string | null>(null)
const imageBoxRef = useRef<HTMLDivElement | null>(null)
const detectorBoxesScrollRef = useRef<HTMLDivElement | null>(null)
const detectorBoxItemRefs = useRef<Array<HTMLDivElement | null>>([])
const epochTimingRef = useRef<{
lastEpoch: number
lastAt: number
}>({
lastEpoch: 0,
lastAt: 0,
})
const etaSmoothingRef = useRef<{
lastAt: number
}>({
lastAt: 0,
})
const [trainingNowMs, setTrainingNowMs] = useState(() => Date.now())
const [smoothedTrainingEtaMs, setSmoothedTrainingEtaMs] = useState(0)
const [estimatedEpochMs, setEstimatedEpochMs] = useState(0)
const [drawingBox, setDrawingBox] = useState<TrainingBox | null>(null)
const [boxInteraction, setBoxInteraction] = useState<BoxInteraction | null>(null)
const [touchMagnifier, setTouchMagnifier] = useState<MagnifierState | null>(null)
@ -2024,6 +2073,67 @@ export default function TrainingTab(props: {
if (data) setLabels(sortTrainingLabels(data))
}, [])
const applyTrainingStatus = useCallback((data: any) => {
if (!data) return
const job = data.training || null
setTrainingStatus((prev) => ({
feedbackCount: Number(data.feedbackCount ?? prev?.feedbackCount ?? 0),
requiredCount: Number(data.requiredCount ?? prev?.requiredCount ?? 5),
canTrain: Boolean(data.canTrain ?? prev?.canTrain ?? false),
training: job
? {
running: Boolean(job.running),
progress: Number(job.progress ?? 0),
step: String(job.step ?? ''),
message: job.message,
error: job.error,
startedAt: job.startedAt,
finishedAt: job.finishedAt,
durationMs: Number(job.durationMs ?? 0),
stage: job.stage,
epoch: Number(job.epoch ?? 0),
epochs: Number(job.epochs ?? 0),
}
: prev?.training,
}))
setTraining(Boolean(job?.running))
if (job?.message && !job.running) {
setTrainingProgress(100)
setTrainingStep('Training abgeschlossen.')
const finishedAt = String(job.finishedAt || '').trim()
const completionKey =
finishedAt || `${String(job.startedAt || '')}:${String(job.message || '')}`
if (completionKey && shownTrainingCompletionRef.current !== completionKey) {
shownTrainingCompletionRef.current = completionKey
const duration = trainingDurationMs(job)
const durationText = duration > 0
? ` Dauer: ${formatDuration(duration)}.`
: ''
setMessage(`${String(job.message)}${durationText}`)
}
}
if (job?.error && !job.running) {
const finishedAt = String(job.finishedAt || '').trim()
const errorKey = finishedAt
? `${finishedAt}:error`
: `${String(job.startedAt || '')}:error`
if (errorKey && shownTrainingCompletionRef.current !== errorKey) {
shownTrainingCompletionRef.current = errorKey
setError(String(job.error))
}
}
}, [])
const loadNext = useCallback(async (opts?: {
forceNew?: boolean
refreshPrediction?: boolean
@ -2046,6 +2156,8 @@ export default function TrainingTab(props: {
if (value < 35) return Math.min(35, value + 4)
if (value < 65) return Math.min(65, value + 2)
if (value < 95) return Math.min(95, value + 1)
setAnalysisStep('Analyse läuft noch… Ergebnis wird erwartet.')
return value
})
}, 350)
@ -2086,6 +2198,7 @@ export default function TrainingTab(props: {
setSample(data)
setCorrection(nextCorrection)
setHasManualCorrection(false)
const currentLabels = labelsRef.current
const personLabels = new Set(currentLabels.people)
@ -2137,37 +2250,8 @@ export default function TrainingTab(props: {
if (!res.ok || !data) return
const job = data.training || null
setTrainingStatus({
feedbackCount: Number(data.feedbackCount ?? 0),
requiredCount: Number(data.requiredCount ?? 5),
canTrain: Boolean(data.canTrain),
training: job
? {
running: Boolean(job.running),
progress: Number(job.progress ?? 0),
step: String(job.step ?? ''),
message: job.message,
error: job.error,
startedAt: job.startedAt,
finishedAt: job.finishedAt,
}
: undefined,
})
setTraining(Boolean(job?.running))
if (job?.message && !job.running) {
setTrainingProgress(100)
setTrainingStep('Training abgeschlossen.')
setMessage((prev) => prev || String(job.message))
}
if (job?.error && !job.running) {
setError((prev) => prev || String(job.error))
}
}, [])
applyTrainingStatus(data)
}, [applyTrainingStatus])
const loadTrainingStats = useCallback(async () => {
setTrainingStatsLoading(true)
@ -2204,6 +2288,35 @@ export default function TrainingTab(props: {
}
}, [])
useEffect(() => {
const es = new EventSource('/api/events/stream')
const onTraining = (ev: MessageEvent) => {
try {
const data = JSON.parse(String(ev.data ?? 'null'))
if (data?.type !== 'training_status') return
applyTrainingStatus({
training: data.training,
})
} catch {
// ignore
}
}
es.addEventListener('training', onTraining as EventListener)
es.onerror = () => {
// Optional: Polling-Fallback bleibt separat bestehen.
}
return () => {
es.removeEventListener('training', onTraining as EventListener)
es.close()
}
}, [applyTrainingStatus])
useEffect(() => {
if (!statsModalOpen) return
@ -2216,6 +2329,25 @@ export default function TrainingTab(props: {
onTrainingRunningChange?.(trainingRunning)
}, [trainingRunning, onTrainingRunningChange])
useEffect(() => {
if (!trainingRunning) {
etaSmoothingRef.current = {
lastAt: 0,
}
setSmoothedTrainingEtaMs(0)
return
}
setTrainingNowMs(Date.now())
const timer = window.setInterval(() => {
setTrainingNowMs(Date.now())
}, 1000)
return () => window.clearInterval(timer)
}, [trainingRunning])
useEffect(() => {
if (!boxLabel) return
@ -2287,14 +2419,6 @@ export default function TrainingTab(props: {
}
}, [loadLabels, loadNext, loadTrainingStatus])
useEffect(() => {
const timer = window.setInterval(() => {
void loadTrainingStatus()
}, trainingRunning ? 1500 : 5000)
return () => window.clearInterval(timer)
}, [loadTrainingStatus, trainingRunning])
useEffect(() => {
if (!trainingRunning) return
@ -2332,6 +2456,57 @@ export default function TrainingTab(props: {
trainingStatus?.training?.step,
])
useEffect(() => {
const job = trainingStatus?.training
const epoch = Number(job?.epoch ?? 0)
const epochs = Number(job?.epochs ?? 0)
if (!trainingRunning || epoch <= 0 || epochs <= 0) {
epochTimingRef.current = {
lastEpoch: 0,
lastAt: 0,
}
setEstimatedEpochMs(0)
return
}
const now = Date.now()
const previous = epochTimingRef.current
if (
previous.lastEpoch > 0 &&
previous.lastAt > 0 &&
epoch > previous.lastEpoch
) {
const measuredMsPerEpoch = (now - previous.lastAt) / (epoch - previous.lastEpoch)
if (Number.isFinite(measuredMsPerEpoch) && measuredMsPerEpoch > 0) {
setEstimatedEpochMs((old) => {
if (!Number.isFinite(old) || old <= 0) return measuredMsPerEpoch
const clampedMeasured = Math.max(
old * 0.5,
Math.min(old * 2, measuredMsPerEpoch)
)
// Stärker glätten, damit Restzeit/Ø-Zeit nicht springt.
return old * 0.85 + clampedMeasured * 0.15
})
}
}
if (epoch !== previous.lastEpoch) {
epochTimingRef.current = {
lastEpoch: epoch,
lastAt: now,
}
}
}, [
trainingRunning,
trainingStatus?.training?.epoch,
trainingStatus?.training?.epochs,
])
const saveFeedback = useCallback(
async (accepted: boolean) => {
if (!sample) return
@ -2390,6 +2565,8 @@ export default function TrainingTab(props: {
)
const startTraining = useCallback(async () => {
shownTrainingCompletionRef.current = null
setTraining(true)
setTrainingProgress(5)
setTrainingStep('Training wird gestartet…')
@ -2407,8 +2584,6 @@ export default function TrainingTab(props: {
throw new Error(backendText(data, `HTTP ${res.status}`))
}
setMessage(backendText(data, 'Training wurde gestartet.'))
await loadTrainingStatus()
// WICHTIG:
@ -2585,10 +2760,16 @@ export default function TrainingTab(props: {
})
}
const correctedNextBox = boxGeometryChanged(original, nextBox)
const geometryChanged = boxGeometryChanged(original, nextBox)
const correctedNextBox = geometryChanged
? markBoxCorrected(nextBox)
: nextBox
if (geometryChanged) {
setHasManualCorrection(true)
}
setCorrection((prev) => ({
...prev,
boxes: (prev.boxes ?? []).map((box, index) =>
@ -2636,6 +2817,8 @@ export default function TrainingTab(props: {
if (box.w < 0.01 || box.h < 0.01) return
setHasManualCorrection(true)
setCorrection((prev) => {
const next: CorrectionState = {
...prev,
@ -2647,6 +2830,8 @@ export default function TrainingTab(props: {
}, [boxInteraction, drawingBox])
const removeBox = useCallback((index: number) => {
setHasManualCorrection(true)
let removedLabel = ''
let shouldClearBoxLabel = false
@ -2678,12 +2863,21 @@ export default function TrainingTab(props: {
}, [])
const changeBoxLabel = useCallback((index: number, nextLabel: string) => {
const currentLabel = String(correction.boxes?.[index]?.label || '').trim()
const cleanNextLabel = String(nextLabel || '').trim()
if (currentLabel !== cleanNextLabel) {
setHasManualCorrection(true)
}
setCorrection((prev) =>
changeBoxLabelInCorrection(prev, index, nextLabel, labelsRef.current)
)
}, [])
}, [correction.boxes])
const clearBoxes = useCallback(() => {
setHasManualCorrection(true)
setBoxLabel('')
setActiveBoxIndex(null)
@ -2710,6 +2904,117 @@ export default function TrainingTab(props: {
const showImageBoxes = !loading && !trainingRunning
const shownTrainingDurationMs = useMemo(() => {
const job = trainingStatus?.training
if (!job) return 0
if (job.running && job.startedAt) {
const started = Date.parse(job.startedAt)
if (Number.isFinite(started)) {
return Math.max(0, trainingNowMs - started)
}
}
return trainingDurationMs(job)
}, [trainingStatus?.training, trainingNowMs])
const rawTrainingEtaMs = useMemo(() => {
if (!trainingRunning) return 0
const job = trainingStatus?.training
const epoch = Number(job?.epoch ?? 0)
const epochs = Number(job?.epochs ?? 0)
if (
Number.isFinite(epoch) &&
Number.isFinite(epochs) &&
Number.isFinite(estimatedEpochMs) &&
epoch > 0 &&
epochs > 0 &&
epoch < epochs &&
estimatedEpochMs > 0
) {
return Math.max(0, (epochs - epoch) * estimatedEpochMs)
}
const progress = clampPercent(Number(shownTrainingProgress))
if (
shownTrainingDurationMs > 0 &&
progress >= 5 &&
progress < 99
) {
return Math.max(0, shownTrainingDurationMs * ((100 - progress) / progress))
}
return 0
}, [
trainingRunning,
trainingStatus?.training,
estimatedEpochMs,
shownTrainingDurationMs,
shownTrainingProgress,
])
useEffect(() => {
if (!trainingRunning || rawTrainingEtaMs <= 0) {
etaSmoothingRef.current = {
lastAt: 0,
}
setSmoothedTrainingEtaMs(0)
return
}
setSmoothedTrainingEtaMs((previous) => {
const lastAt = etaSmoothingRef.current.lastAt || trainingNowMs
const elapsed = Math.max(0, trainingNowMs - lastAt)
// Anzeige zählt zwischen Backend-Updates weiter runter.
const countedDown = previous > 0
? Math.max(0, previous - elapsed)
: rawTrainingEtaMs
const diff = rawTrainingEtaMs - countedDown
// Nach oben sehr vorsichtig glätten, nach unten etwas schneller.
const factor = diff > 0 ? 0.08 : 0.18
let next = countedDown + diff * factor
// Harte Sprünge zusätzlich begrenzen.
if (diff > 0) {
next = Math.min(next, countedDown + 10_000)
} else {
next = Math.max(next, countedDown - 20_000)
}
next = Math.max(0, next)
etaSmoothingRef.current = {
lastAt: trainingNowMs,
}
return next
})
}, [trainingRunning, rawTrainingEtaMs, trainingNowMs])
const shownTrainingEtaMs = smoothedTrainingEtaMs
const shownTrainingEpochText = useMemo(() => {
const epoch = Number(trainingStatus?.training?.epoch ?? 0)
const epochs = Number(trainingStatus?.training?.epochs ?? 0)
if (!Number.isFinite(epoch) || !Number.isFinite(epochs)) return ''
if (epoch <= 0 || epochs <= 0) return ''
return `Epoche ${epoch}/${epochs}`
}, [
trainingStatus?.training?.epoch,
trainingStatus?.training?.epochs,
])
const activeNotice = useMemo<TrainingNotice | null>(() => {
if (error) {
return {
@ -2732,15 +3037,47 @@ export default function TrainingTab(props: {
}
if (trainingRunning) {
const parts: string[] = []
if (shownTrainingDurationMs > 0) {
parts.push(`Laufzeit ${formatDuration(shownTrainingDurationMs)}`)
}
if (shownTrainingEtaMs > 0) {
parts.push(`Restzeit ca. ${formatDuration(shownTrainingEtaMs)}`)
}
if (shownTrainingEpochText) {
parts.push(shownTrainingEpochText)
}
if (estimatedEpochMs > 0) {
parts.push(`Ø ${formatDuration(estimatedEpochMs)} pro Epoche`)
}
return {
kind: 'info',
title: 'Training läuft',
message: shownTrainingStep || 'Training läuft im Hintergrund.',
title: 'Trainingsprognose',
progress: shownTrainingProgress,
message: parts.length > 0
? parts.join(' · ')
: shownTrainingStep || 'Training läuft…',
detail: shownTrainingStep || undefined,
}
}
return null
}, [error, message, trainingRunning, shownTrainingStep])
}, [
error,
message,
trainingRunning,
shownTrainingStep,
shownTrainingDurationMs,
shownTrainingEtaMs,
shownTrainingEpochText,
estimatedEpochMs,
shownTrainingProgress,
])
useEffect(() => {
if (!message) return
@ -3455,13 +3792,15 @@ export default function TrainingTab(props: {
<Button
variant="soft"
color="emerald"
disabled={uiLocked || !sample || !sample.prediction.modelAvailable}
disabled={uiLocked || !sample || !sample.prediction.modelAvailable || hasManualCorrection}
onClick={() => void saveFeedback(true)}
className="w-full justify-center"
title={
sample?.prediction.modelAvailable
? 'Die Erkennung stimmt. Prediction wird als korrekt gespeichert.'
: 'Erst verfügbar, wenn ein Modell trainiert wurde.'
hasManualCorrection
? 'Du hast Korrekturen vorgenommen. Bitte mit „Speichern & weiter“ speichern.'
: sample?.prediction.modelAvailable
? 'Die Erkennung stimmt. Prediction wird als korrekt gespeichert.'
: 'Erst verfügbar, wenn ein Modell trainiert wurde.'
}
>
Passt so & weiter
@ -3469,10 +3808,14 @@ export default function TrainingTab(props: {
<Button
variant="primary"
disabled={uiLocked || !sample}
disabled={uiLocked || !sample || !hasManualCorrection}
onClick={() => void saveFeedback(false)}
className="w-full justify-center"
title="Die Werte rechts werden als richtige Korrektur gespeichert."
title={
hasManualCorrection
? 'Die Werte rechts werden als richtige Korrektur gespeichert.'
: 'Erst verfügbar, wenn du eine Korrektur vorgenommen hast.'
}
>
Speichern & weiter
</Button>
@ -3553,10 +3896,16 @@ export default function TrainingTab(props: {
}))
}
onChange={(value) =>
setCorrection((p) => ({
...p,
sexPosition: value,
}))
setCorrection((p) => {
if (p.sexPosition !== value) {
setHasManualCorrection(true)
}
return {
...p,
sexPosition: value,
}
})
}
disabled={uiLocked}
gridClassName="grid grid-cols-3 gap-2"
@ -3595,11 +3944,15 @@ export default function TrainingTab(props: {
bodyParts: expanded,
}))
}
onToggle={(value) =>
setCorrection((p) => ({
...p,
bodyPartsPresent: toggleArrayValue(p.bodyPartsPresent, value),
}))
onToggle={(value) =>
setCorrection((p) => {
setHasManualCorrection(true)
return {
...p,
bodyPartsPresent: toggleArrayValue(p.bodyPartsPresent, value),
}
})
}
drawLabel={boxLabel}
onDrawLabelChange={setBoxLabel}
@ -3620,10 +3973,14 @@ export default function TrainingTab(props: {
}))
}
onToggle={(value) =>
setCorrection((p) => ({
...p,
objectsPresent: toggleArrayValue(p.objectsPresent, value),
}))
setCorrection((p) => {
setHasManualCorrection(true)
return {
...p,
objectsPresent: toggleArrayValue(p.objectsPresent, value),
}
})
}
drawLabel={boxLabel}
onDrawLabelChange={setBoxLabel}
@ -3644,10 +4001,14 @@ export default function TrainingTab(props: {
}))
}
onToggle={(value) =>
setCorrection((p) => ({
...p,
clothingPresent: toggleArrayValue(p.clothingPresent, value),
}))
setCorrection((p) => {
setHasManualCorrection(true)
return {
...p,
clothingPresent: toggleArrayValue(p.clothingPresent, value),
}
})
}
drawLabel={boxLabel}
onDrawLabelChange={setBoxLabel}