nsfwapp/backend/analyze_videomae.go
2026-07-15 13:53:16 +02:00

342 lines
8.2 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 = 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
}