diff --git a/backend/routes.go b/backend/routes.go index 276c39e..c29959a 100644 --- a/backend/routes.go +++ b/backend/routes.go @@ -96,6 +96,7 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore { api.HandleFunc("/api/training/stats", trainingStatsHandler) api.HandleFunc("/api/training/delete-all", trainingDeleteAllHandler) api.HandleFunc("/api/training/skip", trainingSkipHandler) + api.HandleFunc("/api/training/import-video", trainingImportVideoHandler) api.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler) api.HandleFunc("/api/chaturbate/biocontext", chaturbateBioContextHandler) diff --git a/backend/training.go b/backend/training.go index 246c60a..e30ea8c 100644 --- a/backend/training.go +++ b/backend/training.go @@ -1029,6 +1029,276 @@ func trainingLabelsHandler(w http.ResponseWriter, r *http.Request) { trainingWriteJSON(w, http.StatusOK, defaultTrainingLabelsFromJSON()) } +const trainingImportVideoDefaultFrameCount = 8 +const trainingImportVideoMaxFrameCount = 30 + +type TrainingImportVideoRequest struct { + JobID string `json:"jobId"` + Output string `json:"output"` + Count int `json:"count"` + AnalysisRequestID string `json:"analysisRequestId"` +} + +type TrainingImportVideoResponse struct { + OK bool `json:"ok"` + Count int `json:"count"` + Sample *TrainingSample `json:"sample,omitempty"` + Samples []TrainingSample `json:"samples,omitempty"` + Errors []string `json:"errors,omitempty"` +} + +func trainingCleanImportVideoCount(count int) int { + if count <= 0 { + return trainingImportVideoDefaultFrameCount + } + + if count > trainingImportVideoMaxFrameCount { + return trainingImportVideoMaxFrameCount + } + + return count +} + +func trainingSupportedImportVideo(path string) bool { + switch strings.ToLower(filepath.Ext(path)) { + case ".mp4", ".m4v", ".mov", ".mkv", ".webm": + return true + default: + return false + } +} + +func trainingFrameSecondsForVideo(duration float64, count int) []float64 { + count = trainingCleanImportVideoCount(count) + + if duration <= 0 { + return []float64{0} + } + + if duration <= 2 { + return []float64{0} + } + + out := make([]float64, 0, count) + + maxSec := duration - 0.5 + if maxSec < 0 { + maxSec = duration + } + + for i := 0; i < count; i++ { + ratio := float64(i+1) / float64(count+1) + sec := duration * ratio + + if sec < 0.5 { + sec = 0.5 + } + if sec > maxSec { + sec = maxSec + } + + // Auf 0.1s runden, damit SampleIDs lesbarer/stabiler sind. + sec = math.Round(sec*10) / 10 + + if len(out) > 0 && math.Abs(sec-out[len(out)-1]) < 0.2 { + continue + } + + out = append(out, sec) + } + + if len(out) == 0 { + out = append(out, 0) + } + + return out +} + +func trainingImportVideoHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + var req TrainingImportVideoRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + trainingWriteError(w, http.StatusBadRequest, "invalid json") + return + } + + outPath := strings.TrimSpace(req.Output) + if outPath == "" { + trainingWriteError(w, http.StatusBadRequest, "output missing") + return + } + + if !trainingSupportedImportVideo(outPath) { + trainingWriteError(w, http.StatusBadRequest, "unsupported video type") + return + } + + fi, err := os.Stat(outPath) + if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { + if err == nil { + err = errors.New("video file missing or empty") + } + + trainingWriteError(w, http.StatusBadRequest, "video not found: "+err.Error()) + return + } + + duration := trainingProbeDurationSeconds(outPath) + if duration <= 0 { + trainingWriteError(w, http.StatusBadRequest, "Videolänge konnte nicht bestimmt werden") + return + } + + root, err := trainingRootDir() + if err != nil { + trainingWriteError(w, http.StatusInternalServerError, err.Error()) + return + } + + if err := trainingEnsureDetectorDirs(root); err != nil { + trainingWriteError(w, http.StatusInternalServerError, err.Error()) + return + } + + if err := os.MkdirAll(filepath.Join(root, "frames"), 0755); err != nil { + trainingWriteError(w, http.StatusInternalServerError, err.Error()) + return + } + + if err := os.MkdirAll(filepath.Join(root, "samples"), 0755); err != nil { + trainingWriteError(w, http.StatusInternalServerError, err.Error()) + return + } + + seconds := trainingFrameSecondsForVideo(duration, req.Count) + sourceFile := filepath.Base(outPath) + + requestID := strings.TrimSpace(req.AnalysisRequestID) + if requestID == "" { + requestID = trainingMakeSampleID(outPath, float64(time.Now().UnixNano())) + } + + totalSteps := len(seconds) * 3 + if totalSteps < 1 { + totalSteps = 1 + } + + startedAtMs := trainingPublishAnalysisStarted( + requestID, + totalSteps, + sourceFile, + "Video wird ins Training übernommen…", + ) + + var sourceSizeBytes int64 + if st, err := os.Stat(outPath); err == nil && st != nil && !st.IsDir() { + sourceSizeBytes = st.Size() + } + + samples := make([]TrainingSample, 0, len(seconds)) + errs := []string{} + + for i, second := range seconds { + stepBase := i * 3 + + trainingPublishAnalysisStep( + requestID, + startedAtMs, + stepBase+1, + totalSteps, + sourceFile, + fmt.Sprintf("Frame %d/%d wird extrahiert…", i+1, len(seconds)), + ) + + id := trainingMakeSampleID(outPath, second) + framePath := filepath.Join(root, "frames", id+".jpg") + + if err := trainingExtractFrame(outPath, framePath, second); err != nil { + errs = append(errs, fmt.Sprintf("Frame bei %.1fs: %v", second, err)) + continue + } + + trainingPublishAnalysisStep( + requestID, + startedAtMs, + stepBase+2, + totalSteps, + sourceFile, + fmt.Sprintf("Frame %d/%d wird analysiert…", i+1, len(seconds)), + ) + + prediction := trainingPredictFrame(framePath) + + sample := &TrainingSample{ + SampleID: id, + FrameURL: "/api/training/frame?id=" + id, + SourceFile: sourceFile, + SourcePath: outPath, + SourceSizeBytes: sourceSizeBytes, + Second: second, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + Prediction: prediction, + } + + trainingPublishAnalysisStep( + requestID, + startedAtMs, + stepBase+3, + totalSteps, + sourceFile, + fmt.Sprintf("Frame %d/%d wird gespeichert…", i+1, len(seconds)), + ) + + if err := trainingWriteSample(root, sample); err != nil { + _ = os.Remove(framePath) + errs = append(errs, fmt.Sprintf("Frame bei %.1fs speichern: %v", second, err)) + continue + } + + samples = append(samples, *sample) + } + + if len(samples) == 0 { + msg := "keine Trainingsframes erzeugt" + if len(errs) > 0 { + msg += ": " + strings.Join(errs, "; ") + } + + err := errors.New(msg) + + trainingPublishAnalysisError( + requestID, + startedAtMs, + sourceFile, + "Video konnte nicht ins Training übernommen werden.", + err, + ) + + trainingWriteError(w, http.StatusInternalServerError, msg) + return + } + + trainingPublishAnalysisFinished( + requestID, + startedAtMs, + totalSteps, + sourceFile, + fmt.Sprintf("%d Frames ins Training übernommen.", len(samples)), + ) + + trainingWriteJSON(w, http.StatusOK, TrainingImportVideoResponse{ + OK: true, + Count: len(samples), + Sample: &samples[0], + Samples: samples, + Errors: errs, + }) +} + func trainingNextHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed") @@ -1040,6 +1310,8 @@ func trainingNextHandler(w http.ResponseWriter, r *http.Request) { analysisRequestID := strings.TrimSpace(r.URL.Query().Get("analysisRequestId")) + excludeIDs := trainingExcludedSampleIDs(r) + preferUncertain := strings.EqualFold(r.URL.Query().Get("mode"), "uncertain") || strings.EqualFold(r.URL.Query().Get("sampleMode"), "uncertain") @@ -1067,7 +1339,7 @@ func trainingNextHandler(w http.ResponseWriter, r *http.Request) { ) } - if sample, ok, err := trainingLatestOpenSample(root, refreshPrediction, startedAtMs, analysisRequestID); err != nil { + if sample, ok, err := trainingLatestOpenSample(root, refreshPrediction, startedAtMs, analysisRequestID, excludeIDs); err != nil { if refreshPrediction { trainingPublishAnalysisError( analysisRequestID, @@ -1146,7 +1418,13 @@ func trainingNextHandler(w http.ResponseWriter, r *http.Request) { trainingWriteJSON(w, http.StatusOK, sample) } -func trainingLatestOpenSample(root string, refreshPrediction bool, startedAtMs int64, requestID string) (*TrainingSample, bool, error) { +func trainingLatestOpenSample( + root string, + refreshPrediction bool, + startedAtMs int64, + requestID string, + excludeIDs map[string]bool, +) (*TrainingSample, bool, error) { answered, err := trainingAnsweredSampleIDs(root) if err != nil { return nil, false, err @@ -1181,7 +1459,7 @@ func trainingLatestOpenSample(root string, refreshPrediction bool, startedAtMs i } id := strings.TrimSuffix(name, filepath.Ext(name)) - if id == "" || answered[id] { + if id == "" || answered[id] || excludeIDs[id] { continue } @@ -1249,6 +1527,27 @@ func trainingLatestOpenSample(root string, refreshPrediction bool, startedAtMs i return nil, false, nil } +func trainingExcludedSampleIDs(r *http.Request) map[string]bool { + out := map[string]bool{} + + for _, raw := range r.URL.Query()["exclude"] { + for _, part := range strings.Split(raw, ",") { + id := strings.TrimSpace(part) + if id == "" { + continue + } + + if strings.Contains(id, "/") || strings.Contains(id, "\\") { + continue + } + + out[id] = true + } + } + + return out +} + func trainingAnsweredSampleIDs(root string) (map[string]bool, error) { out := map[string]bool{} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 216f459..c9879b4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3206,6 +3206,8 @@ export default function App() { return () => window.removeEventListener('app:navigate-tab', onNav as EventListener) }, []) + + // ---- Player model sync (wie bei dir) ---- useEffect(() => { if (!playerJob) { diff --git a/frontend/src/components/ui/FinishedDownloadsCardsView.tsx b/frontend/src/components/ui/FinishedDownloadsCardsView.tsx index c3386e9..c59fa64 100644 --- a/frontend/src/components/ui/FinishedDownloadsCardsView.tsx +++ b/frontend/src/components/ui/FinishedDownloadsCardsView.tsx @@ -940,7 +940,7 @@ function FinishedDownloadsCardsView({ onDelete={deleteVideo} onSplit={onSplit} onAddToDownloads={onAddToDownloads} - order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']} + order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add', 'training']} className="w-full gap-1.5" /> diff --git a/frontend/src/components/ui/FinishedDownloadsDesktopCard.tsx b/frontend/src/components/ui/FinishedDownloadsDesktopCard.tsx index 38609bd..e05f820 100644 --- a/frontend/src/components/ui/FinishedDownloadsDesktopCard.tsx +++ b/frontend/src/components/ui/FinishedDownloadsDesktopCard.tsx @@ -415,7 +415,7 @@ const FinishedDownloadsDesktopCard = memo( onDelete={deleteVideo} onSplit={onSplit} onAddToDownloads={onAddToDownloads} - order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']} + order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add', 'training']} className="w-full gap-1.5" /> diff --git a/frontend/src/components/ui/FinishedDownloadsGalleryCard.tsx b/frontend/src/components/ui/FinishedDownloadsGalleryCard.tsx index de40014..c21f484 100644 --- a/frontend/src/components/ui/FinishedDownloadsGalleryCard.tsx +++ b/frontend/src/components/ui/FinishedDownloadsGalleryCard.tsx @@ -589,7 +589,7 @@ function FinishedDownloadsGalleryCardInner({ onDelete={deleteVideo} onSplit={onSplit} onAddToDownloads={onAddToDownloads} - order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']} + order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add', 'training']} className="w-full gap-1.5" /> diff --git a/frontend/src/components/ui/FinishedDownloadsTableView.tsx b/frontend/src/components/ui/FinishedDownloadsTableView.tsx index 77011cf..3b67a75 100644 --- a/frontend/src/components/ui/FinishedDownloadsTableView.tsx +++ b/frontend/src/components/ui/FinishedDownloadsTableView.tsx @@ -564,7 +564,7 @@ export default function FinishedDownloadsTableView({ onDelete={deleteVideo} onSplit={onSplit} onAddToDownloads={onAddToDownloads} - order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']} + order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add', 'training']} className="flex items-center justify-end gap-1" /> ) diff --git a/frontend/src/components/ui/Player.tsx b/frontend/src/components/ui/Player.tsx index db9abc8..416426c 100644 --- a/frontend/src/components/ui/Player.tsx +++ b/frontend/src/components/ui/Player.tsx @@ -1965,7 +1965,7 @@ export default function Player({ } : undefined } - order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'details']} + order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'details', 'training']} className="gap-1 min-w-0 flex-1" /> )} @@ -1996,7 +1996,11 @@ export default function Player({ }} >
{isLive ? ( @@ -2278,7 +2282,7 @@ export default function Player({ } : undefined } - order={isLive ? ['watch', 'favorite', 'like', 'details'] : ['watch', 'favorite', 'like', 'hot', 'details', 'keep', 'delete']} + order={isLive ? ['watch', 'favorite', 'like', 'details'] : ['watch', 'favorite', 'like', 'hot', 'details', 'keep', 'delete', 'training']} className="flex items-center justify-start gap-1" />
@@ -2803,6 +2807,37 @@ export default function Player({ position: absolute !important; inset: 0 !important; } + + /* Maximierter Finished-Player: CurrentTime / EndTime in der Video.js-Controlbar anzeigen */ + .vjs-expanded-player .video-js .vjs-current-time, + .vjs-expanded-player .video-js .vjs-time-divider, + .vjs-expanded-player .video-js .vjs-duration { + display: flex !important; + align-items: center !important; + flex: 0 0 auto !important; + } + + .vjs-expanded-player .video-js .vjs-time-control { + min-width: auto !important; + padding-left: 0.35em !important; + padding-right: 0.35em !important; + font-variant-numeric: tabular-nums; + } + + .vjs-expanded-player .video-js .vjs-current-time-display, + .vjs-expanded-player .video-js .vjs-duration-display { + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + + .vjs-expanded-player .video-js .vjs-time-divider { + padding-left: 0.15em !important; + padding-right: 0.15em !important; + } + + .vjs-expanded-player .video-js .vjs-progress-control { + min-width: 120px; + } `} {expanded || miniDesktop ? ( diff --git a/frontend/src/components/ui/RecordJobActions.tsx b/frontend/src/components/ui/RecordJobActions.tsx index dfbbfbf..adc0123 100644 --- a/frontend/src/components/ui/RecordJobActions.tsx +++ b/frontend/src/components/ui/RecordJobActions.tsx @@ -24,6 +24,7 @@ import { EyeIcon as EyeOutlineIcon, ArrowDownTrayIcon, ScissorsIcon, + AcademicCapIcon, } from '@heroicons/react/24/outline' import { FireIcon as FireSolidIcon, @@ -40,6 +41,7 @@ type Variant = 'overlay' | 'table' type ActionKey = | 'details' | 'add' + | 'training' | 'split' | 'hot' | 'favorite' @@ -73,6 +75,7 @@ type Props = { onToggleWatch?: ActionFn onAddToDownloads?: ActionFn onSplit?: ActionFn + onAddToTraining?: ActionFn order?: ActionKey[] @@ -123,6 +126,7 @@ export default function RecordJobActions({ onToggleWatch, onAddToDownloads, onSplit, + onAddToTraining, order, className, }: Props) { @@ -165,7 +169,7 @@ export default function RecordJobActions({ // ✅ Reihenfolge strikt nach `order` (wenn gesetzt). Keys die nicht im order stehen: niemals anzeigen. - const actionOrder: ActionKey[] = order ?? ['watch', 'favorite', 'like', 'split', 'hot', 'keep', 'delete', 'details'] + const actionOrder: ActionKey[] = order ?? ['watch', 'favorite', 'like', 'training', 'split', 'hot', 'keep', 'delete', 'details'] const inOrder = (k: ActionKey) => actionOrder.includes(k) const addUrl = String((job as any)?.sourceUrl ?? '').trim() @@ -180,6 +184,7 @@ export default function RecordJobActions({ const wantKeep = inOrder('keep') const wantDelete = inOrder('delete') const wantSplit = inOrder('split') && Boolean(baseName(job.output || '')) + const wantTraining = inOrder('training') && Boolean(baseName(job.output || '')) // Details: wenn du ihn auch ohne detailsKey zeigen willst, nimm nur inOrder('details'). // (Aktuell macht Details ohne detailsKey wenig Sinn, daher: nur anzeigen wenn key existiert.) @@ -234,6 +239,58 @@ export default function RecordJobActions({ } } + const doTrainingImport = async (): Promise => { + if (busy) return false + + const output = String(job.output || '').trim() + if (!output) return false + + try { + if (onAddToTraining) { + const r = await onAddToTraining(job) + return r !== false + } + + const detail = { + jobId: String((job as any)?.id ?? ''), + output, + sourceFile: baseName(output), + count: 8, + } + + // Falls TrainingTab gerade nicht gemountet ist. + try { + window.sessionStorage.setItem( + 'training:pending-import-video', + JSON.stringify(detail) + ) + } catch { + // ignore + } + + // App kann darauf reagieren und zum Training-Tab wechseln. + window.dispatchEvent( + new CustomEvent('app:navigate-tab', { + detail: { tab: 'training' }, + }) + ) + + // Einen Frame warten, damit der TrainingTab nach dem Tab-Wechsel sicher gemountet/aktiv ist. + // sessionStorage bleibt trotzdem der Fallback. + window.requestAnimationFrame(() => { + window.dispatchEvent( + new CustomEvent('training:import-video', { + detail, + }) + ) + }) + + return true + } catch { + return false + } + } + // ✅ Auto-Fit: verfügbare Breite + tatsächlicher gap (Tailwind gap-1/gap-2/…) const [rootW, setRootW] = useState(0) const [gapPx, setGapPx] = useState(4) @@ -291,6 +348,26 @@ export default function RecordJobActions({ ) : null + const TrainingBtn = wantTraining ? ( + + ) : null + const FavoriteBtn = wantFavorite ? ( + ) + } + if (k === 'split') { return (