updated
This commit is contained in:
parent
141f8714c3
commit
e29893b0cb
@ -96,6 +96,7 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
|
|||||||
api.HandleFunc("/api/training/stats", trainingStatsHandler)
|
api.HandleFunc("/api/training/stats", trainingStatsHandler)
|
||||||
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/chaturbate/online", chaturbateOnlineHandler)
|
api.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler)
|
||||||
api.HandleFunc("/api/chaturbate/biocontext", chaturbateBioContextHandler)
|
api.HandleFunc("/api/chaturbate/biocontext", chaturbateBioContextHandler)
|
||||||
|
|||||||
@ -1029,6 +1029,276 @@ func trainingLabelsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
trainingWriteJSON(w, http.StatusOK, defaultTrainingLabelsFromJSON())
|
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) {
|
func trainingNextHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
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"))
|
analysisRequestID := strings.TrimSpace(r.URL.Query().Get("analysisRequestId"))
|
||||||
|
|
||||||
|
excludeIDs := trainingExcludedSampleIDs(r)
|
||||||
|
|
||||||
preferUncertain := strings.EqualFold(r.URL.Query().Get("mode"), "uncertain") ||
|
preferUncertain := strings.EqualFold(r.URL.Query().Get("mode"), "uncertain") ||
|
||||||
strings.EqualFold(r.URL.Query().Get("sampleMode"), "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 {
|
if refreshPrediction {
|
||||||
trainingPublishAnalysisError(
|
trainingPublishAnalysisError(
|
||||||
analysisRequestID,
|
analysisRequestID,
|
||||||
@ -1146,7 +1418,13 @@ func trainingNextHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
trainingWriteJSON(w, http.StatusOK, sample)
|
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)
|
answered, err := trainingAnsweredSampleIDs(root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
@ -1181,7 +1459,7 @@ func trainingLatestOpenSample(root string, refreshPrediction bool, startedAtMs i
|
|||||||
}
|
}
|
||||||
|
|
||||||
id := strings.TrimSuffix(name, filepath.Ext(name))
|
id := strings.TrimSuffix(name, filepath.Ext(name))
|
||||||
if id == "" || answered[id] {
|
if id == "" || answered[id] || excludeIDs[id] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1249,6 +1527,27 @@ func trainingLatestOpenSample(root string, refreshPrediction bool, startedAtMs i
|
|||||||
return nil, false, nil
|
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) {
|
func trainingAnsweredSampleIDs(root string) (map[string]bool, error) {
|
||||||
out := map[string]bool{}
|
out := map[string]bool{}
|
||||||
|
|
||||||
|
|||||||
@ -3206,6 +3206,8 @@ export default function App() {
|
|||||||
return () => window.removeEventListener('app:navigate-tab', onNav as EventListener)
|
return () => window.removeEventListener('app:navigate-tab', onNav as EventListener)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ---- Player model sync (wie bei dir) ----
|
// ---- Player model sync (wie bei dir) ----
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!playerJob) {
|
if (!playerJob) {
|
||||||
|
|||||||
@ -940,7 +940,7 @@ function FinishedDownloadsCardsView({
|
|||||||
onDelete={deleteVideo}
|
onDelete={deleteVideo}
|
||||||
onSplit={onSplit}
|
onSplit={onSplit}
|
||||||
onAddToDownloads={onAddToDownloads}
|
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"
|
className="w-full gap-1.5"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -415,7 +415,7 @@ const FinishedDownloadsDesktopCard = memo(
|
|||||||
onDelete={deleteVideo}
|
onDelete={deleteVideo}
|
||||||
onSplit={onSplit}
|
onSplit={onSplit}
|
||||||
onAddToDownloads={onAddToDownloads}
|
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"
|
className="w-full gap-1.5"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -589,7 +589,7 @@ function FinishedDownloadsGalleryCardInner({
|
|||||||
onDelete={deleteVideo}
|
onDelete={deleteVideo}
|
||||||
onSplit={onSplit}
|
onSplit={onSplit}
|
||||||
onAddToDownloads={onAddToDownloads}
|
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"
|
className="w-full gap-1.5"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -564,7 +564,7 @@ export default function FinishedDownloadsTableView({
|
|||||||
onDelete={deleteVideo}
|
onDelete={deleteVideo}
|
||||||
onSplit={onSplit}
|
onSplit={onSplit}
|
||||||
onAddToDownloads={onAddToDownloads}
|
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"
|
className="flex items-center justify-end gap-1"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1965,7 +1965,7 @@ export default function Player({
|
|||||||
}
|
}
|
||||||
: undefined
|
: 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"
|
className="gap-1 min-w-0 flex-1"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -1996,7 +1996,11 @@ export default function Player({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn('relative w-full h-full player-video-frame', miniDesktop && 'vjs-mini')}
|
className={cn(
|
||||||
|
'relative w-full h-full player-video-frame',
|
||||||
|
miniDesktop && 'vjs-mini',
|
||||||
|
expanded && !isLive && 'vjs-expanded-player'
|
||||||
|
)}
|
||||||
style={{ ['--vjs-controlbar-h' as any]: `${controlBarH}px` }}
|
style={{ ['--vjs-controlbar-h' as any]: `${controlBarH}px` }}
|
||||||
>
|
>
|
||||||
{isLive ? (
|
{isLive ? (
|
||||||
@ -2278,7 +2282,7 @@ export default function Player({
|
|||||||
}
|
}
|
||||||
: undefined
|
: 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"
|
className="flex items-center justify-start gap-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -2803,6 +2807,37 @@ export default function Player({
|
|||||||
position: absolute !important;
|
position: absolute !important;
|
||||||
inset: 0 !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;
|
||||||
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
|
|
||||||
{expanded || miniDesktop ? (
|
{expanded || miniDesktop ? (
|
||||||
|
|||||||
@ -24,6 +24,7 @@ import {
|
|||||||
EyeIcon as EyeOutlineIcon,
|
EyeIcon as EyeOutlineIcon,
|
||||||
ArrowDownTrayIcon,
|
ArrowDownTrayIcon,
|
||||||
ScissorsIcon,
|
ScissorsIcon,
|
||||||
|
AcademicCapIcon,
|
||||||
} from '@heroicons/react/24/outline'
|
} from '@heroicons/react/24/outline'
|
||||||
import {
|
import {
|
||||||
FireIcon as FireSolidIcon,
|
FireIcon as FireSolidIcon,
|
||||||
@ -40,6 +41,7 @@ type Variant = 'overlay' | 'table'
|
|||||||
type ActionKey =
|
type ActionKey =
|
||||||
| 'details'
|
| 'details'
|
||||||
| 'add'
|
| 'add'
|
||||||
|
| 'training'
|
||||||
| 'split'
|
| 'split'
|
||||||
| 'hot'
|
| 'hot'
|
||||||
| 'favorite'
|
| 'favorite'
|
||||||
@ -73,6 +75,7 @@ type Props = {
|
|||||||
onToggleWatch?: ActionFn
|
onToggleWatch?: ActionFn
|
||||||
onAddToDownloads?: ActionFn
|
onAddToDownloads?: ActionFn
|
||||||
onSplit?: ActionFn
|
onSplit?: ActionFn
|
||||||
|
onAddToTraining?: ActionFn
|
||||||
|
|
||||||
order?: ActionKey[]
|
order?: ActionKey[]
|
||||||
|
|
||||||
@ -123,6 +126,7 @@ export default function RecordJobActions({
|
|||||||
onToggleWatch,
|
onToggleWatch,
|
||||||
onAddToDownloads,
|
onAddToDownloads,
|
||||||
onSplit,
|
onSplit,
|
||||||
|
onAddToTraining,
|
||||||
order,
|
order,
|
||||||
className,
|
className,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
@ -165,7 +169,7 @@ export default function RecordJobActions({
|
|||||||
|
|
||||||
|
|
||||||
// ✅ Reihenfolge strikt nach `order` (wenn gesetzt). Keys die nicht im order stehen: niemals anzeigen.
|
// ✅ 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 inOrder = (k: ActionKey) => actionOrder.includes(k)
|
||||||
|
|
||||||
const addUrl = String((job as any)?.sourceUrl ?? '').trim()
|
const addUrl = String((job as any)?.sourceUrl ?? '').trim()
|
||||||
@ -180,6 +184,7 @@ export default function RecordJobActions({
|
|||||||
const wantKeep = inOrder('keep')
|
const wantKeep = inOrder('keep')
|
||||||
const wantDelete = inOrder('delete')
|
const wantDelete = inOrder('delete')
|
||||||
const wantSplit = inOrder('split') && Boolean(baseName(job.output || ''))
|
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').
|
// 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.)
|
// (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<boolean> => {
|
||||||
|
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/…)
|
// ✅ Auto-Fit: verfügbare Breite + tatsächlicher gap (Tailwind gap-1/gap-2/…)
|
||||||
const [rootW, setRootW] = useState(0)
|
const [rootW, setRootW] = useState(0)
|
||||||
const [gapPx, setGapPx] = useState(4)
|
const [gapPx, setGapPx] = useState(4)
|
||||||
@ -291,6 +348,26 @@ export default function RecordJobActions({
|
|||||||
</button>
|
</button>
|
||||||
) : null
|
) : null
|
||||||
|
|
||||||
|
const TrainingBtn = wantTraining ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={btnBase}
|
||||||
|
title="Video ins Training übernehmen"
|
||||||
|
aria-label="Video ins Training übernehmen"
|
||||||
|
disabled={busy || (!onAddToTraining && !job.output)}
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
await doTrainingImport()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className={cn('inline-flex items-center justify-center', iconBox)}>
|
||||||
|
<AcademicCapIcon className={cn(iconFill, colors.off)} />
|
||||||
|
</span>
|
||||||
|
<span className="sr-only">Ins Training übernehmen</span>
|
||||||
|
</button>
|
||||||
|
) : null
|
||||||
|
|
||||||
const FavoriteBtn = wantFavorite ? (
|
const FavoriteBtn = wantFavorite ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -464,6 +541,7 @@ export default function RecordJobActions({
|
|||||||
const byKey: Record<ActionKey, ReactNode> = {
|
const byKey: Record<ActionKey, ReactNode> = {
|
||||||
details: DetailsBtn,
|
details: DetailsBtn,
|
||||||
add: AddBtn,
|
add: AddBtn,
|
||||||
|
training: TrainingBtn,
|
||||||
split: SplitBtn,
|
split: SplitBtn,
|
||||||
favorite: FavoriteBtn,
|
favorite: FavoriteBtn,
|
||||||
like: LikeBtn,
|
like: LikeBtn,
|
||||||
@ -676,6 +754,26 @@ export default function RecordJobActions({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (k === 'training') {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key="training"
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-gray-100/70 dark:hover:bg-white/5 disabled:opacity-50"
|
||||||
|
disabled={busy || (!onAddToTraining && !job.output)}
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setMenuOpen(false)
|
||||||
|
await doTrainingImport()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AcademicCapIcon className={cn('size-4', colors.off)} />
|
||||||
|
<span className="truncate">Ins Training übernehmen</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (k === 'split') {
|
if (k === 'split') {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user