This commit is contained in:
Linrador 2026-05-12 19:19:18 +02:00
parent e29893b0cb
commit a836bc5c7e
6 changed files with 785 additions and 161 deletions

View File

@ -1,2 +1,2 @@
HTTPS_ENABLED=1
HTTPS_ENABLED=0
AUTH_RP_ORIGINS=https://l14pbbk95100006.tegdssd.de:9999,https://l14pbbk95100006.tegdssd.de:5173,https://localhost:9999,https://127.0.0.1:9999,https://10.0.1.25:9999,http://localhost:5173,http://127.0.0.1:5173,http://10.0.1.25:5173

View File

@ -11,7 +11,7 @@
"useMyFreeCamsWatcher": true,
"autoDeleteSmallDownloads": true,
"autoDeleteSmallDownloadsBelowMB": 300,
"autoDeleteSmallDownloadsKeepFavorites": true,
"autoDeleteSmallDownloadsKeepFavorites": false,
"lowDiskPauseBelowGB": 5,
"blurPreviews": false,
"teaserPlayback": "all",

View File

@ -97,6 +97,7 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
api.HandleFunc("/api/training/delete-all", trainingDeleteAllHandler)
api.HandleFunc("/api/training/skip", trainingSkipHandler)
api.HandleFunc("/api/training/import-video", trainingImportVideoHandler)
api.HandleFunc("/api/training/video-preview", trainingVideoPreviewHandler)
api.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler)
api.HandleFunc("/api/chaturbate/biocontext", chaturbateBioContextHandler)

View File

