nsfwapp/backend/analyze_videomae.go
2026-06-22 15:22:29 +02:00

272 lines
6.4 KiB
Go

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,
) ([]analyzeVideoMAEClipPrediction, error) {
if len(clips) == 0 {
return []analyzeVideoMAEClipPrediction{}, nil
}
if !trainingRecognitionEnabled() {
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 {
return out, nil
}
out = append(out, parsed.Predictions...)
}
return out, nil
}
func applyVideoMAEPositionClipsForAnalyze(
ctx context.Context,
samples []videoFrameSample,
duration float64,
highlightHits []analyzeHit,
positionEvidence []analyzePositionEvidence,
) ([]analyzeHit, []analyzePositionEvidence) {
if !analyzeVideoMAEEnabled() {
return highlightHits, positionEvidence
}
clips := buildAnalyzeVideoMAEClips(samples, duration)
if len(clips) == 0 {
return highlightHits, positionEvidence
}
predictions, err := predictVideoMAEPositionClipsForAnalyze(ctx, clips)
if err != nil {
if ctx.Err() == nil {
appLogln("VideoMAE Clip-Analyse uebersprungen:", err)
}
return highlightHits, positionEvidence
}
for _, pred := range predictions {
label := strings.ToLower(strings.TrimSpace(pred.SexPosition))
if isNoSexPositionLabel(label) || !isKnownPositionLabel(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"
}
highlightHits = append(highlightHits, analyzeHit{
Time: pred.Time,
Label: "position:" + label,
Score: score,
Start: start,
End: end,
})
positionEvidence = append(positionEvidence, analyzePositionEvidence{
Time: pred.Time,
Label: label,
Score: score,
Source: source,
HasClip: true,
})
}
return highlightHits, positionEvidence
}