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
|
||||
Binary file not shown.
@ -11,7 +11,7 @@
|
||||
"useMyFreeCamsWatcher": true,
|
||||
"autoDeleteSmallDownloads": true,
|
||||
"autoDeleteSmallDownloadsBelowMB": 300,
|
||||
"autoDeleteSmallDownloadsKeepFavorites": true,
|
||||
"autoDeleteSmallDownloadsKeepFavorites": false,
|
||||
"lowDiskPauseBelowGB": 5,
|
||||
"blurPreviews": false,
|
||||
"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/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)
|
||||
|
||||
@ -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…")
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user