package main import ( "bytes" "context" "encoding/json" "fmt" "io" "math" "net/http" "os" "strings" "time" ) const ( analyzeVideoMAEClipWindowSeconds = 4.0 analyzeVideoMAEClipStrideSeconds = 2.0 analyzeVideoMAEMinScore = 0.34 analyzeVideoMAERequestBatchSize = 8 analyzeVideoMAEMaxClips = 96 analyzeVideoMAERequestTimeout = 45 * time.Second analyzeVideoMAETotalTimeout = 2 * time.Minute ) type analyzeVideoMAEClipReqItem struct { Time float64 `json:"time"` Start float64 `json:"start"` End float64 `json:"end"` Paths []string `json:"paths"` } type analyzeVideoMAEClipPredictReq struct { Clips []analyzeVideoMAEClipReqItem `json:"clips"` NumFrames int `json:"numFrames,omitempty"` } type analyzeVideoMAEClipPrediction struct { Time float64 `json:"time"` Start float64 `json:"start"` End float64 `json:"end"` SexPosition string `json:"sexPosition"` SexPositionScore float64 `json:"sexPositionScore"` Source string `json:"source,omitempty"` Scores []TrainingScoredLabel `json:"scores,omitempty"` } type analyzeVideoMAEClipPredictResp struct { OK bool `json:"ok"` Available bool `json:"available"` Predictions []analyzeVideoMAEClipPrediction `json:"predictions"` Error string `json:"error,omitempty"` } func analyzeVideoMAEEnabled() bool { raw := strings.ToLower(strings.TrimSpace(os.Getenv("VIDEOMAE_ANALYZE_ENABLED"))) return raw == "" || raw == "1" || raw == "true" || raw == "yes" || raw == "on" } func buildAnalyzeVideoMAEClips( samples []videoFrameSample, duration float64, ) []analyzeVideoMAEClipReqItem { if len(samples) == 0 || duration <= 0 { return []analyzeVideoMAEClipReqItem{} } clips := []analyzeVideoMAEClipReqItem{} halfWindow := analyzeVideoMAEClipWindowSeconds / 2 if halfWindow <= 0 { halfWindow = 2 } lastCenter := -math.MaxFloat64 for _, sample := range samples { center := math.Max(0, sample.Time) if len(clips) > 0 && center-lastCenter < analyzeVideoMAEClipStrideSeconds-0.001 { continue } start := math.Max(0, center-halfWindow) end := center + halfWindow if duration > 0 { end = math.Min(duration, end) } if end <= start { end = math.Min(duration, start+math.Max(1, float64(analyzeVideoFrameIntervalSeconds))) } paths := []string{} for _, candidate := range samples { if candidate.Time < start-0.001 || candidate.Time > end+0.001 { continue } path := strings.TrimSpace(candidate.Path) if path != "" { paths = append(paths, path) } } if len(paths) == 0 { continue } clips = append(clips, analyzeVideoMAEClipReqItem{ Time: center, Start: start, End: end, Paths: paths, }) lastCenter = center } return clips } func limitAnalyzeVideoMAEClips(clips []analyzeVideoMAEClipReqItem, maxClips int) []analyzeVideoMAEClipReqItem { if maxClips <= 0 || len(clips) <= maxClips { return clips } if maxClips == 1 { return clips[:1] } out := make([]analyzeVideoMAEClipReqItem, 0, maxClips) lastIdx := -1 for i := 0; i < maxClips; i++ { idx := int(math.Round(float64(i) * float64(len(clips)-1) / float64(maxClips-1))) if idx <= lastIdx { idx = lastIdx + 1 } if idx >= len(clips) { idx = len(clips) - 1 } out = append(out, clips[idx]) lastIdx = idx } return out } func predictVideoMAEPositionClipsForAnalyze( ctx context.Context, clips []analyzeVideoMAEClipReqItem, onProgress func(current int, total int), ) ([]analyzeVideoMAEClipPrediction, error) { if len(clips) == 0 { return []analyzeVideoMAEClipPrediction{}, nil } if !trainingRecognitionEnabled() { if onProgress != nil { onProgress(len(clips), len(clips)) } return []analyzeVideoMAEClipPrediction{}, nil } out := []analyzeVideoMAEClipPrediction{} for start := 0; start < len(clips); start += analyzeVideoMAERequestBatchSize { end := start + analyzeVideoMAERequestBatchSize if end > len(clips) { end = len(clips) } payload := analyzeVideoMAEClipPredictReq{ Clips: clips[start:end], NumFrames: trainingVideoMAENumFrames, } body, err := json.Marshal(payload) if err != nil { return out, err } parsed, err := func() (analyzeVideoMAEClipPredictResp, error) { var parsed analyzeVideoMAEClipPredictResp reqCtx, cancel := context.WithTimeout(ctx, analyzeVideoMAERequestTimeout) defer cancel() req, err := http.NewRequestWithContext( reqCtx, http.MethodPost, analyzeAIServerURL()+"/predict-position-clips", bytes.NewReader(body), ) if err != nil { return parsed, err } req.Header.Set("Content-Type", "application/json") addAIServerAuth(req) client := &http.Client{ Timeout: analyzeVideoMAERequestTimeout + 5*time.Second, } res, err := client.Do(req) if err != nil { if ctxErr := ctx.Err(); ctxErr != nil { return parsed, ctxErr } return parsed, err } rawBody, readErr := io.ReadAll(res.Body) statusCode := res.StatusCode _ = res.Body.Close() if readErr != nil { if ctxErr := ctx.Err(); ctxErr != nil { return parsed, ctxErr } return parsed, readErr } if err := json.Unmarshal(rawBody, &parsed); err != nil { if ctxErr := ctx.Err(); ctxErr != nil { return parsed, ctxErr } return parsed, fmt.Errorf("AI server VideoMAE JSON ungueltig: HTTP %d: %s", statusCode, strings.TrimSpace(string(rawBody))) } if statusCode < 200 || statusCode >= 300 || !parsed.OK { msg := strings.TrimSpace(parsed.Error) if msg == "" { msg = fmt.Sprintf("AI server VideoMAE HTTP %d", statusCode) } return parsed, fmt.Errorf("%s", msg) } return parsed, nil }() if err != nil { return out, err } if !parsed.Available { if onProgress != nil { onProgress(len(clips), len(clips)) } return out, nil } out = append(out, parsed.Predictions...) if onProgress != nil { onProgress(end, len(clips)) } } return out, nil } func applyVideoMAEPositionClipsForAnalyze( ctx context.Context, file string, samples []videoFrameSample, duration float64, highlightHits []analyzeHit, positionEvidence []analyzePositionEvidence, onProgress func(current int, total int), ) ([]analyzeHit, []analyzePositionEvidence) { if !analyzeVideoMAEEnabled() { return highlightHits, positionEvidence } clips := buildAnalyzeVideoMAEClips(samples, duration) if len(clips) == 0 { return highlightHits, positionEvidence } if len(clips) > analyzeVideoMAEMaxClips { appLogf( "VideoMAE Clip-Analyse begrenzt file=%q clips=%d max=%d", strings.TrimSpace(file), len(clips), analyzeVideoMAEMaxClips, ) clips = limitAnalyzeVideoMAEClips(clips, analyzeVideoMAEMaxClips) } if onProgress != nil { onProgress(0, len(clips)) } videoCtx, cancel := context.WithTimeout(ctx, analyzeVideoMAETotalTimeout) defer cancel() predictions, err := predictVideoMAEPositionClipsForAnalyze(videoCtx, clips, onProgress) if err != nil { if ctx.Err() == nil { appLogf( "VideoMAE Clip-Analyse begrenzt/übersprungen file=%q predictions=%d err=%v", strings.TrimSpace(file), len(predictions), err, ) if onProgress != nil { onProgress(len(clips), len(clips)) } appLogln("VideoMAE Clip-Analyse übersprungen:", err) } } for _, pred := range predictions { label := strings.ToLower(strings.TrimSpace(pred.SexPosition)) if !isAnalyzeTimelinePositionLabel(label) { continue } score := clamp01(pred.SexPositionScore) if score < analyzeVideoMAEMinScore { continue } start := math.Max(0, pred.Start) end := pred.End if duration > 0 { end = math.Min(duration, end) } if end <= start { end = math.Min(duration, start+math.Max(1, float64(analyzeVideoFrameIntervalSeconds))) } source := strings.ToLower(strings.TrimSpace(pred.Source)) if source == "" { source = "videomae" } positionEvidence = append(positionEvidence, analyzePositionEvidence{ Time: pred.Time, Start: start, End: end, Label: label, Score: score, Source: source, HasClip: true, }) } return highlightHits, positionEvidence }