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 = 48 ) 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 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 nil, err } req, err := http.NewRequestWithContext( ctx, http.MethodPost, analyzeAIServerURL()+"/predict-position-clips", bytes.NewReader(body), ) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") addAIServerAuth(req) client := &http.Client{ Timeout: 180 * time.Second, } res, err := client.Do(req) if err != nil { if ctxErr := ctx.Err(); ctxErr != nil { return nil, ctxErr } return nil, err } rawBody, readErr := io.ReadAll(res.Body) _ = res.Body.Close() if readErr != nil { if ctxErr := ctx.Err(); ctxErr != nil { return nil, ctxErr } return nil, readErr } var parsed analyzeVideoMAEClipPredictResp if err := json.Unmarshal(rawBody, &parsed); err != nil { if ctxErr := ctx.Err(); ctxErr != nil { return nil, ctxErr } return nil, fmt.Errorf("AI server VideoMAE JSON ungueltig: HTTP %d: %s", res.StatusCode, strings.TrimSpace(string(rawBody))) } if res.StatusCode < 200 || res.StatusCode >= 300 || !parsed.OK { msg := strings.TrimSpace(parsed.Error) if msg == "" { msg = fmt.Sprintf("AI server VideoMAE HTTP %d", res.StatusCode) } return nil, fmt.Errorf("%s", msg) } 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, 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 onProgress != nil { onProgress(0, len(clips)) } predictions, err := predictVideoMAEPositionClipsForAnalyze(ctx, clips, onProgress) if err != nil { if ctx.Err() == nil { if onProgress != nil { onProgress(len(clips), len(clips)) } appLogln("VideoMAE Clip-Analyse übersprungen:", err) } return highlightHits, positionEvidence } 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 }