bugfixes
This commit is contained in:
parent
19faed7a40
commit
34df62aa13
@ -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"
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user