@ -13,6 +13,7 @@ import (
"math"
"math/rand"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
@ -697,13 +698,33 @@ func trainingPublishAnalysisStep(
total int,
sourceFile string,
message string,
) {
trainingPublishAnalysisStepWithPreview(
requestID,
startedAtMs,
current,
total,
sourceFile,
"",
message,
)
}
func trainingPublishAnalysisStepWithPreview(
requestID string,
startedAtMs int64,
current int,
total int,
sourceFile string,
previewURL string,
message string,
) {
progress := 0.0
if total > 0 {
progress = float64(current) / float64(total)
}
b, err := json.Marshal(map[string]any{
payload := map[string]any{
"type": "analysis_progress",
"scope": "training",
"requestId": requestID,
@ -716,7 +737,13 @@ func trainingPublishAnalysisStep(
"sourceFile": strings.TrimSpace(sourceFile),
"message": strings.TrimSpace(message),
"ts": time.Now().UnixMilli(),
})
}
if strings.TrimSpace(previewURL) != "" {
payload["previewUrl"] = strings.TrimSpace(previewURL)
}
b, err := json.Marshal(payload)
if err != nil {
return
}
@ -729,10 +756,26 @@ func trainingPublishAnalysisStarted(
total int,
sourceFile string,
message string,
) int64 {
return trainingPublishAnalysisStartedWithPreview(
requestID,
total,
sourceFile,
"",
message,
)
}
func trainingPublishAnalysisStartedWithPreview(
requestID string,
total int,
sourceFile string,
previewURL string,
message string,
) int64 {
startedAtMs := time.Now().UnixMilli()
b, err := json.Marshal(map[string]any{
payload := map[string]any{
"type": "analysis_progress",
"scope": "training",
"requestId": requestID,
@ -745,7 +788,13 @@ func trainingPublishAnalysisStarted(
"sourceFile": strings.TrimSpace(sourceFile),
"message": strings.TrimSpace(message),
"ts": time.Now().UnixMilli(),
})
}
if strings.TrimSpace(previewURL) != "" {
payload["previewUrl"] = strings.TrimSpace(previewURL)
}
b, err := json.Marshal(payload)
if err == nil {
publishSSE("analysisProgress", b)
}
@ -1068,6 +1117,187 @@ func trainingSupportedImportVideo(path string) bool {
}
}
func trainingGeneratedAssetIDCandidatesForVideo(videoPath string) []string {
videoPath = strings.TrimSpace(videoPath)
if videoPath == "" {
return nil
}
out := []string{}
seen := map[string]bool{}
add := func(id string) {
id = stripHotPrefix(strings.TrimSpace(id))
if id == "" ||
id == "." ||
id == ".." ||
strings.Contains(id, "/") ||
strings.Contains(id, "\\") {
return
}
if seen[id] {
return
}
seen[id] = true
out = append(out, id)
}
// Fall 1:
// Video liegt selbst unter /generated/<id>/...
//
// Beispiel:
// C:\app\generated\abc123\video.mp4
// => abc123
slashPath := filepath.ToSlash(filepath.Clean(videoPath))
parts := strings.Split(slashPath, "/")
for i := 0; i+1 < len(parts); i++ {
if strings.EqualFold(parts[i], "generated") {
add(parts[i+1])
}
}
// Fall 2:
// Video liegt z.B. in done/keep, aber generated/<id>/preview.jpg
// basiert auf dem Dateinamen ohne Extension.
//
// Beispiel:
// done/keep/model/abc123.mp4
// => generated/abc123/preview.jpg
base := filepath.Base(videoPath)
stem := strings.TrimSuffix(base, filepath.Ext(base))
add(stem)
return out
}
func trainingGeneratedPreviewPathForAssetID(assetID string) (string, bool) {
assetID = stripHotPrefix(strings.TrimSpace(assetID))
if assetID == "" ||
assetID == "." ||
assetID == ".." ||
strings.Contains(assetID, "/") ||
strings.Contains(assetID, "\\") {
return "", false
}
previewPath, err := resolvePathRelativeToApp(
filepath.Join("generated", assetID, "preview.jpg"),
)
if err != nil {
return "", false
}
if !fileExistsNonEmpty(previewPath) {
return "", false
}
return previewPath, true
}
func trainingPreviewPathForVideo(videoPath string) (string, bool) {
for _, assetID := range trainingGeneratedAssetIDCandidatesForVideo(videoPath) {
if previewPath, ok := trainingGeneratedPreviewPathForAssetID(assetID); ok {
return previewPath, true
}
}
return "", false
}
func trainingPreviewURLForVideoPath(videoPath string) string {
videoPath = strings.TrimSpace(videoPath)
if videoPath == "" {
return ""
}
if !trainingSupportedImportVideo(videoPath) {
return ""
}
return "/api/training/video-preview?output=" + url.QueryEscape(videoPath)
}
func trainingPreviewAssetIDForVideo(videoPath string) string {
candidates := trainingGeneratedAssetIDCandidatesForVideo(videoPath)
for _, assetID := range candidates {
if _, ok := trainingGeneratedPreviewPathForAssetID(assetID); ok {
return assetID
}
}
for _, assetID := range candidates {
if _, err := findFinishedFileByID(assetID); err == nil {
return assetID
}
}
if len(candidates) > 0 {
return candidates[0]
}
return ""
}
func trainingVideoPreviewHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
outPath := strings.TrimSpace(r.URL.Query().Get("output"))
if outPath == "" {
trainingWriteError(w, http.StatusBadRequest, "output missing")
return
}
if !trainingSupportedImportVideo(outPath) {
trainingWriteError(w, http.StatusBadRequest, "unsupported video type")
return
}
st, err := os.Stat(outPath)
if err != nil || st == nil || st.IsDir() || st.Size() <= 0 {
trainingWriteError(w, http.StatusNotFound, "video not found")
return
}
// Fast path: Wenn /generated/<id>/preview.jpg schon existiert, direkt ausliefern.
if previewPath, ok := trainingPreviewPathForVideo(outPath); ok {
w.Header().Set("Cache-Control", "no-store")
servePreviewJPGFile(w, r, previewPath)
return
}
assetID := trainingPreviewAssetIDForVideo(outPath)
if assetID == "" {
trainingWriteError(w, http.StatusNotFound, "preview asset id not found")
return
}
// Wichtig:
// Nicht file=preview.jpg setzen.
// Ohne file=... darf recordPreviewWithBase die Preview bei Bedarf erzeugen.
r2 := r.Clone(r.Context())
u := *r.URL
q := u.Query()
q.Set("id", assetID)
q.Del("output")
q.Del("file")
q.Del("fallbackOnly")
u.RawQuery = q.Encode()
r2.URL = &u
recordPreviewWithBase(w, r2, "/api/training/video-preview")
}
func trainingFrameSecondsForVideo(duration float64, count int) []float64 {
count = trainingCleanImportVideoCount(count)
@ -1176,6 +1406,7 @@ func trainingImportVideoHandler(w http.ResponseWriter, r *http.Request) {
seconds := trainingFrameSecondsForVideo(duration, req.Count)
sourceFile := filepath.Base(outPath)
previewURL := trainingPreviewURLForVideoPath(outPath)
requestID := strings.TrimSpace(req.AnalysisRequestID)
if requestID == "" {
@ -1187,10 +1418,11 @@ func trainingImportVideoHandler(w http.ResponseWriter, r *http.Request) {
totalSteps = 1
}
startedAtMs := trainingPublishAnalysisStarted(
startedAtMs := trainingPublishAnalysisStartedWithPreview(
requestID,
totalSteps,
sourceFile,
previewURL,
"Video wird ins Training übernommen…",
)
@ -1205,12 +1437,13 @@ func trainingImportVideoHandler(w http.ResponseWriter, r *http.Request) {
for i, second := range seconds {
stepBase := i * 3
trainingPublishAnalysisStep(
trainingPublishAnalysisStepWithPreview(
requestID,
startedAtMs,
stepBase+1,
totalSteps,
sourceFile,
previewURL,
fmt.Sprintf("Frame %d/%d wird extrahiert…", i+1, len(seconds)),
)
@ -1222,12 +1455,13 @@ func trainingImportVideoHandler(w http.ResponseWriter, r *http.Request) {
continue
}
trainingPublishAnalysisStep(
trainingPublishAnalysisStepWithPreview(
requestID,
startedAtMs,
stepBase+2,
totalSteps,
sourceFile,
previewURL,
fmt.Sprintf("Frame %d/%d wird analysiert…", i+1, len(seconds)),
)
@ -1244,12 +1478,13 @@ func trainingImportVideoHandler(w http.ResponseWriter, r *http.Request) {
Prediction: prediction,
}
trainingPublishAnalysisStep(
trainingPublishAnalysisStepWithPreview(
requestID,
startedAtMs,
stepBase+3,
totalSteps,
sourceFile,
previewURL,
fmt.Sprintf("Frame %d/%d wird gespeichert…", i+1, len(seconds)),
)
@ -3191,6 +3426,19 @@ func trainingCreateNextSampleWithProgressRange(
}
sourceFile := filepath.Base(videoPath)
previewURL := trainingPreviewURLForVideoPath(videoPath)
publishStep = func(localStep int, sourceFile string, message string) {
trainingPublishAnalysisStepWithPreview(
requestID,
startedAtMs,
stepStart+localStep-1,
stepTotal,
sourceFile,
previewURL,
prefix+message,
)
}
publishStep(2, sourceFile, "Bild wird extrahiert…")

View File

@ -143,6 +143,13 @@ type MagnifierState = {
imageY: number
}
type PendingTrainingVideoImport = {
jobId?: string
output: string
sourceFile?: string
count?: number
}
type TrainingConfidence = {
score: number
level: 'none' | 'low' | 'mid' | 'high'
@ -751,69 +758,65 @@ function sortTrainingLabels(input: Partial<TrainingLabels> | null | undefined):
}
}
function TrainingOverlay(props: { step: string; progress: number }) {
return (
<div className="absolute inset-0 z-[200] flex items-center justify-center text-center text-white">
<div className="absolute inset-0 rounded-md bg-black/45 backdrop-blur-[8px] shadow-[inset_0_0_72px_30px_rgba(0,0,0,0.75)]" />
<div className="relative z-10 flex flex-col items-center justify-center">
<LoadingSpinner
size="lg"
className="text-white"
srLabel="Training läuft…"
/>
<div className="mt-3 text-sm font-semibold">
Training läuft
</div>
<div className="mt-1 max-w-[260px] px-4 text-xs text-white/80">
{props.step || 'Bitte warten. Die Oberfläche ist währenddessen gesperrt.'}
</div>
<div className="mt-3 h-2 w-48 overflow-hidden rounded-full bg-white/20">
<div
className="h-full rounded-full bg-emerald-400 transition-all duration-500"
style={{ width: `${clampPercent(props.progress)}%` }}
/>
</div>
<div className="mt-1 text-[11px] text-white/70">
{Math.round(props.progress)}%
</div>
</div>
</div>
)
}
function LoadingImageOverlay(props: {
function TrainingStageOverlay(props: {
mode: 'training' | 'analysis'
text?: string
progress?: number
backgroundUrl?: string
}) {
const progress = clampPercent(props.progress ?? 0)
const isTraining = props.mode === 'training'
const hasBackground = !isTraining && Boolean(props.backgroundUrl)
const title = isTraining ? 'Training läuft…' : 'Analyse läuft…'
const fallbackText = isTraining
? 'Bitte warten. Die Oberfläche ist währenddessen gesperrt.'
: 'Bild wird erstellt und analysiert. Bitte warten.'
return (
<div className="absolute inset-0 z-[200] flex items-center justify-center text-center text-white">
<div className="absolute inset-0 rounded-md bg-black/45 backdrop-blur-[8px] shadow-[inset_0_0_72px_30px_rgba(0,0,0,0.75)]" />
<div className="absolute inset-0 z-[500] flex items-center justify-center overflow-hidden rounded-md bg-black text-center text-white">
<div className="absolute inset-1 overflow-hidden rounded-md sm:inset-2">
{hasBackground ? (
<img
src={props.backgroundUrl}
alt=""
aria-hidden="true"
draggable={false}
className="absolute inset-0 z-0 h-full w-full scale-105 object-cover opacity-80 blur-[1px]"
/>
) : null}
<div
className={[
'absolute inset-0 z-[1] rounded-md',
hasBackground
? 'bg-black/30 backdrop-blur-[4px] shadow-[inset_0_0_48px_18px_rgba(0,0,0,0.55)]'
: 'bg-black/45 backdrop-blur-[8px] shadow-[inset_0_0_72px_30px_rgba(0,0,0,0.75)]',
].join(' ')}
/>
</div>
<div className="relative z-10 flex flex-col items-center justify-center">
<LoadingSpinner
size="lg"
className="text-white"
srLabel="Analyse läuft…"
srLabel={title}
/>
<div className="mt-3 text-sm font-semibold">
Analyse läuft
{title}
</div>
<div className="mt-1 max-w-[260px] px-4 text-xs text-white/80">
{props.text || 'Bild wird erstellt und analysiert. Bitte warten.'}
{props.text || fallbackText}
</div>
<div className="mt-3 h-2 w-48 overflow-hidden rounded-full bg-white/20">
<div
className="h-full rounded-full bg-indigo-400 transition-all duration-500"
className={[
'h-full rounded-full transition-all duration-500',
isTraining ? 'bg-indigo-400' : 'bg-emerald-400',
].join(' ')}
style={{ width: `${progress}%` }}
/>
</div>
@ -1977,6 +1980,9 @@ export default function TrainingTab(props: {
const wasTrainingRunningRef = useRef(false)
const shownTrainingCompletionRef = useRef<string | null>(null)
const [importedSampleQueue, setImportedSampleQueue] = useState<TrainingSample[]>([])
const importedSampleQueueRef = useRef<TrainingSample[]>([])
const [feedbackModalOpen, setFeedbackModalOpen] = useState(false)
const [feedbackItems, setFeedbackItems] = useState<TrainingAnnotation[]>([])
const [feedbackLoading, setFeedbackLoading] = useState(false)
@ -2001,6 +2007,15 @@ export default function TrainingTab(props: {
const [frameImageLoaded, setFrameImageLoaded] = useState(false)
const [imageExpanded, setImageExpanded] = useState(false)
const [frameNaturalSize, setFrameNaturalSize] = useState<{
width: number
height: number
} | null>(null)
const [loadingPreviewUrl, setLoadingPreviewUrl] = useState('')
const [loadingPreviewLoaded, setLoadingPreviewLoaded] = useState(false)
const [loadingPreviewFailed, setLoadingPreviewFailed] = useState(false)
const imageBoxRef = useRef<HTMLDivElement | null>(null)
const frameImageRef = useRef<HTMLImageElement | null>(null)
@ -2016,13 +2031,13 @@ export default function TrainingTab(props: {
}
const loadFeedbackHistoryInitial = useCallback(async (
options?: {
options: {
query?: string
filter?: FeedbackFilter
}
} = {}
) => {
const query = options?.query ?? feedbackSearchQuery
const filter = options?.filter ?? feedbackSearchFilter
const query = options.query ?? ''
const filter = options.filter ?? 'all'
setFeedbackLoading(true)
setFeedbackError(null)
@ -2059,7 +2074,7 @@ export default function TrainingTab(props: {
} finally {
setFeedbackLoading(false)
}
}, [feedbackSearchFilter, feedbackSearchQuery])
}, [])
const loadMoreFeedbackHistory = useCallback(async () => {
if (feedbackLoading || feedbackLoadingMore || !feedbackHasMore) return
@ -2204,6 +2219,9 @@ export default function TrainingTab(props: {
const activeAnalysisRequestIdRef = useRef<string | null>(null)
const loadingRef = useRef(false)
const videoImportStartedRef = useRef(false)
const videoImportInFlightKeyRef = useRef<string | null>(null)
const epochTimingRef = useRef<{
@ -2236,6 +2254,7 @@ export default function TrainingTab(props: {
const [activeBoxIndex, setActiveBoxIndex] = useState<number | null>(null)
const [imageReloadKey, setImageReloadKey] = useState(0)
const [trainingSampleMode, setTrainingSampleMode] = useState<TrainingSampleMode>('random')
const trainingSampleModeRef = useRef<TrainingSampleMode>('random')
const [expandedCorrectionSections, setExpandedCorrectionSections] = useState({
sexPosition: false,
people: false,
@ -2318,6 +2337,10 @@ export default function TrainingTab(props: {
const labelsRef = useRef<TrainingLabels>(emptyLabels)
useEffect(() => {
importedSampleQueueRef.current = importedSampleQueue
}, [importedSampleQueue])
useEffect(() => {
if (!feedbackModalOpen) return
@ -2338,6 +2361,10 @@ export default function TrainingTab(props: {
labelsRef.current = labels
}, [labels])
useEffect(() => {
trainingSampleModeRef.current = trainingSampleMode
}, [trainingSampleMode])
const boxLabels = useMemo(() => {
return uniqStrings([
...labels.people,
@ -2411,8 +2438,39 @@ export default function TrainingTab(props: {
const imageSrc = useMemo(() => {
if (!sample?.frameUrl) return ''
return `${sample.frameUrl}&t=${encodeURIComponent(sample.sampleId)}&r=${imageReloadKey}`
}, [sample, imageReloadKey])
const sep = sample.frameUrl.includes('?') ? '&' : '?'
return `${sample.frameUrl}${sep}t=${encodeURIComponent(sample.sampleId)}&r=${imageReloadKey}`
}, [sample?.frameUrl, sample?.sampleId, imageReloadKey])
const loadingPreviewRawUrlRef = useRef('')
const setLoadingPreviewCandidate = useCallback((url: string) => {
const clean = String(url || '').trim()
if (!clean) {
loadingPreviewRawUrlRef.current = ''
setLoadingPreviewUrl('')
setLoadingPreviewLoaded(false)
setLoadingPreviewFailed(false)
return
}
// Wichtig:
// Bei gleichem Preview nicht neu cache-busten und nicht loaded=false setzen.
// Sonst flackert das Overlay bei jedem Phasenwechsel.
if (loadingPreviewRawUrlRef.current === clean) {
return
}
loadingPreviewRawUrlRef.current = clean
const sep = clean.includes('?') ? '&' : '?'
setLoadingPreviewUrl(`${clean}${sep}r=${Date.now()}`)
setLoadingPreviewLoaded(false)
setLoadingPreviewFailed(false)
}, [])
useEffect(() => {
if (!imageSrc) {
@ -2527,6 +2585,71 @@ export default function TrainingTab(props: {
}
}, [])
const loadTrainingSampleIntoTab = useCallback((
nextSample: TrainingSample,
opts?: { manualCorrection?: boolean }
) => {
const nextCorrection = predictionToCorrection(nextSample)
setDrawingBox(null)
setBoxInteraction(null)
setTouchMagnifier(null)
setBoxLabel('')
setActiveBoxIndex(null)
setMobilePanel(trainingRunningRef.current ? 'training' : 'labels')
window.requestAnimationFrame(() => {
mobileLabelsScrollRef.current?.scrollTo({
top: 0,
behavior: 'smooth',
})
})
setSample(nextSample)
setCorrection(nextCorrection)
setHasManualCorrection(Boolean(opts?.manualCorrection))
const initiallyExpandedSection: CorrectionSectionKey | null =
nextCorrection.sexPosition && nextCorrection.sexPosition !== 'unknown'
? 'sexPosition'
: nextCorrection.peoplePresent.length > 0
? 'people'
: nextCorrection.bodyPartsPresent.length > 0
? 'bodyParts'
: nextCorrection.objectsPresent.length > 0
? 'objects'
: nextCorrection.clothingPresent.length > 0
? 'clothing'
: null
setExpandedCorrectionSections(
initiallyExpandedSection
? nextExpandedCorrectionSections(initiallyExpandedSection, true)
: {
sexPosition: false,
people: false,
bodyParts: false,
objects: false,
clothing: false,
}
)
}, [])
const loadNextImportedQueuedSample = useCallback(() => {
const [nextSample, ...rest] = importedSampleQueueRef.current
if (!nextSample) {
return false
}
importedSampleQueueRef.current = rest
setImportedSampleQueue(rest)
loadTrainingSampleIntoTab(nextSample)
return true
}, [loadTrainingSampleIntoTab])
const loadNext = useCallback(async (opts?: {
forceNew?: boolean
refreshPrediction?: boolean
@ -2535,10 +2658,12 @@ export default function TrainingTab(props: {
}) => {
const requestId = makeRequestId()
activeAnalysisRequestIdRef.current = requestId
const isCurrentRequest = () => activeAnalysisRequestIdRef.current === requestId
const mode = opts?.mode ?? trainingSampleMode
const mode = opts?.mode ?? trainingSampleModeRef.current
const uncertainMode = mode === 'uncertain' && !opts?.refreshPrediction
setLoadingPreviewCandidate('')
setLoading(true)
setAnalysisProgress(8)
setAnalysisStep(
@ -2581,56 +2706,23 @@ export default function TrainingTab(props: {
throw new Error(data?.error || `HTTP ${res.status}`)
}
if (!isCurrentRequest()) {
return
}
setAnalysisProgress(92)
setAnalysisStep('Analyse-Ergebnis wird übernommen…')
const nextCorrection = predictionToCorrection(data)
setDrawingBox(null)
setBoxInteraction(null)
setTouchMagnifier(null)
setBoxLabel('')
setActiveBoxIndex(null)
setMobilePanel(trainingRunningRef.current ? 'training' : 'labels')
window.requestAnimationFrame(() => {
mobileLabelsScrollRef.current?.scrollTo({
top: 0,
behavior: 'smooth',
})
})
setSample(data)
setCorrection(nextCorrection)
setHasManualCorrection(false)
const initiallyExpandedSection: CorrectionSectionKey | null =
nextCorrection.sexPosition && nextCorrection.sexPosition !== 'unknown'
? 'sexPosition'
: nextCorrection.peoplePresent.length > 0
? 'people'
: nextCorrection.bodyPartsPresent.length > 0
? 'bodyParts'
: nextCorrection.objectsPresent.length > 0
? 'objects'
: nextCorrection.clothingPresent.length > 0
? 'clothing'
: null
setExpandedCorrectionSections(
initiallyExpandedSection
? nextExpandedCorrectionSections(initiallyExpandedSection, true)
: {
sexPosition: false,
people: false,
bodyParts: false,
objects: false,
clothing: false,
}
)
loadTrainingSampleIntoTab(data as TrainingSample)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
if (isCurrentRequest()) {
setError(e instanceof Error ? e.message : String(e))
}
} finally {
if (!isCurrentRequest()) {
return
}
setAnalysisProgress((value) => Math.max(value, 100))
setAnalysisStep((value) => value || 'Analyse abgeschlossen.')
@ -2645,7 +2737,7 @@ export default function TrainingTab(props: {
setAnalysisStep('')
}, 500)
}
}, [trainingSampleMode])
}, [loadTrainingSampleIntoTab, setLoadingPreviewCandidate])
const reloadCurrentImage = useCallback(async () => {
setDrawingBox(null)
@ -2666,6 +2758,124 @@ export default function TrainingTab(props: {
applyTrainingStatus(data)
}, [applyTrainingStatus])
const importVideoIntoTraining = useCallback(async (raw: any) => {
const output = String(raw?.output || '').trim()
if (!output) return false
setLoadingPreviewCandidate(
`/api/training/video-preview?output=${encodeURIComponent(output)}`
)
const detail: PendingTrainingVideoImport = {
jobId: String(raw?.jobId || '').trim(),
output,
sourceFile: String(raw?.sourceFile || '').trim(),
count: Number(raw?.count || 8),
}
const importKey = `${detail.jobId || ''}|${detail.output}|${detail.count || 8}`
// Verhindert Doppelimport durch sessionStorage + CustomEvent.
if (videoImportInFlightKeyRef.current === importKey) {
return false
}
videoImportStartedRef.current = true
videoImportInFlightKeyRef.current = importKey
const requestId = makeRequestId()
activeAnalysisRequestIdRef.current = requestId
loadingRef.current = true
setLoading(true)
setAnalysisProgress(5)
setAnalysisStep('Video wird ins Training übernommen…')
setError(null)
setMessage(null)
try {
try {
window.sessionStorage.removeItem('training:pending-import-video')
} catch {
// ignore
}
const res = await fetch('/api/training/import-video', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
body: JSON.stringify({
jobId: detail.jobId,
output: detail.output,
count: detail.count || 8,
analysisRequestId: requestId,
}),
})
const data = await res.json().catch(() => null)
if (!res.ok || !data?.ok) {
throw new Error(backendText(data, `HTTP ${res.status}`))
}
const samples: TrainingSample[] = Array.isArray(data.samples)
? data.samples
: data.sample
? [data.sample]
: []
if (samples.length === 0) {
throw new Error('Es wurden keine Trainingsframes erzeugt.')
}
const [firstSample, ...queuedSamples] = samples
importedSampleQueueRef.current = queuedSamples
setImportedSampleQueue(queuedSamples)
loadTrainingSampleIntoTab(firstSample)
setImageReloadKey((value) => value + 1)
await loadTrainingStatus()
const errorCount = Array.isArray(data.errors) ? data.errors.length : 0
setMessage(
errorCount > 0
? `${samples.length} Frames ins Training übernommen, ${errorCount} Frames fehlgeschlagen.`
: `${samples.length} Frames ins Training übernommen.`
)
return true
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
return false
} finally {
setAnalysisProgress(100)
setAnalysisStep('Video-Import abgeschlossen.')
const finishedRequestId = requestId
window.setTimeout(() => {
if (activeAnalysisRequestIdRef.current === finishedRequestId) {
activeAnalysisRequestIdRef.current = null
loadingRef.current = false
setLoading(false)
setAnalysisProgress(0)
setAnalysisStep('')
}
if (videoImportInFlightKeyRef.current === importKey) {
videoImportInFlightKeyRef.current = null
}
}, 500)
}
}, [
loadTrainingSampleIntoTab,
loadTrainingStatus,
setLoadingPreviewCandidate,
])
const loadTrainingStats = useCallback(async () => {
setTrainingStatsLoading(true)
setTrainingStatsError(null)
@ -2790,6 +3000,16 @@ export default function TrainingTab(props: {
''
).trim()
const previewUrl = String(
data?.previewUrl ||
data?.analysis?.previewUrl ||
''
).trim()
if (previewUrl) {
setLoadingPreviewCandidate(previewUrl)
}
const current = Number(data?.current ?? data?.stepIndex ?? data?.index)
const total = Number(data?.total ?? data?.steps ?? data?.stepTotal)
@ -2832,7 +3052,7 @@ export default function TrainingTab(props: {
return () => {
window.removeEventListener('app:sse:analysis', onAnalysis as EventListener)
}
}, [])
}, [setLoadingPreviewCandidate])
useEffect(() => {
const draggingBox = Boolean(drawingBox || boxInteraction)
@ -2959,6 +3179,30 @@ export default function TrainingTab(props: {
})
}, [activeBoxIndex])
useEffect(() => {
const onImportVideo = (event: Event) => {
const detail = (event as CustomEvent<any>).detail
void importVideoIntoTraining(detail)
}
window.addEventListener('training:import-video', onImportVideo as EventListener)
try {
const raw = window.sessionStorage.getItem('training:pending-import-video')
if (raw) {
const detail = JSON.parse(raw)
void importVideoIntoTraining(detail)
}
} catch {
// ignore
}
return () => {
window.removeEventListener('training:import-video', onImportVideo as EventListener)
}
}, [importVideoIntoTraining])
useEffect(() => {
let cancelled = false
@ -2968,8 +3212,13 @@ export default function TrainingTab(props: {
if (cancelled) return
// Wichtig: Auch während laufendem Training wieder das aktuelle offene Sample laden,
// damit nicht "Kein Bild geladen" angezeigt wird.
// Wichtig:
// Wenn gerade ein Video-Import über "Video ins Training übernehmen" läuft,
// darf loadNext() nicht danach ein zufälliges/letztes Sample darüberlegen.
if (videoImportStartedRef.current || videoImportInFlightKeyRef.current) {
return
}
await loadNext()
}
@ -3161,14 +3410,27 @@ export default function TrainingTab(props: {
)
await loadTrainingStatus()
await loadNext({ preserveNotice: true })
if (!loadNextImportedQueuedSample()) {
await loadNext({
forceNew: true,
preserveNotice: true,
})
}
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setSaving(false)
}
},
[sample, correction, editingFeedback, loadNext, loadTrainingStatus]
[
sample,
correction,
editingFeedback,
loadNext,
loadTrainingStatus,
loadNextImportedQueuedSample,
]
)
const skipCurrentSample = useCallback(async () => {
@ -3202,16 +3464,18 @@ export default function TrainingTab(props: {
setBoxLabel('')
setActiveBoxIndex(null)
await loadNext({
forceNew: true,
preserveNotice: true,
})
if (!loadNextImportedQueuedSample()) {
await loadNext({
forceNew: true,
preserveNotice: true,
})
}
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setSaving(false)
}
}, [sample, loadNext])
}, [sample, loadNext, loadNextImportedQueuedSample])
const startTraining = useCallback(async () => {
shownTrainingCompletionRef.current = null
@ -3683,6 +3947,15 @@ export default function TrainingTab(props: {
const frameBusy = loading || (!!imageSrc && !frameImageLoaded)
useEffect(() => {
if (loading || frameBusy) return
if (!loadingPreviewUrl) return
setLoadingPreviewUrl('')
setLoadingPreviewLoaded(false)
setLoadingPreviewFailed(false)
}, [loading, frameBusy, loadingPreviewUrl])
const showImageBoxes = !frameBusy && !trainingRunning
const shownTrainingDurationMs = useMemo(() => {
@ -4387,8 +4660,115 @@ export default function TrainingTab(props: {
? 'touch-none'
: 'touch-pan-y'
const loadingPreviewBackgroundUrl =
loadingPreviewUrl && loadingPreviewLoaded && !loadingPreviewFailed
? loadingPreviewUrl
: ''
const frameLayoutSize = useMemo(() => {
const width = Number(frameNaturalSize?.width)
const height = Number(frameNaturalSize?.height)
if (
Number.isFinite(width) &&
Number.isFinite(height) &&
width > 0 &&
height > 0
) {
return { width, height }
}
// Fallback, bis das echte Bildformat bekannt ist.
return { width: 1600, height: 900 }
}, [frameNaturalSize])
const imageAspectRatio = Math.max(
0.25,
Math.min(4, frameLayoutSize.width / frameLayoutSize.height)
)
const imageStageLimits = imageExpanded
? {
baseDvh: 52,
smDvh: 60,
lgDvh: 78,
lgPx: 820,
}
: {
baseDvh: 44,
smDvh: 52,
lgDvh: 64,
lgPx: 680,
}
const imageStageStyle = {
aspectRatio: `${frameLayoutSize.width} / ${frameLayoutSize.height}`,
'--image-stage-max-h': `${imageStageLimits.baseDvh}dvh`,
'--image-stage-max-h-sm': `${imageStageLimits.smDvh}dvh`,
'--image-stage-max-h-lg': `min(${imageStageLimits.lgDvh}dvh, ${imageStageLimits.lgPx}px)`,
'--image-stage-w': `${imageStageLimits.baseDvh * imageAspectRatio}dvh`,
'--image-stage-w-sm': `${imageStageLimits.smDvh * imageAspectRatio}dvh`,
'--image-stage-w-lg': `min(${imageStageLimits.lgDvh * imageAspectRatio}dvh, ${Math.round(
imageStageLimits.lgPx * imageAspectRatio
)}px)`,
} as CSSProperties & Record<string, string | number>
const imageStageHeightClass = [
'max-h-[var(--image-stage-max-h)]',
'sm:max-h-[var(--image-stage-max-h-sm)]',
'lg:max-h-[var(--image-stage-max-h-lg)]',
'w-[min(100%,var(--image-stage-w))]',
'sm:w-[min(100%,var(--image-stage-w-sm))]',
'lg:w-[min(100%,var(--image-stage-w-lg))]',
].join(' ')
const stageBusy = trainingRunning || frameBusy
const stageOverlayMode: 'training' | 'analysis' =
trainingRunning ? 'training' : 'analysis'
const stageOverlayText = trainingRunning
? shownTrainingStep || 'Aktuelles Bild wird geladen…'
: analysisStep || 'Bild wird geladen…'
const stageOverlayProgress = trainingRunning
? shownTrainingProgress
: loading
? analysisProgress
: 100
return (
<div className="min-h-full lg:h-full lg:min-h-0 lg:overflow-hidden">
{loadingPreviewUrl ? (
<img
src={loadingPreviewUrl}
alt=""
aria-hidden="true"
draggable={false}
className="pointer-events-none fixed h-px w-px opacity-0"
style={{ left: -9999, top: -9999 }}
onLoad={(e) => {
const img = e.currentTarget
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
setFrameNaturalSize({
width: img.naturalWidth,
height: img.naturalHeight,
})
}
setLoadingPreviewLoaded(true)
setLoadingPreviewFailed(false)
}}
onError={() => {
setLoadingPreviewLoaded(false)
setLoadingPreviewFailed(true)
}}
/>
) : null}
<div className="mb-2 flex items-center justify-between gap-2 rounded-xl border border-gray-200 bg-white px-3 py-2 shadow-sm dark:border-white/10 dark:bg-gray-900/80 lg:hidden">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
@ -4499,29 +4879,23 @@ export default function TrainingTab(props: {
<section
className={[
'min-w-0 rounded-xl border border-gray-200 bg-white p-2 shadow-sm dark:border-white/10 dark:bg-gray-900/60 sm:p-3',
imageExpanded
? 'lg:grid lg:h-full lg:min-h-0 lg:grid-rows-[minmax(0,1fr)_auto_auto] lg:self-stretch lg:overflow-visible'
: 'lg:self-start',
'lg:self-start',
].join(' ')}
>
<div
className={[
'relative z-50 flex min-h-[180px] min-h-0 items-center justify-center rounded-lg bg-black p-2 sm:p-3',
imageExpanded ? 'lg:h-full lg:min-h-0 lg:overflow-visible' : 'h-full overflow-visible',
'relative z-50 mx-auto flex items-center justify-center rounded-lg bg-black p-2 sm:p-3',
imageStageHeightClass,
'overflow-visible',
].join(' ')}
style={imageStageStyle}
>
{imageSrc ? (
<div
className={[
'relative z-50 flex min-h-0 w-full items-center justify-center',
imageExpanded ? 'lg:h-full lg:overflow-visible' : 'h-full overflow-visible',
].join(' ')}
>
<div className="relative z-50 flex h-full min-h-0 w-full items-center justify-center overflow-visible">
<div
ref={imageBoxRef}
className={[
'relative z-50 inline-flex min-h-0 max-h-[52dvh] max-w-full select-none overscroll-contain transition sm:max-h-[60dvh]',
imageExpanded ? 'lg:max-h-full lg:overflow-visible' : 'lg:max-h-[72dvh]',
'relative z-50 inline-flex min-h-0 max-h-full max-w-full select-none overscroll-contain transition',
imageTouchClass,
'[-webkit-touch-callout:none] [-webkit-user-select:none] [user-select:none]',
trainingRunning || loading ? 'pointer-events-none' : '',
@ -4535,7 +4909,18 @@ export default function TrainingTab(props: {
src={imageSrc}
alt="Training Frame"
draggable={false}
onLoad={() => {
width={frameLayoutSize.width}
height={frameLayoutSize.height}
onLoad={(e) => {
const img = e.currentTarget
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
setFrameNaturalSize({
width: img.naturalWidth,
height: img.naturalHeight,
})
}
setFrameImageLoaded(true)
window.requestAnimationFrame(updateImageLayerStyle)
}}
@ -4546,8 +4931,7 @@ export default function TrainingTab(props: {
onContextMenu={(e) => e.preventDefault()}
onDragStart={(e) => e.preventDefault()}
className={[
'block rounded-md h-auto min-h-0 max-h-[52dvh] max-w-full object-contain sm:max-h-[60dvh]',
imageExpanded ? 'lg:max-h-full' : 'lg:max-h-[72dvh]',
'block h-auto min-h-0 max-h-full max-w-full rounded-md object-contain',
'select-none',
imageTouchClass,
'[-webkit-user-drag:none] [-webkit-touch-callout:none]',
@ -4627,9 +5011,9 @@ export default function TrainingTab(props: {
'dark:bg-amber-400 dark:text-black dark:ring-black/15',
].join(' ')
: [
'bg-white/95 text-gray-950 ring-black/10 hover:bg-gray-50',
'dark:bg-white/95 dark:text-gray-950 dark:ring-black/10 dark:hover:bg-gray-50',
].join(' '),
'bg-white/95 text-gray-950 ring-black/10 hover:bg-gray-50',
'dark:bg-white/95 dark:text-gray-950 dark:ring-black/10 dark:hover:bg-gray-50',
].join(' '),
isDraft ? 'cursor-default' : 'cursor-move',
].join(' ')}
disabled={Boolean(isDraft) || uiLocked}
@ -4734,7 +5118,7 @@ export default function TrainingTab(props: {
}}
>
<TrashIcon
className="h-3 w-3 shrink-0 !text-white"
className="h-3 w-3 shrink-0 !text-white"
aria-hidden="true"
/>
</span>
@ -4781,8 +5165,6 @@ export default function TrainingTab(props: {
setActiveBoxIndex(index)
// Erster Klick/Touch aktiviert nur die Box.
// Erst danach darf Resize starten.
if (requiresActivationFirst) return
const pos = getPointerPosInImage(e.clientX, e.clientY)
@ -4969,32 +5351,25 @@ export default function TrainingTab(props: {
: null
})() : null}
</div>
{trainingRunning ? (
<TrainingOverlay
step={shownTrainingStep}
progress={shownTrainingProgress}
/>
) : frameBusy ? (
<LoadingImageOverlay
text={analysisStep || 'Bild wird geladen…'}
progress={loading ? analysisProgress : 100}
/>
) : null}
</div>
) : trainingRunning ? (
<TrainingOverlay
step={shownTrainingStep || 'Aktuelles Bild wird geladen…'}
progress={shownTrainingProgress}
/>
) : loading ? (
<LoadingImageOverlay
text={analysisStep}
progress={analysisProgress}
/>
) : (
<div className="text-sm text-white/80">Kein Bild geladen</div>
<div className="relative z-10 text-sm text-white/80">
Kein Bild geladen
</div>
)}
{stageBusy ? (
<TrainingStageOverlay
mode={stageOverlayMode}
text={stageOverlayText}
progress={stageOverlayProgress}
backgroundUrl={
stageOverlayMode === 'analysis'
? loadingPreviewBackgroundUrl
: undefined
}
/>
) : null}
</div>
<div