bugfixes
This commit is contained in:
parent
e29893b0cb
commit
a836bc5c7e
@ -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
|
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
|
||||||
Binary file not shown.
@ -11,7 +11,7 @@
|
|||||||
"useMyFreeCamsWatcher": true,
|
"useMyFreeCamsWatcher": true,
|
||||||
"autoDeleteSmallDownloads": true,
|
"autoDeleteSmallDownloads": true,
|
||||||
"autoDeleteSmallDownloadsBelowMB": 300,
|
"autoDeleteSmallDownloadsBelowMB": 300,
|
||||||
"autoDeleteSmallDownloadsKeepFavorites": true,
|
"autoDeleteSmallDownloadsKeepFavorites": false,
|
||||||
"lowDiskPauseBelowGB": 5,
|
"lowDiskPauseBelowGB": 5,
|
||||||
"blurPreviews": false,
|
"blurPreviews": false,
|
||||||
"teaserPlayback": "all",
|
"teaserPlayback": "all",
|
||||||
|
|||||||
@ -97,6 +97,7 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
|
|||||||
api.HandleFunc("/api/training/delete-all", trainingDeleteAllHandler)
|
api.HandleFunc("/api/training/delete-all", trainingDeleteAllHandler)
|
||||||
api.HandleFunc("/api/training/skip", trainingSkipHandler)
|
api.HandleFunc("/api/training/skip", trainingSkipHandler)
|
||||||
api.HandleFunc("/api/training/import-video", trainingImportVideoHandler)
|
api.HandleFunc("/api/training/import-video", trainingImportVideoHandler)
|
||||||
|
api.HandleFunc("/api/training/video-preview", trainingVideoPreviewHandler)
|
||||||
|
|
||||||
api.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler)
|
api.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler)
|
||||||
api.HandleFunc("/api/chaturbate/biocontext", chaturbateBioContextHandler)
|
api.HandleFunc("/api/chaturbate/biocontext", chaturbateBioContextHandler)
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@ -697,13 +698,33 @@ func trainingPublishAnalysisStep(
|
|||||||
total int,
|
total int,
|
||||||
sourceFile string,
|
sourceFile string,
|
||||||
message 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
|
progress := 0.0
|
||||||
if total > 0 {
|
if total > 0 {
|
||||||
progress = float64(current) / float64(total)
|
progress = float64(current) / float64(total)
|
||||||
}
|
}
|
||||||
|
|
||||||
b, err := json.Marshal(map[string]any{
|
payload := map[string]any{
|
||||||
"type": "analysis_progress",
|
"type": "analysis_progress",
|
||||||
"scope": "training",
|
"scope": "training",
|
||||||
"requestId": requestID,
|
"requestId": requestID,
|
||||||
@ -716,7 +737,13 @@ func trainingPublishAnalysisStep(
|
|||||||
"sourceFile": strings.TrimSpace(sourceFile),
|
"sourceFile": strings.TrimSpace(sourceFile),
|
||||||
"message": strings.TrimSpace(message),
|
"message": strings.TrimSpace(message),
|
||||||
"ts": time.Now().UnixMilli(),
|
"ts": time.Now().UnixMilli(),
|
||||||
})
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(previewURL) != "" {
|
||||||
|
payload["previewUrl"] = strings.TrimSpace(previewURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := json.Marshal(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -729,10 +756,26 @@ func trainingPublishAnalysisStarted(
|
|||||||
total int,
|
total int,
|
||||||
sourceFile string,
|
sourceFile string,
|
||||||
message string,
|
message string,
|
||||||
|
) int64 {
|
||||||
|
return trainingPublishAnalysisStartedWithPreview(
|
||||||
|
requestID,
|
||||||
|
total,
|
||||||
|
sourceFile,
|
||||||
|
"",
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func trainingPublishAnalysisStartedWithPreview(
|
||||||
|
requestID string,
|
||||||
|
total int,
|
||||||
|
sourceFile string,
|
||||||
|
previewURL string,
|
||||||
|
message string,
|
||||||
) int64 {
|
) int64 {
|
||||||
startedAtMs := time.Now().UnixMilli()
|
startedAtMs := time.Now().UnixMilli()
|
||||||
|
|
||||||
b, err := json.Marshal(map[string]any{
|
payload := map[string]any{
|
||||||
"type": "analysis_progress",
|
"type": "analysis_progress",
|
||||||
"scope": "training",
|
"scope": "training",
|
||||||
"requestId": requestID,
|
"requestId": requestID,
|
||||||
@ -745,7 +788,13 @@ func trainingPublishAnalysisStarted(
|
|||||||
"sourceFile": strings.TrimSpace(sourceFile),
|
"sourceFile": strings.TrimSpace(sourceFile),
|
||||||
"message": strings.TrimSpace(message),
|
"message": strings.TrimSpace(message),
|
||||||
"ts": time.Now().UnixMilli(),
|
"ts": time.Now().UnixMilli(),
|
||||||
})
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(previewURL) != "" {
|
||||||
|
payload["previewUrl"] = strings.TrimSpace(previewURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := json.Marshal(payload)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
publishSSE("analysisProgress", b)
|
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 {
|
func trainingFrameSecondsForVideo(duration float64, count int) []float64 {
|
||||||
count = trainingCleanImportVideoCount(count)
|
count = trainingCleanImportVideoCount(count)
|
||||||
|
|
||||||
@ -1176,6 +1406,7 @@ func trainingImportVideoHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
seconds := trainingFrameSecondsForVideo(duration, req.Count)
|
seconds := trainingFrameSecondsForVideo(duration, req.Count)
|
||||||
sourceFile := filepath.Base(outPath)
|
sourceFile := filepath.Base(outPath)
|
||||||
|
previewURL := trainingPreviewURLForVideoPath(outPath)
|
||||||
|
|
||||||
requestID := strings.TrimSpace(req.AnalysisRequestID)
|
requestID := strings.TrimSpace(req.AnalysisRequestID)
|
||||||
if requestID == "" {
|
if requestID == "" {
|
||||||
@ -1187,10 +1418,11 @@ func trainingImportVideoHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
totalSteps = 1
|
totalSteps = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
startedAtMs := trainingPublishAnalysisStarted(
|
startedAtMs := trainingPublishAnalysisStartedWithPreview(
|
||||||
requestID,
|
requestID,
|
||||||
totalSteps,
|
totalSteps,
|
||||||
sourceFile,
|
sourceFile,
|
||||||
|
previewURL,
|
||||||
"Video wird ins Training übernommen…",
|
"Video wird ins Training übernommen…",
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1205,12 +1437,13 @@ func trainingImportVideoHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
for i, second := range seconds {
|
for i, second := range seconds {
|
||||||
stepBase := i * 3
|
stepBase := i * 3
|
||||||
|
|
||||||
trainingPublishAnalysisStep(
|
trainingPublishAnalysisStepWithPreview(
|
||||||
requestID,
|
requestID,
|
||||||
startedAtMs,
|
startedAtMs,
|
||||||
stepBase+1,
|
stepBase+1,
|
||||||
totalSteps,
|
totalSteps,
|
||||||
sourceFile,
|
sourceFile,
|
||||||
|
previewURL,
|
||||||
fmt.Sprintf("Frame %d/%d wird extrahiert…", i+1, len(seconds)),
|
fmt.Sprintf("Frame %d/%d wird extrahiert…", i+1, len(seconds)),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1222,12 +1455,13 @@ func trainingImportVideoHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
trainingPublishAnalysisStep(
|
trainingPublishAnalysisStepWithPreview(
|
||||||
requestID,
|
requestID,
|
||||||
startedAtMs,
|
startedAtMs,
|
||||||
stepBase+2,
|
stepBase+2,
|
||||||
totalSteps,
|
totalSteps,
|
||||||
sourceFile,
|
sourceFile,
|
||||||
|
previewURL,
|
||||||
fmt.Sprintf("Frame %d/%d wird analysiert…", i+1, len(seconds)),
|
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,
|
Prediction: prediction,
|
||||||
}
|
}
|
||||||
|
|
||||||
trainingPublishAnalysisStep(
|
trainingPublishAnalysisStepWithPreview(
|
||||||
requestID,
|
requestID,
|
||||||
startedAtMs,
|
startedAtMs,
|
||||||
stepBase+3,
|
stepBase+3,
|
||||||
totalSteps,
|
totalSteps,
|
||||||
sourceFile,
|
sourceFile,
|
||||||
|
previewURL,
|
||||||
fmt.Sprintf("Frame %d/%d wird gespeichert…", i+1, len(seconds)),
|
fmt.Sprintf("Frame %d/%d wird gespeichert…", i+1, len(seconds)),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -3191,6 +3426,19 @@ func trainingCreateNextSampleWithProgressRange(
|
|||||||
}
|
}
|
||||||
|
|
||||||
sourceFile := filepath.Base(videoPath)
|
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…")
|
publishStep(2, sourceFile, "Bild wird extrahiert…")
|
||||||
|
|
||||||
|
|||||||
@ -143,6 +143,13 @@ type MagnifierState = {
|
|||||||
imageY: number
|
imageY: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PendingTrainingVideoImport = {
|
||||||
|
jobId?: string
|
||||||
|
output: string
|
||||||
|
sourceFile?: string
|
||||||
|
count?: number
|
||||||
|
}
|
||||||
|
|
||||||
type TrainingConfidence = {
|
type TrainingConfidence = {
|
||||||
score: number
|
score: number
|
||||||
level: 'none' | 'low' | 'mid' | 'high'
|
level: 'none' | 'low' | 'mid' | 'high'
|
||||||
@ -751,69 +758,65 @@ function sortTrainingLabels(input: Partial<TrainingLabels> | null | undefined):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function TrainingOverlay(props: { step: string; progress: number }) {
|
function TrainingStageOverlay(props: {
|
||||||
return (
|
mode: 'training' | 'analysis'
|
||||||
<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: {
|
|
||||||
text?: string
|
text?: string
|
||||||
progress?: number
|
progress?: number
|
||||||
|
backgroundUrl?: string
|
||||||
}) {
|
}) {
|
||||||
const progress = clampPercent(props.progress ?? 0)
|
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 (
|
return (
|
||||||
<div className="absolute inset-0 z-[200] flex items-center justify-center text-center text-white">
|
<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-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-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">
|
<div className="relative z-10 flex flex-col items-center justify-center">
|
||||||
<LoadingSpinner
|
<LoadingSpinner
|
||||||
size="lg"
|
size="lg"
|
||||||
className="text-white"
|
className="text-white"
|
||||||
srLabel="Analyse läuft…"
|
srLabel={title}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-3 text-sm font-semibold">
|
<div className="mt-3 text-sm font-semibold">
|
||||||
Analyse läuft…
|
{title}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-1 max-w-[260px] px-4 text-xs text-white/80">
|
<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>
|
||||||
|
|
||||||
<div className="mt-3 h-2 w-48 overflow-hidden rounded-full bg-white/20">
|
<div className="mt-3 h-2 w-48 overflow-hidden rounded-full bg-white/20">
|
||||||
<div
|
<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}%` }}
|
style={{ width: `${progress}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -1977,6 +1980,9 @@ export default function TrainingTab(props: {
|
|||||||
const wasTrainingRunningRef = useRef(false)
|
const wasTrainingRunningRef = useRef(false)
|
||||||
const shownTrainingCompletionRef = useRef<string | null>(null)
|
const shownTrainingCompletionRef = useRef<string | null>(null)
|
||||||
|
|
||||||
|
const [importedSampleQueue, setImportedSampleQueue] = useState<TrainingSample[]>([])
|
||||||
|
const importedSampleQueueRef = useRef<TrainingSample[]>([])
|
||||||
|
|
||||||
const [feedbackModalOpen, setFeedbackModalOpen] = useState(false)
|
const [feedbackModalOpen, setFeedbackModalOpen] = useState(false)
|
||||||
const [feedbackItems, setFeedbackItems] = useState<TrainingAnnotation[]>([])
|
const [feedbackItems, setFeedbackItems] = useState<TrainingAnnotation[]>([])
|
||||||
const [feedbackLoading, setFeedbackLoading] = useState(false)
|
const [feedbackLoading, setFeedbackLoading] = useState(false)
|
||||||
@ -2001,6 +2007,15 @@ export default function TrainingTab(props: {
|
|||||||
const [frameImageLoaded, setFrameImageLoaded] = useState(false)
|
const [frameImageLoaded, setFrameImageLoaded] = useState(false)
|
||||||
const [imageExpanded, setImageExpanded] = 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 imageBoxRef = useRef<HTMLDivElement | null>(null)
|
||||||
const frameImageRef = useRef<HTMLImageElement | null>(null)
|
const frameImageRef = useRef<HTMLImageElement | null>(null)
|
||||||
|
|
||||||
@ -2016,13 +2031,13 @@ export default function TrainingTab(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loadFeedbackHistoryInitial = useCallback(async (
|
const loadFeedbackHistoryInitial = useCallback(async (
|
||||||
options?: {
|
options: {
|
||||||
query?: string
|
query?: string
|
||||||
filter?: FeedbackFilter
|
filter?: FeedbackFilter
|
||||||
}
|
} = {}
|
||||||
) => {
|
) => {
|
||||||
const query = options?.query ?? feedbackSearchQuery
|
const query = options.query ?? ''
|
||||||
const filter = options?.filter ?? feedbackSearchFilter
|
const filter = options.filter ?? 'all'
|
||||||
|
|
||||||
setFeedbackLoading(true)
|
setFeedbackLoading(true)
|
||||||
setFeedbackError(null)
|
setFeedbackError(null)
|
||||||
@ -2059,7 +2074,7 @@ export default function TrainingTab(props: {
|
|||||||
} finally {
|
} finally {
|
||||||
setFeedbackLoading(false)
|
setFeedbackLoading(false)
|
||||||
}
|
}
|
||||||
}, [feedbackSearchFilter, feedbackSearchQuery])
|
}, [])
|
||||||
|
|
||||||
const loadMoreFeedbackHistory = useCallback(async () => {
|
const loadMoreFeedbackHistory = useCallback(async () => {
|
||||||
if (feedbackLoading || feedbackLoadingMore || !feedbackHasMore) return
|
if (feedbackLoading || feedbackLoadingMore || !feedbackHasMore) return
|
||||||
@ -2205,6 +2220,9 @@ export default function TrainingTab(props: {
|
|||||||
const activeAnalysisRequestIdRef = useRef<string | null>(null)
|
const activeAnalysisRequestIdRef = useRef<string | null>(null)
|
||||||
const loadingRef = useRef(false)
|
const loadingRef = useRef(false)
|
||||||
|
|
||||||
|
const videoImportStartedRef = useRef(false)
|
||||||
|
const videoImportInFlightKeyRef = useRef<string | null>(null)
|
||||||
|
|
||||||
|
|
||||||
const epochTimingRef = useRef<{
|
const epochTimingRef = useRef<{
|
||||||
firstEpochAt: number
|
firstEpochAt: number
|
||||||
@ -2236,6 +2254,7 @@ export default function TrainingTab(props: {
|
|||||||
const [activeBoxIndex, setActiveBoxIndex] = useState<number | null>(null)
|
const [activeBoxIndex, setActiveBoxIndex] = useState<number | null>(null)
|
||||||
const [imageReloadKey, setImageReloadKey] = useState(0)
|
const [imageReloadKey, setImageReloadKey] = useState(0)
|
||||||
const [trainingSampleMode, setTrainingSampleMode] = useState<TrainingSampleMode>('random')
|
const [trainingSampleMode, setTrainingSampleMode] = useState<TrainingSampleMode>('random')
|
||||||
|
const trainingSampleModeRef = useRef<TrainingSampleMode>('random')
|
||||||
const [expandedCorrectionSections, setExpandedCorrectionSections] = useState({
|
const [expandedCorrectionSections, setExpandedCorrectionSections] = useState({
|
||||||
sexPosition: false,
|
sexPosition: false,
|
||||||
people: false,
|
people: false,
|
||||||
@ -2318,6 +2337,10 @@ export default function TrainingTab(props: {
|
|||||||
|
|
||||||
const labelsRef = useRef<TrainingLabels>(emptyLabels)
|
const labelsRef = useRef<TrainingLabels>(emptyLabels)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
importedSampleQueueRef.current = importedSampleQueue
|
||||||
|
}, [importedSampleQueue])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!feedbackModalOpen) return
|
if (!feedbackModalOpen) return
|
||||||
|
|
||||||
@ -2338,6 +2361,10 @@ export default function TrainingTab(props: {
|
|||||||
labelsRef.current = labels
|
labelsRef.current = labels
|
||||||
}, [labels])
|
}, [labels])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
trainingSampleModeRef.current = trainingSampleMode
|
||||||
|
}, [trainingSampleMode])
|
||||||
|
|
||||||
const boxLabels = useMemo(() => {
|
const boxLabels = useMemo(() => {
|
||||||
return uniqStrings([
|
return uniqStrings([
|
||||||
...labels.people,
|
...labels.people,
|
||||||
@ -2411,8 +2438,39 @@ export default function TrainingTab(props: {
|
|||||||
const imageSrc = useMemo(() => {
|
const imageSrc = useMemo(() => {
|
||||||
if (!sample?.frameUrl) return ''
|
if (!sample?.frameUrl) return ''
|
||||||
|
|
||||||
return `${sample.frameUrl}&t=${encodeURIComponent(sample.sampleId)}&r=${imageReloadKey}`
|
const sep = sample.frameUrl.includes('?') ? '&' : '?'
|
||||||
}, [sample, imageReloadKey])
|
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
if (!imageSrc) {
|
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?: {
|
const loadNext = useCallback(async (opts?: {
|
||||||
forceNew?: boolean
|
forceNew?: boolean
|
||||||
refreshPrediction?: boolean
|
refreshPrediction?: boolean
|
||||||
@ -2535,10 +2658,12 @@ export default function TrainingTab(props: {
|
|||||||
}) => {
|
}) => {
|
||||||
const requestId = makeRequestId()
|
const requestId = makeRequestId()
|
||||||
activeAnalysisRequestIdRef.current = requestId
|
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
|
const uncertainMode = mode === 'uncertain' && !opts?.refreshPrediction
|
||||||
|
|
||||||
|
setLoadingPreviewCandidate('')
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setAnalysisProgress(8)
|
setAnalysisProgress(8)
|
||||||
setAnalysisStep(
|
setAnalysisStep(
|
||||||
@ -2581,56 +2706,23 @@ export default function TrainingTab(props: {
|
|||||||
throw new Error(data?.error || `HTTP ${res.status}`)
|
throw new Error(data?.error || `HTTP ${res.status}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isCurrentRequest()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setAnalysisProgress(92)
|
setAnalysisProgress(92)
|
||||||
setAnalysisStep('Analyse-Ergebnis wird übernommen…')
|
setAnalysisStep('Analyse-Ergebnis wird übernommen…')
|
||||||
|
|
||||||
const nextCorrection = predictionToCorrection(data)
|
loadTrainingSampleIntoTab(data as TrainingSample)
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : String(e))
|
if (isCurrentRequest()) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e))
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
if (!isCurrentRequest()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setAnalysisProgress((value) => Math.max(value, 100))
|
setAnalysisProgress((value) => Math.max(value, 100))
|
||||||
setAnalysisStep((value) => value || 'Analyse abgeschlossen.')
|
setAnalysisStep((value) => value || 'Analyse abgeschlossen.')
|
||||||
|
|
||||||
@ -2645,7 +2737,7 @@ export default function TrainingTab(props: {
|
|||||||
setAnalysisStep('')
|
setAnalysisStep('')
|
||||||
}, 500)
|
}, 500)
|
||||||
}
|
}
|
||||||
}, [trainingSampleMode])
|
}, [loadTrainingSampleIntoTab, setLoadingPreviewCandidate])
|
||||||
|
|
||||||
const reloadCurrentImage = useCallback(async () => {
|
const reloadCurrentImage = useCallback(async () => {
|
||||||
setDrawingBox(null)
|
setDrawingBox(null)
|
||||||
@ -2666,6 +2758,124 @@ export default function TrainingTab(props: {
|
|||||||
applyTrainingStatus(data)
|
applyTrainingStatus(data)
|
||||||
}, [applyTrainingStatus])
|
}, [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 () => {
|
const loadTrainingStats = useCallback(async () => {
|
||||||
setTrainingStatsLoading(true)
|
setTrainingStatsLoading(true)
|
||||||
setTrainingStatsError(null)
|
setTrainingStatsError(null)
|
||||||
@ -2790,6 +3000,16 @@ export default function TrainingTab(props: {
|
|||||||
''
|
''
|
||||||
).trim()
|
).trim()
|
||||||
|
|
||||||
|
const previewUrl = String(
|
||||||
|
data?.previewUrl ||
|
||||||
|
data?.analysis?.previewUrl ||
|
||||||
|
''
|
||||||
|
).trim()
|
||||||
|
|
||||||
|
if (previewUrl) {
|
||||||
|
setLoadingPreviewCandidate(previewUrl)
|
||||||
|
}
|
||||||
|
|
||||||
const current = Number(data?.current ?? data?.stepIndex ?? data?.index)
|
const current = Number(data?.current ?? data?.stepIndex ?? data?.index)
|
||||||
const total = Number(data?.total ?? data?.steps ?? data?.stepTotal)
|
const total = Number(data?.total ?? data?.steps ?? data?.stepTotal)
|
||||||
|
|
||||||
@ -2832,7 +3052,7 @@ export default function TrainingTab(props: {
|
|||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('app:sse:analysis', onAnalysis as EventListener)
|
window.removeEventListener('app:sse:analysis', onAnalysis as EventListener)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [setLoadingPreviewCandidate])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const draggingBox = Boolean(drawingBox || boxInteraction)
|
const draggingBox = Boolean(drawingBox || boxInteraction)
|
||||||
@ -2959,6 +3179,30 @@ export default function TrainingTab(props: {
|
|||||||
})
|
})
|
||||||
}, [activeBoxIndex])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
||||||
@ -2968,8 +3212,13 @@ export default function TrainingTab(props: {
|
|||||||
|
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
|
|
||||||
// Wichtig: Auch während laufendem Training wieder das aktuelle offene Sample laden,
|
// Wichtig:
|
||||||
// damit nicht "Kein Bild geladen" angezeigt wird.
|
// 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()
|
await loadNext()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3161,14 +3410,27 @@ export default function TrainingTab(props: {
|
|||||||
)
|
)
|
||||||
|
|
||||||
await loadTrainingStatus()
|
await loadTrainingStatus()
|
||||||
await loadNext({ preserveNotice: true })
|
|
||||||
|
if (!loadNextImportedQueuedSample()) {
|
||||||
|
await loadNext({
|
||||||
|
forceNew: true,
|
||||||
|
preserveNotice: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : String(e))
|
setError(e instanceof Error ? e.message : String(e))
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[sample, correction, editingFeedback, loadNext, loadTrainingStatus]
|
[
|
||||||
|
sample,
|
||||||
|
correction,
|
||||||
|
editingFeedback,
|
||||||
|
loadNext,
|
||||||
|
loadTrainingStatus,
|
||||||
|
loadNextImportedQueuedSample,
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
const skipCurrentSample = useCallback(async () => {
|
const skipCurrentSample = useCallback(async () => {
|
||||||
@ -3202,16 +3464,18 @@ export default function TrainingTab(props: {
|
|||||||
setBoxLabel('')
|
setBoxLabel('')
|
||||||
setActiveBoxIndex(null)
|
setActiveBoxIndex(null)
|
||||||
|
|
||||||
await loadNext({
|
if (!loadNextImportedQueuedSample()) {
|
||||||
forceNew: true,
|
await loadNext({
|
||||||
preserveNotice: true,
|
forceNew: true,
|
||||||
})
|
preserveNotice: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : String(e))
|
setError(e instanceof Error ? e.message : String(e))
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
}, [sample, loadNext])
|
}, [sample, loadNext, loadNextImportedQueuedSample])
|
||||||
|
|
||||||
const startTraining = useCallback(async () => {
|
const startTraining = useCallback(async () => {
|
||||||
shownTrainingCompletionRef.current = null
|
shownTrainingCompletionRef.current = null
|
||||||
@ -3683,6 +3947,15 @@ export default function TrainingTab(props: {
|
|||||||
|
|
||||||
const frameBusy = loading || (!!imageSrc && !frameImageLoaded)
|
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 showImageBoxes = !frameBusy && !trainingRunning
|
||||||
|
|
||||||
const shownTrainingDurationMs = useMemo(() => {
|
const shownTrainingDurationMs = useMemo(() => {
|
||||||
@ -4387,8 +4660,115 @@ export default function TrainingTab(props: {
|
|||||||
? 'touch-none'
|
? 'touch-none'
|
||||||
: 'touch-pan-y'
|
: '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 (
|
return (
|
||||||
<div className="min-h-full lg:h-full lg:min-h-0 lg:overflow-hidden">
|
<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="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="min-w-0 flex-1">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
@ -4499,29 +4879,23 @@ export default function TrainingTab(props: {
|
|||||||
<section
|
<section
|
||||||
className={[
|
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',
|
'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:self-start',
|
||||||
? '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',
|
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
'relative z-50 flex min-h-[180px] min-h-0 items-center justify-center rounded-lg bg-black p-2 sm:p-3',
|
'relative z-50 mx-auto flex 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',
|
imageStageHeightClass,
|
||||||
|
'overflow-visible',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
|
style={imageStageStyle}
|
||||||
>
|
>
|
||||||
{imageSrc ? (
|
{imageSrc ? (
|
||||||
<div
|
<div className="relative z-50 flex h-full min-h-0 w-full items-center justify-center overflow-visible">
|
||||||
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
|
<div
|
||||||
ref={imageBoxRef}
|
ref={imageBoxRef}
|
||||||
className={[
|
className={[
|
||||||
'relative z-50 inline-flex min-h-0 max-h-[52dvh] max-w-full select-none overscroll-contain transition sm:max-h-[60dvh]',
|
'relative z-50 inline-flex min-h-0 max-h-full max-w-full select-none overscroll-contain transition',
|
||||||
imageExpanded ? 'lg:max-h-full lg:overflow-visible' : 'lg:max-h-[72dvh]',
|
|
||||||
imageTouchClass,
|
imageTouchClass,
|
||||||
'[-webkit-touch-callout:none] [-webkit-user-select:none] [user-select:none]',
|
'[-webkit-touch-callout:none] [-webkit-user-select:none] [user-select:none]',
|
||||||
trainingRunning || loading ? 'pointer-events-none' : '',
|
trainingRunning || loading ? 'pointer-events-none' : '',
|
||||||
@ -4535,7 +4909,18 @@ export default function TrainingTab(props: {
|
|||||||
src={imageSrc}
|
src={imageSrc}
|
||||||
alt="Training Frame"
|
alt="Training Frame"
|
||||||
draggable={false}
|
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)
|
setFrameImageLoaded(true)
|
||||||
window.requestAnimationFrame(updateImageLayerStyle)
|
window.requestAnimationFrame(updateImageLayerStyle)
|
||||||
}}
|
}}
|
||||||
@ -4546,8 +4931,7 @@ export default function TrainingTab(props: {
|
|||||||
onContextMenu={(e) => e.preventDefault()}
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
onDragStart={(e) => e.preventDefault()}
|
onDragStart={(e) => e.preventDefault()}
|
||||||
className={[
|
className={[
|
||||||
'block rounded-md h-auto min-h-0 max-h-[52dvh] max-w-full object-contain sm:max-h-[60dvh]',
|
'block h-auto min-h-0 max-h-full max-w-full rounded-md object-contain',
|
||||||
imageExpanded ? 'lg:max-h-full' : 'lg:max-h-[72dvh]',
|
|
||||||
'select-none',
|
'select-none',
|
||||||
imageTouchClass,
|
imageTouchClass,
|
||||||
'[-webkit-user-drag:none] [-webkit-touch-callout:none]',
|
'[-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',
|
'dark:bg-amber-400 dark:text-black dark:ring-black/15',
|
||||||
].join(' ')
|
].join(' ')
|
||||||
: [
|
: [
|
||||||
'bg-white/95 text-gray-950 ring-black/10 hover:bg-gray-50',
|
'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',
|
'dark:bg-white/95 dark:text-gray-950 dark:ring-black/10 dark:hover:bg-gray-50',
|
||||||
].join(' '),
|
].join(' '),
|
||||||
isDraft ? 'cursor-default' : 'cursor-move',
|
isDraft ? 'cursor-default' : 'cursor-move',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
disabled={Boolean(isDraft) || uiLocked}
|
disabled={Boolean(isDraft) || uiLocked}
|
||||||
@ -4734,7 +5118,7 @@ export default function TrainingTab(props: {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TrashIcon
|
<TrashIcon
|
||||||
className="h-3 w-3 shrink-0 !text-white"
|
className="h-3 w-3 shrink-0 !text-white"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
@ -4781,8 +5165,6 @@ export default function TrainingTab(props: {
|
|||||||
|
|
||||||
setActiveBoxIndex(index)
|
setActiveBoxIndex(index)
|
||||||
|
|
||||||
// Erster Klick/Touch aktiviert nur die Box.
|
|
||||||
// Erst danach darf Resize starten.
|
|
||||||
if (requiresActivationFirst) return
|
if (requiresActivationFirst) return
|
||||||
|
|
||||||
const pos = getPointerPosInImage(e.clientX, e.clientY)
|
const pos = getPointerPosInImage(e.clientX, e.clientY)
|
||||||
@ -4969,32 +5351,25 @@ export default function TrainingTab(props: {
|
|||||||
: null
|
: null
|
||||||
})() : null}
|
})() : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{trainingRunning ? (
|
|
||||||
<TrainingOverlay
|
|
||||||
step={shownTrainingStep}
|
|
||||||
progress={shownTrainingProgress}
|
|
||||||
/>
|
|
||||||
) : frameBusy ? (
|
|
||||||
<LoadingImageOverlay
|
|
||||||
text={analysisStep || 'Bild wird geladen…'}
|
|
||||||
progress={loading ? analysisProgress : 100}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user