nsfwapp/backend/analyze.go
2026-05-06 21:05:08 +02:00

2809 lines
59 KiB
Go

// backend\analyze.go
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"image"
"image/jpeg"
"math"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
"syscall"
"time"
)
type analyzeVideoReq struct {
JobID string `json:"jobId"`
Output string `json:"output"`
Mode string `json:"mode"` // "video" | "sprite"
Goal string `json:"goal"` // "highlights" | "nsfw"
}
type analyzeHit struct {
Time float64 `json:"time"`
Label string `json:"label"`
Score float64 `json:"score,omitempty"`
Start float64 `json:"start,omitempty"`
End float64 `json:"end,omitempty"`
}
type analyzeVideoResp struct {
OK bool `json:"ok"`
Mode string `json:"mode,omitempty"`
Goal string `json:"goal,omitempty"`
Hits []analyzeHit `json:"hits"`
Segments []aiSegmentMeta `json:"segments,omitempty"`
Rating *aiRatingMeta `json:"rating,omitempty"`
Error string `json:"error,omitempty"`
}
type videoFrameSample struct {
Index int
Time float64
Path string
}
const (
analyzeSegmentMergeGapSeconds = 8.0
// Ein Label darf kurz verschwinden, ohne dass ein neues Segment entsteht.
// Bei 3s Frame-Intervall heißt das: ein fehlender Frame wird überbrückt.
analyzeLabelInvisibleGraceSeconds = 3.0
nsfwThresholdModerate = 0.35
nsfwThresholdStrong = 0.60
// Video-Modus: extrahiert 1 Frame alle N Sekunden.
// 1 = jeder Sekunde, 3 = alle 3 Sekunden, 5 = alle 5 Sekunden.
analyzeVideoFrameIntervalSeconds = 3
// AI-Server nicht mit tausenden Pfaden auf einmal fluten.
analyzeFramePredictBatchSize = 32
// 640 ist für YOLO meist deutlich schneller als 960/1280.
analyzeVideoFrameWidth = 640
// Lokaler optionaler Python-Inference-Server.
// Kann per Environment überschrieben werden:
// AI_SERVER_URL=http://127.0.0.1:8765
analyzeAIServerDefaultURL = "http://127.0.0.1:8765"
)
func autoSelectedAILabelSet() map[string]struct{} {
grouped, err := trainingGroupedLabels()
if err != nil {
appLogln("⚠️ analyze labels fallback:", err)
return map[string]struct{}{}
}
out := map[string]struct{}{}
add := func(values []string) {
for _, value := range values {
label := strings.ToLower(strings.TrimSpace(value))
if label == "" || label == "unknown" {
continue
}
out[label] = struct{}{}
}
}
add(grouped.BodyParts)
add(grouped.Objects)
add(grouped.Clothing)
add(grouped.SexPositions)
return out
}
var autoSelectedAILabelsOnce sync.Once
var autoSelectedAILabelsCache map[string]struct{}
func shouldAutoSelectAnalyzeHit(label string) bool {
label = strings.ToLower(strings.TrimSpace(label))
if label == "" || label == "unknown" {
return false
}
labels := autoSelectedAILabelSet()
_, ok := labels[label]
return ok
}
var nsfwIgnoredLabels = map[string]struct{}{
// Personen sollen nicht als interessante Segmente auftauchen.
"person_male": {},
"person_female": {},
}
func isIgnoredNSFWLabel(label string) bool {
label = strings.ToLower(strings.TrimSpace(label))
_, ok := nsfwIgnoredLabels[label]
return ok
}
func addTrainingAnalyzeResult(best map[string]float64, label string, score float64) {
label = strings.ToLower(strings.TrimSpace(label))
if label == "" {
return
}
if score <= 0 {
score = 1
}
if old, ok := best[label]; !ok || score > old {
best[label] = score
}
}
func trainingPredictionToNSFWResults(pred TrainingPrediction) []NsfwFrameResult {
best := map[string]float64{}
// Für NSFW/AI-Segmente nur echte Boxen verwenden.
// BodyPartsPresent/ObjectsPresent/ClothingPresent sind daraus abgeleitete Übersichten
// und können sonst Labels doppelt oder zu breit einbringen.
for _, box := range pred.Boxes {
label := strings.ToLower(strings.TrimSpace(box.Label))
if label == "" {
continue
}
if isIgnoredNSFWLabel(label) {
continue
}
// Nur Labels zulassen, die für Analyse/Rating relevant sind.
// Dadurch erzeugen neue YOLO-Klassen nur dann Segmente,
// wenn du sie bewusst in autoSelectedAILabels einträgst.
if !shouldAutoSelectAnalyzeHit(label) {
continue
}
score := box.Score
if score <= 0 {
score = 1
}
if old, ok := best[label]; !ok || score > old {
best[label] = score
}
}
out := make([]NsfwFrameResult, 0, len(best))
for label, score := range best {
out = append(out, NsfwFrameResult{
Label: label,
Score: score,
})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Score == out[j].Score {
return out[i].Label < out[j].Label
}
return out[i].Score > out[j].Score
})
return out
}
func addHighlightResult(best map[string]float64, label string, score float64) {
label = strings.ToLower(strings.TrimSpace(label))
if label == "" || label == "unknown" {
return
}
if score <= 0 {
score = 1
}
if old, ok := best[label]; !ok || score > old {
best[label] = score
}
}
func addScoredHighlightLabels(best map[string]float64, prefix string, items []TrainingScoredLabel) {
prefix = strings.ToLower(strings.TrimSpace(prefix))
if prefix == "" {
return
}
for _, item := range items {
label := strings.ToLower(strings.TrimSpace(item.Label))
if label == "" || label == "unknown" {
continue
}
addHighlightResult(best, prefix+":"+label, item.Score)
}
}
func trainingPredictionToHighlightResults(pred TrainingPrediction) []NsfwFrameResult {
best := map[string]float64{}
sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition))
if sexPosition != "" && sexPosition != "unknown" {
addHighlightResult(best, "position:"+sexPosition, pred.SexPositionScore)
}
addScoredHighlightLabels(best, "body", pred.BodyPartsPresent)
addScoredHighlightLabels(best, "object", pred.ObjectsPresent)
addScoredHighlightLabels(best, "clothing", pred.ClothingPresent)
for _, box := range pred.Boxes {
label := strings.ToLower(strings.TrimSpace(box.Label))
if label == "" || label == "unknown" {
continue
}
if isIgnoredNSFWLabel(label) {
continue
}
// Wichtig:
// Keine beliebigen YOLO-/COCO-Labels als Highlights übernehmen.
// Nur bewusst erlaubte Analyse-Labels anzeigen.
if !shouldAutoSelectAnalyzeHit(label) {
continue
}
addHighlightResult(best, "detector:"+label, box.Score)
}
// Kombis nur erzeugen, wenn wirklich Position + Zusatz vorhanden ist.
if sexPosition != "" && sexPosition != "unknown" {
positionScore := pred.SexPositionScore
if positionScore <= 0 {
positionScore = 1
}
addCombo := func(prefix string, items []TrainingScoredLabel) {
for _, item := range items {
label := strings.ToLower(strings.TrimSpace(item.Label))
if label == "" || label == "unknown" {
continue
}
score := item.Score
if score <= 0 {
score = 1
}
comboScore := math.Min(positionScore, score)
addHighlightResult(best, "combo:position:"+sexPosition+"+"+prefix+":"+label, comboScore)
}
}
addCombo("body", pred.BodyPartsPresent)
addCombo("object", pred.ObjectsPresent)
addCombo("clothing", pred.ClothingPresent)
}
out := make([]NsfwFrameResult, 0, len(best))
for label, score := range best {
out = append(out, NsfwFrameResult{
Label: label,
Score: score,
})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Score == out[j].Score {
return out[i].Label < out[j].Label
}
return out[i].Score > out[j].Score
})
return out
}
func pickHighlightResults(results []NsfwFrameResult) []NsfwFrameResult {
out := make([]NsfwFrameResult, 0, len(results))
for _, r := range results {
label := strings.ToLower(strings.TrimSpace(r.Label))
if label == "" || label == "unknown" {
continue
}
score := r.Score
if score <= 0 {
score = 1
}
// Schwellen kannst du später pro Gruppe anders machen.
switch {
case strings.HasPrefix(label, "combo:"):
if score < 0.35 {
continue
}
case strings.HasPrefix(label, "position:"):
if score < 0.30 {
continue
}
case strings.HasPrefix(label, "object:"):
if score < 0.30 {
continue
}
case strings.HasPrefix(label, "clothing:"):
if score < 0.30 {
continue
}
case strings.HasPrefix(label, "body:"):
if score < 0.30 {
continue
}
case strings.HasPrefix(label, "detector:"):
raw := strings.TrimPrefix(label, "detector:")
if !shouldAutoSelectAnalyzeHit(raw) {
continue
}
if score < 0.40 {
continue
}
}
out = append(out, NsfwFrameResult{
Label: label,
Score: score,
})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Score == out[j].Score {
return out[i].Label < out[j].Label
}
return out[i].Score > out[j].Score
})
return out
}
func classifyFrameForAnalyze(ctx context.Context, img image.Image) (*NsfwImageResponse, error) {
_ = ctx
if !trainingRecognitionEnabled() {
return &NsfwImageResponse{
Ok: true,
Results: []NsfwFrameResult{},
}, nil
}
tmp, err := os.CreateTemp("", "training-analyze-frame-*.jpg")
if err != nil {
return nil, err
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
if err := jpeg.Encode(tmp, img, &jpeg.Options{Quality: 92}); err != nil {
_ = tmp.Close()
return nil, err
}
if err := tmp.Close(); err != nil {
return nil, err
}
pred := trainingPredictFrameDetectorOnly(tmpPath)
// Wichtig: kein Fallback mehr auf altes ONNX-NSFW-Modell.
if !pred.ModelAvailable {
return &NsfwImageResponse{
Ok: true,
Results: []NsfwFrameResult{},
}, nil
}
results := trainingPredictionToNSFWResults(pred)
return &NsfwImageResponse{
Ok: true,
Results: results,
}, nil
}
func predictFrameForAnalyze(ctx context.Context, img image.Image) TrainingPrediction {
_ = ctx
if !trainingRecognitionEnabled() {
return trainingEmptyPrediction("recognition_disabled")
}
// Fallback für alte Sprite-/Image-Pfade:
// Wenn kein echter Video-Frame-Pfad vorhanden ist, schreiben wir NICHT mehr in os.TempDir(),
// sondern in generated/training/analyze-temp, damit die Bilder auffindbar bleiben.
root, err := trainingRootDir()
if err != nil {
return trainingEmptyPrediction("root_failed")
}
tmpDir := filepath.Join(root, "analyze-temp")
if err := os.MkdirAll(tmpDir, 0755); err != nil {
return trainingEmptyPrediction("mkdir_failed")
}
tmp, err := os.CreateTemp(tmpDir, "training-highlight-frame-*.jpg")
if err != nil {
return trainingEmptyPrediction("temp_failed")
}
tmpPath := tmp.Name()
// Wichtig: NICHT löschen, damit du die Bilder kontrollieren kannst.
// defer os.Remove(tmpPath)
if err := jpeg.Encode(tmp, img, &jpeg.Options{Quality: 92}); err != nil {
_ = tmp.Close()
return trainingEmptyPrediction("encode_failed")
}
if err := tmp.Close(); err != nil {
return trainingEmptyPrediction("close_failed")
}
return trainingPredictFrame(tmpPath)
}
func classifyFramePathForAnalyze(ctx context.Context, framePath string) (*NsfwImageResponse, error) {
_ = ctx
if !trainingRecognitionEnabled() {
return &NsfwImageResponse{
Ok: true,
Results: []NsfwFrameResult{},
}, nil
}
pred := trainingPredictFrameDetectorOnly(framePath)
if !pred.ModelAvailable {
return &NsfwImageResponse{
Ok: true,
Results: []NsfwFrameResult{},
}, nil
}
results := trainingPredictionToNSFWResults(pred)
return &NsfwImageResponse{
Ok: true,
Results: results,
}, nil
}
func predictFramePathForAnalyze(ctx context.Context, framePath string) TrainingPrediction {
_ = ctx
if !trainingRecognitionEnabled() {
return trainingEmptyPrediction("recognition_disabled")
}
return trainingPredictFrame(framePath)
}
type analyzeBatchPredictReq struct {
Paths []string `json:"paths"`
DetectorOnly bool `json:"detectorOnly"`
ImageSize int `json:"imageSize"`
Model string `json:"model,omitempty"`
}
type analyzeBatchPredictResp struct {
OK bool `json:"ok"`
Predictions []TrainingPrediction `json:"predictions"`
Error string `json:"error,omitempty"`
}
func analyzeAIServerURL() string {
raw := strings.TrimSpace(os.Getenv("AI_SERVER_URL"))
if raw == "" {
raw = analyzeAIServerDefaultURL
}
return strings.TrimRight(raw, "/")
}
func trainingPredictFramePathsBatchForAnalyze(
ctx context.Context,
paths []string,
detectorOnly bool,
) ([]TrainingPrediction, error) {
cleanPaths := make([]string, 0, len(paths))
for _, path := range paths {
path = strings.TrimSpace(path)
if path == "" {
continue
}
cleanPaths = append(cleanPaths, path)
}
if len(cleanPaths) == 0 {
return nil, appErrorf("keine frame-pfade für batch prediction")
}
if !trainingRecognitionEnabled() {
out := make([]TrainingPrediction, 0, len(cleanPaths))
for range cleanPaths {
out = append(out, trainingEmptyPrediction("recognition_disabled"))
}
return out, nil
}
payload := analyzeBatchPredictReq{
Paths: cleanPaths,
DetectorOnly: detectorOnly,
ImageSize: analyzeVideoFrameWidth,
}
body, err := json.Marshal(payload)
if err != nil {
return nil, err
}
url := analyzeAIServerURL() + "/predict-batch"
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
url,
bytes.NewReader(body),
)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{
Timeout: 120 * time.Second,
}
res, err := client.Do(req)
if err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return nil, ctxErr
}
return nil, err
}
defer res.Body.Close()
var parsed analyzeBatchPredictResp
if err := json.NewDecoder(res.Body).Decode(&parsed); err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return nil, ctxErr
}
return nil, err
}
if res.StatusCode < 200 || res.StatusCode >= 300 || !parsed.OK {
msg := strings.TrimSpace(parsed.Error)
if msg == "" {
msg = fmt.Sprintf("AI server HTTP %d", res.StatusCode)
}
return nil, appErrorf("%s", msg)
}
if len(parsed.Predictions) == 0 {
return nil, appErrorf("AI server lieferte keine predictions")
}
return parsed.Predictions, nil
}
func nsfwLabelPriority(label string) int {
label = strings.ToLower(strings.TrimSpace(label))
switch label {
case "vulva", "pussy":
return 1000
case "penis":
return 950
case "anus":
return 900
case "breasts":
return 800
case "buttocks", "ass":
return 700
default:
if shouldAutoSelectAnalyzeHit(label) {
return 500
}
return 0
}
}
func pickBestNSFWResult(results []NsfwFrameResult) (string, float64) {
bestLabel := ""
bestScore := 0.0
bestPriority := -1
for _, r := range results {
label := strings.ToLower(strings.TrimSpace(r.Label))
if label == "" {
continue
}
if isIgnoredNSFWLabel(label) {
continue
}
score := r.Score
priority := nsfwLabelPriority(label)
if priority > bestPriority {
bestLabel = label
bestScore = score
bestPriority = priority
continue
}
if priority == bestPriority && score > bestScore {
bestLabel = label
bestScore = score
bestPriority = priority
}
}
return bestLabel, bestScore
}
func extractVideoFrameAt(ctx context.Context, outPath string, atSec float64) (image.Image, error) {
tmp, err := os.CreateTemp("", "nsfw-frame-*.jpg")
if err != nil {
return nil, err
}
tmpPath := tmp.Name()
_ = tmp.Close()
defer os.Remove(tmpPath)
ffmpegPath := strings.TrimSpace(getSettings().FFmpegPath)
if ffmpegPath == "" {
ffmpegPath = "ffmpeg"
}
cmd := exec.CommandContext(
ctx,
ffmpegPath,
"-ss", fmt.Sprintf("%.3f", atSec),
"-i", outPath,
"-frames:v", "1",
"-vf", fmt.Sprintf("scale=%d:-2:flags=fast_bilinear", analyzeVideoFrameWidth),
"-q:v", "2",
"-y",
tmpPath,
)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
if out, err := cmd.CombinedOutput(); err != nil {
return nil, appErrorf("ffmpeg fehlgeschlagen: %v: %s", err, strings.TrimSpace(string(out)))
}
f, err := os.Open(tmpPath)
if err != nil {
return nil, err
}
defer f.Close()
img, _, err := image.Decode(f)
if err != nil {
return nil, err
}
return img, nil
}
func analyzeFramesDirForOutput(outPath string) (string, error) {
id := strings.TrimSpace(videoIDFromOutputPath(outPath))
if id == "" {
return "", appErrorf("konnte keine video-id aus output ableiten")
}
metaPath, err := generatedMetaFile(id)
if err != nil || strings.TrimSpace(metaPath) == "" {
return "", appErrorf("meta.json nicht gefunden")
}
return filepath.Join(filepath.Dir(metaPath), "frames"), nil
}
func cleanupAnalyzeFramesDirForOutput(outPath string) error {
framesDir, err := analyzeFramesDirForOutput(outPath)
if err != nil {
return err
}
// Sicherheitscheck: niemals versehentlich etwas anderes löschen.
if filepath.Base(framesDir) != "frames" {
return appErrorf("cleanup abgebrochen: unerwarteter frames-ordner: %s", framesDir)
}
// Erst alle JPGs im frames-Ordner löschen.
patterns := []string{
filepath.Join(framesDir, "*.jpg"),
filepath.Join(framesDir, "*.jpeg"),
}
for _, pattern := range patterns {
files, globErr := filepath.Glob(pattern)
if globErr != nil {
return globErr
}
for _, file := range files {
if removeErr := os.Remove(file); removeErr != nil && !os.IsNotExist(removeErr) {
return removeErr
}
}
}
// Danach den Ordner selbst löschen.
// Das klappt nur, wenn er leer ist. Falls dort andere Dateien liegen,
// bleibt der Ordner absichtlich bestehen.
if err := os.Remove(framesDir); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func extractVideoFramesBatch(
ctx context.Context,
outPath string,
durationSec float64,
intervalSeconds int,
onExtracted func(current int, expected int),
) ([]videoFrameSample, func(), error) {
if durationSec <= 0 {
return nil, nil, appErrorf("videolänge fehlt")
}
if intervalSeconds <= 0 {
intervalSeconds = 1
}
framesDir, err := analyzeFramesDirForOutput(outPath)
if err != nil {
return nil, nil, err
}
if err := os.MkdirAll(framesDir, 0755); err != nil {
return nil, nil, err
}
// Alte Analyse-Frames entfernen, damit keine stale Frames mitgelesen werden.
oldFrames, _ := filepath.Glob(filepath.Join(framesDir, "analyze-frame-*.jpg"))
for _, oldFrame := range oldFrames {
_ = os.Remove(oldFrame)
}
cleanup := func() {
if err := cleanupAnalyzeFramesDirForOutput(outPath); err != nil {
appLogln("⚠️ frames cleanup:", err)
}
}
ffmpegPath := strings.TrimSpace(getSettings().FFmpegPath)
if ffmpegPath == "" {
ffmpegPath = "ffmpeg"
}
pattern := filepath.Join(framesDir, "analyze-frame-%06d.jpg")
// fps=1/<intervalSeconds> bedeutet:
// intervalSeconds=1 -> 1 Frame pro Sekunde
// intervalSeconds=2 -> 1 Frame alle 2 Sekunden
// intervalSeconds=5 -> 1 Frame alle 5 Sekunden
vf := fmt.Sprintf(
"fps=1/%d,scale=%d:-2:flags=fast_bilinear",
intervalSeconds,
analyzeVideoFrameWidth,
)
cmd := exec.CommandContext(
ctx,
ffmpegPath,
"-hide_banner",
"-loglevel", "error",
"-i", outPath,
"-vf", vf,
"-q:v", "4",
"-fps_mode", "vfr",
"-y",
pattern,
)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000,
}
expectedFrames := int(math.Ceil(durationSec / float64(intervalSeconds)))
if expectedFrames < 1 {
expectedFrames = 1
}
emitExtractProgress := func(current int) {
if onExtracted == nil {
return
}
if current < 0 {
current = 0
}
if current > expectedFrames {
current = expectedFrames
}
onExtracted(current, expectedFrames)
}
countExtractedFrames := func() int {
files, err := filepath.Glob(filepath.Join(framesDir, "analyze-frame-*.jpg"))
if err != nil {
return 0
}
return len(files)
}
emitExtractProgress(0)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Start(); err != nil {
return nil, nil, appErrorf(
"ffmpeg frames extrahieren konnte nicht gestartet werden: %w",
err,
)
}
done := make(chan error, 1)
go func() {
done <- cmd.Wait()
}()
lastCount := -1
ticker := time.NewTicker(250 * time.Millisecond)
defer ticker.Stop()
waiting := true
for waiting {
select {
case <-ctx.Done():
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
select {
case <-done:
case <-time.After(2 * time.Second):
}
return nil, nil, ctx.Err()
case waitErr := <-done:
waiting = false
count := countExtractedFrames()
if count != lastCount {
lastCount = count
emitExtractProgress(count)
}
if waitErr != nil {
return nil, nil, appErrorf(
"ffmpeg frames extrahieren fehlgeschlagen: %v: %s",
waitErr,
strings.TrimSpace(stderr.String()),
)
}
case <-ticker.C:
count := countExtractedFrames()
if count != lastCount {
lastCount = count
emitExtractProgress(count)
}
}
}
files, err := filepath.Glob(filepath.Join(framesDir, "analyze-frame-*.jpg"))
if err != nil {
return nil, nil, err
}
sort.Strings(files)
if len(files) == 0 {
return nil, nil, appErrorf("ffmpeg hat keine frames erzeugt")
}
// Nach ffmpeg-Ende noch einmal den echten Endstand melden.
// Das sorgt dafür, dass die Extraktionshälfte zuverlässig bei 50% landen kann.
emitExtractProgress(len(files))
out := make([]videoFrameSample, 0, len(files))
for i, path := range files {
t := float64(i * intervalSeconds)
if t < 0 {
t = 0
}
if durationSec > 0 && t > durationSec {
t = durationSec
}
out = append(out, videoFrameSample{
Index: i,
Time: t,
Path: path,
})
}
return out, cleanup, nil
}
func recordAnalyzeVideo(w http.ResponseWriter, r *http.Request) {
if !mustMethod(w, r, http.MethodPost) {
return
}
var req analyzeVideoReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungültiger body: "+err.Error(), http.StatusBadRequest)
return
}
req.Mode = "video"
req.Goal = strings.ToLower(strings.TrimSpace(req.Goal))
if req.Goal == "" {
req.Goal = "highlights"
}
// Sprite-Modus ist deaktiviert, weil kein predict_sprite_batch.py vorhanden ist.
// Analyse läuft immer über den Video-Frame-Batch-Pfad.
switch req.Goal {
case "highlights", "nsfw":
default:
http.Error(w, "goal muss 'highlights' oder 'nsfw' sein", http.StatusBadRequest)
return
}
outPath := strings.TrimSpace(req.Output)
if outPath == "" {
http.Error(w, "output fehlt", http.StatusBadRequest)
return
}
fi, err := os.Stat(outPath)
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
http.Error(w, "output datei nicht gefunden", http.StatusNotFound)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Minute)
defer cancel()
hits, err := analyzeVideoFromFrames(ctx, outPath, req.Goal)
if err != nil {
respondJSON(w, analyzeVideoResp{
OK: false,
Mode: req.Mode,
Goal: req.Goal,
Hits: []analyzeHit{},
Error: err.Error(),
})
return
}
durationSec, _ := durationSecondsForAnalyze(ctx, outPath)
segments := buildAnalyzeSegmentsForGoal(hits, durationSec, req.Goal)
var rating *aiRatingMeta
if req.Goal == "nsfw" || req.Goal == "highlights" {
rating = computeNSFWRating(segments, durationSec)
}
ai := &aiAnalysisMeta{
Goal: req.Goal,
Mode: req.Mode,
Hits: hits,
Segments: segments,
Rating: rating,
AnalyzedAtUnix: time.Now().Unix(),
}
if err := writeVideoAIForFile(ctx, outPath, "", ai); err != nil {
appLogln("⚠️ writeVideoAIForFile:", err)
}
respondJSON(w, analyzeVideoResp{
OK: true,
Mode: req.Mode,
Goal: req.Goal,
Hits: hits,
Segments: segments,
Rating: rating,
})
}
func nsfwThresholdForLabel(label string) float64 {
label = strings.ToLower(strings.TrimSpace(label))
switch label {
case "vulva", "penis", "anus":
return nsfwThresholdStrong
case "pussy", "breasts", "buttocks", "ass":
return nsfwThresholdModerate
default:
if shouldAutoSelectAnalyzeHit(label) {
return 0.40
}
return 0.50
}
}
func appendNSFWHitFromPrediction(
hits []analyzeHit,
pred TrainingPrediction,
t float64,
) []analyzeHit {
if !pred.ModelAvailable {
appLogln("⚠️ nsfw: modelAvailable=false bei", t)
return hits
}
nsfwResults := trainingPredictionToNSFWResults(pred)
if len(nsfwResults) == 0 {
return hits
}
for _, r := range nsfwResults {
label := strings.ToLower(strings.TrimSpace(r.Label))
if label == "" || label == "unknown" {
continue
}
if isIgnoredNSFWLabel(label) || isPersonSegmentLabel(label) {
continue
}
score := r.Score
if score <= 0 {
score = 1
}
if score < nsfwThresholdForLabel(label) {
continue
}
hits = append(hits, analyzeHit{
Time: t,
Label: label,
Score: score,
Start: t,
End: t,
})
}
return hits
}
type highlightSignal struct {
Label string
Score float64
Group string
}
func normalizeHighlightSignalLabel(label string) string {
label = strings.ToLower(strings.TrimSpace(label))
if label == "" || label == "unknown" {
return ""
}
switch {
case strings.HasPrefix(label, "combo:"):
// Bestehende alte Combos hier nicht weiterverwenden,
// weil wir ab jetzt selbst saubere Kombis bauen.
return ""
case strings.HasPrefix(label, "detector:"):
raw := strings.TrimPrefix(label, "detector:")
if !shouldAutoSelectAnalyzeHit(raw) {
return ""
}
return "detector:" + raw
case strings.HasPrefix(label, "body:"):
raw := strings.TrimPrefix(label, "body:")
if raw == "" || raw == "unknown" {
return ""
}
return "body:" + raw
case strings.HasPrefix(label, "object:"):
raw := strings.TrimPrefix(label, "object:")
if raw == "" || raw == "unknown" {
return ""
}
return "object:" + raw
case strings.HasPrefix(label, "clothing:"):
raw := strings.TrimPrefix(label, "clothing:")
if raw == "" || raw == "unknown" {
return ""
}
return "clothing:" + raw
case strings.HasPrefix(label, "position:"):
raw := strings.TrimPrefix(label, "position:")
if raw == "" || raw == "unknown" || !isKnownPositionLabel(raw) {
return ""
}
return "position:" + raw
default:
if isIgnoredNSFWLabel(label) {
return ""
}
if isKnownPositionLabel(label) {
return "position:" + label
}
if shouldAutoSelectAnalyzeHit(label) {
return "detector:" + label
}
return ""
}
}
func highlightSignalGroup(label string) string {
label = strings.ToLower(strings.TrimSpace(label))
switch {
case strings.HasPrefix(label, "position:"):
return "position"
case strings.HasPrefix(label, "body:"):
return "body"
case strings.HasPrefix(label, "object:"):
return "object"
case strings.HasPrefix(label, "clothing:"):
return "clothing"
case strings.HasPrefix(label, "detector:"):
raw := strings.TrimPrefix(label, "detector:")
switch {
case bodyPartSeverityWeight(raw) >= 0.65:
return "body"
case objectSeverityWeight(raw) >= 0.55:
return "object"
case clothingSeverityWeight(raw) >= 0.50:
return "clothing"
default:
return "detector"
}
default:
return "other"
}
}
func highlightSignalInterestingEnough(label string, score float64) bool {
label = normalizeHighlightSignalLabel(label)
if label == "" {
return false
}
if score <= 0 {
score = 1
}
switch {
case strings.HasPrefix(label, "position:"):
// Position alleine ist nicht interessant genug, aber als Kombi-Kontext okay.
return score >= 0.35
case strings.HasPrefix(label, "body:"):
return score >= 0.35 && segmentSeverityWeight(label) >= 0.65
case strings.HasPrefix(label, "object:"):
return score >= 0.35 && segmentSeverityWeight(label) >= 0.50
case strings.HasPrefix(label, "clothing:"):
// Kleidung nur anzeigen, wenn sie als Kombi-Kontext dient.
return score >= 0.45 && segmentSeverityWeight(label) >= 0.50
case strings.HasPrefix(label, "detector:"):
return score >= 0.45 && segmentSeverityWeight(label) >= 0.60
default:
return false
}
}
func addHighlightSignal(best map[string]highlightSignal, label string, score float64) {
label = normalizeHighlightSignalLabel(label)
if label == "" {
return
}
if !highlightSignalInterestingEnough(label, score) {
return
}
if score <= 0 {
score = 1
}
key := normalizeSegmentLabel(label)
if key == "" {
return
}
sig := highlightSignal{
Label: label,
Score: score,
Group: highlightSignalGroup(label),
}
if old, ok := best[key]; !ok || sig.Score > old.Score {
best[key] = sig
}
}
func addHighlightSignalsFromScoredLabels(
best map[string]highlightSignal,
prefix string,
items []TrainingScoredLabel,
) {
prefix = strings.ToLower(strings.TrimSpace(prefix))
if prefix == "" {
return
}
for _, item := range items {
label := strings.ToLower(strings.TrimSpace(item.Label))
if label == "" || label == "unknown" {
continue
}
addHighlightSignal(best, prefix+":"+label, item.Score)
}
}
func highlightComboPartOrder(label string) int {
label = strings.ToLower(strings.TrimSpace(label))
switch {
case strings.HasPrefix(label, "position:"):
return 0
case strings.HasPrefix(label, "body:"):
return 1
case strings.HasPrefix(label, "object:"):
return 2
case strings.HasPrefix(label, "clothing:"):
return 3
case strings.HasPrefix(label, "detector:"):
return 4
default:
return 9
}
}
func buildCombinedHighlightHitFromPrediction(pred TrainingPrediction, t float64) (analyzeHit, bool) {
if !pred.ModelAvailable {
return analyzeHit{}, false
}
best := map[string]highlightSignal{}
sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition))
if sexPosition != "" && sexPosition != "unknown" && isKnownPositionLabel(sexPosition) {
positionScore := pred.SexPositionScore
if positionScore <= 0 {
positionScore = 0.35
}
// Position soll als Kontext in Kombis bleiben.
// Sie erzeugt weiterhin kein Segment alleine, weil unten mindestens
// ein Nicht-Positionssignal verlangt wird.
best["position:"+sexPosition] = highlightSignal{
Label: "position:" + sexPosition,
Score: math.Max(positionScore, 0.20),
Group: "position",
}
}
addHighlightSignalsFromScoredLabels(best, "body", pred.BodyPartsPresent)
addHighlightSignalsFromScoredLabels(best, "object", pred.ObjectsPresent)
addHighlightSignalsFromScoredLabels(best, "clothing", pred.ClothingPresent)
for _, box := range pred.Boxes {
label := strings.ToLower(strings.TrimSpace(box.Label))
if label == "" || label == "unknown" {
continue
}
if isIgnoredNSFWLabel(label) {
continue
}
if !shouldAutoSelectAnalyzeHit(label) {
continue
}
addHighlightSignal(best, "detector:"+label, box.Score)
}
signals := make([]highlightSignal, 0, len(best))
groupSeen := map[string]bool{}
nonPositionCount := 0
hasPosition := false
var bestSingle highlightSignal
var bestSingleQuality float64
for _, sig := range best {
if sig.Label == "" {
continue
}
if sig.Group == "position" {
hasPosition = true
} else {
nonPositionCount++
sev := segmentSeverityWeight(sig.Label)
quality := sig.Score * sev
if quality > bestSingleQuality {
bestSingle = sig
bestSingleQuality = quality
}
}
groupSeen[sig.Group] = true
signals = append(signals, sig)
}
// Fallback: starke Einzel-Treffer sollen auch Highlights werden.
// Sonst verschwinden viele explizite Stellen, wenn sie nicht zufällig
// im selben Frame mit Position/Object/Clothing kombiniert werden.
returnSingleIfGoodEnough := func() (analyzeHit, bool) {
if bestSingle.Label == "" {
return analyzeHit{}, false
}
sev := segmentSeverityWeight(bestSingle.Label)
// Nur wirklich relevante Einzel-Signale übernehmen.
if sev < 0.65 {
return analyzeHit{}, false
}
if bestSingle.Score < 0.35 {
return analyzeHit{}, false
}
if bestSingleQuality < 0.32 {
return analyzeHit{}, false
}
return analyzeHit{
Time: t,
Label: bestSingle.Label,
Score: bestSingle.Score,
Start: t,
End: t,
}, true
}
// Combo nur, wenn wirklich genug Kontext da ist.
if len(signals) < 2 {
return returnSingleIfGoodEnough()
}
if hasPosition && nonPositionCount < 1 {
return returnSingleIfGoodEnough()
}
if !hasPosition && nonPositionCount < 2 {
return returnSingleIfGoodEnough()
}
if len(groupSeen) < 2 && len(signals) < 3 {
return returnSingleIfGoodEnough()
}
sort.SliceStable(signals, func(i, j int) bool {
oi := highlightComboPartOrder(signals[i].Label)
oj := highlightComboPartOrder(signals[j].Label)
if oi != oj {
return oi < oj
}
wi := segmentSeverityWeight(signals[i].Label) * signals[i].Score
wj := segmentSeverityWeight(signals[j].Label) * signals[j].Score
if wi != wj {
return wi > wj
}
return signals[i].Label < signals[j].Label
})
// Nicht zu lange Titel bauen.
if len(signals) > 4 {
signals = signals[:4]
}
parts := make([]string, 0, len(signals))
var scoreSum float64
var scoreWeightSum float64
var maxWeighted float64
for _, sig := range signals {
parts = append(parts, sig.Label)
sev := segmentSeverityWeight(sig.Label)
if sev <= 0 {
sev = 0.5
}
weighted := sig.Score * sev
scoreSum += sig.Score * sev
scoreWeightSum += sev
if weighted > maxWeighted {
maxWeighted = weighted
}
}
if len(parts) < 2 || scoreWeightSum <= 0 {
return analyzeHit{}, false
}
avgScore := scoreSum / scoreWeightSum
// Kombi-Score: stärkstes Signal + Durchschnitt.
score := 0.65*maxWeighted + 0.35*avgScore
if score > 1 {
score = 1
}
if score <= 0 {
score = avgScore
}
// Noch einmal Mindestqualität prüfen.
if score < 0.42 {
return analyzeHit{}, false
}
return analyzeHit{
Time: t,
Label: "combo:" + strings.Join(parts, "+"),
Score: score,
Start: t,
End: t,
}, true
}
func buildHighlightHitsFromPrediction(pred TrainingPrediction, t float64) []analyzeHit {
if !pred.ModelAvailable {
return nil
}
best := map[string]highlightSignal{}
sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition))
if sexPosition != "" && sexPosition != "unknown" && isKnownPositionLabel(sexPosition) {
addHighlightSignal(best, "position:"+sexPosition, pred.SexPositionScore)
}
addHighlightSignalsFromScoredLabels(best, "body", pred.BodyPartsPresent)
addHighlightSignalsFromScoredLabels(best, "object", pred.ObjectsPresent)
addHighlightSignalsFromScoredLabels(best, "clothing", pred.ClothingPresent)
for _, box := range pred.Boxes {
label := strings.ToLower(strings.TrimSpace(box.Label))
if label == "" || label == "unknown" {
continue
}
if isIgnoredNSFWLabel(label) {
continue
}
if !shouldAutoSelectAnalyzeHit(label) {
continue
}
addHighlightSignal(best, "detector:"+label, box.Score)
}
out := make([]analyzeHit, 0, len(best))
for _, sig := range best {
label := strings.ToLower(strings.TrimSpace(sig.Label))
if label == "" {
continue
}
// Schwache Positions-Kontexte wie standing/sitting nicht alleine als Segment anzeigen.
if sig.Group == "position" && segmentSeverityWeight(label) < 0.70 {
continue
}
out = append(out, analyzeHit{
Time: t,
Label: label,
Score: sig.Score,
Start: t,
End: t,
})
}
sort.SliceStable(out, func(i, j int) bool {
wi := segmentSeverityWeight(out[i].Label) * out[i].Score
wj := segmentSeverityWeight(out[j].Label) * out[j].Score
if wi != wj {
return wi > wj
}
return out[i].Label < out[j].Label
})
return out
}
func appendHighlightHitsFromPrediction(
hits []analyzeHit,
pred TrainingPrediction,
t float64,
) []analyzeHit {
next := buildHighlightHitsFromPrediction(pred, t)
if len(next) == 0 {
return hits
}
return append(hits, next...)
}
func analyzeVideoFromFrames(ctx context.Context, outPath, goal string) ([]analyzeHit, error) {
goal = strings.ToLower(strings.TrimSpace(goal))
nsfwHits, highlightHits, err := analyzeVideoFromFramesForGoal(ctx, outPath, goal)
if err != nil {
return nil, err
}
switch goal {
case "nsfw":
return nsfwHits, nil
case "highlights":
return highlightHits, nil
default:
return []analyzeHit{}, nil
}
}
const analyzeProgressTotal = 1000
func publishAnalyzeExtractProgress(
startedAtMs int64,
file string,
progress float64,
message string,
) {
progress = math.Max(0, math.Min(1, progress))
current := int(math.Round(progress * 0.5 * analyzeProgressTotal))
message = strings.TrimSpace(message)
if message == "" || strings.EqualFold(message, "Analyse") || strings.Contains(message, "Frames werden extrahiert") {
message = analyzeGlobalPercentMessageFromCurrent(current, analyzeProgressTotal)
}
publishAnalysisStep(
startedAtMs,
current,
analyzeProgressTotal,
file,
message,
)
}
func analyzePercentMessage(currentFrame int, totalFrames int) string {
if totalFrames <= 0 {
totalFrames = 1
}
ratio := float64(currentFrame) / float64(totalFrames)
ratio = math.Max(0, math.Min(1, ratio))
percent := int(math.Round(ratio * 100))
if percent < 0 {
percent = 0
}
if percent > 100 {
percent = 100
}
return fmt.Sprintf("Analyse %d%%", percent)
}
func publishAnalyzeInferenceProgress(
startedAtMs int64,
file string,
currentFrame int,
totalFrames int,
message string,
) {
if totalFrames <= 0 {
totalFrames = 1
}
ratio := float64(currentFrame) / float64(totalFrames)
ratio = math.Max(0, math.Min(1, ratio))
current := int(math.Round((0.5 + ratio*0.5) * analyzeProgressTotal))
message = strings.TrimSpace(message)
if message == "" || strings.EqualFold(message, "Analyse") {
message = analyzeGlobalPercentMessageFromCurrent(current, analyzeProgressTotal)
}
publishAnalysisStep(
startedAtMs,
current,
analyzeProgressTotal,
file,
message,
)
}
func analyzeGlobalPercentFromCurrent(current int, total int) int {
if total <= 0 {
total = analyzeProgressTotal
}
ratio := float64(current) / float64(total)
ratio = math.Max(0, math.Min(1, ratio))
percent := int(math.Round(ratio * 100))
if percent < 0 {
return 0
}
if percent > 100 {
return 100
}
return percent
}
func analyzeGlobalPercentMessageFromCurrent(current int, total int) string {
return fmt.Sprintf(
"Analyse %d%%",
analyzeGlobalPercentFromCurrent(current, total),
)
}
func analyzeVideoFromFramesForGoal(
ctx context.Context,
outPath string,
goal string,
) (nsfwHits []analyzeHit, highlightHits []analyzeHit, err error) {
goal = strings.ToLower(strings.TrimSpace(goal))
if goal == "" {
goal = "all"
}
file := filepath.Base(strings.TrimSpace(outPath))
startedAtMs := publishAnalysisStarted(file, analyzeProgressTotal, "Analyse 0%")
publishAnalyzeExtractProgress(
startedAtMs,
file,
0,
"Analyse 0%",
)
if err := ctx.Err(); err != nil {
publishAnalysisError(startedAtMs, file, "Analyse abgebrochen", err)
return nil, nil, err
}
durationSec, _ := durationSecondsForAnalyze(ctx, outPath)
if err := ctx.Err(); err != nil {
publishAnalysisError(startedAtMs, file, "Analyse abgebrochen", err)
return nil, nil, err
}
if durationSec <= 0 {
err := appErrorf("videolänge konnte nicht bestimmt werden")
publishAnalysisError(startedAtMs, file, "Analyse fehlgeschlagen", err)
return nil, nil, err
}
publishPercentRange := func(lastPercent *int, nextPercent int, current int, total int, extractPhase bool) {
if total <= 0 {
total = 1
}
if nextPercent < 0 {
nextPercent = 0
}
if nextPercent > 100 {
nextPercent = 100
}
if nextPercent <= *lastPercent {
return
}
for p := *lastPercent + 1; p <= nextPercent; p++ {
label := fmt.Sprintf("Analyse %d%%", p)
if extractPhase {
ratio := float64(p) / 50.0
if ratio < 0 {
ratio = 0
}
if ratio > 1 {
ratio = 1
}
publishAnalyzeExtractProgress(
startedAtMs,
file,
ratio,
label,
)
} else {
inferenceCurrent := current
inferenceTotal := total
if p >= 50 {
inferenceTotal = 50
inferenceCurrent = p - 50
}
publishAnalyzeInferenceProgress(
startedAtMs,
file,
inferenceCurrent,
inferenceTotal,
label,
)
}
}
*lastPercent = nextPercent
}
failCancelled := func() ([]analyzeHit, []analyzeHit, error) {
err := ctx.Err()
if err == nil {
err = context.Canceled
}
publishAnalysisError(startedAtMs, file, "Analyse abgebrochen", err)
return nil, nil, err
}
lastExtractPercent := 0
samples, cleanup, err := extractVideoFramesBatch(
ctx,
outPath,
durationSec,
analyzeVideoFrameIntervalSeconds,
func(current int, expected int) {
if expected <= 0 {
expected = 1
}
if current < 0 {
current = 0
}
if current > expected {
current = expected
}
ratio := float64(current) / float64(expected)
ratio = math.Max(0, math.Min(1, ratio))
globalPercent := int(math.Round(ratio * 50))
publishPercentRange(
&lastExtractPercent,
globalPercent,
current,
expected,
true,
)
},
)
if cleanup != nil {
defer cleanup()
}
if ctx.Err() != nil {
return failCancelled()
}
if err != nil {
publishAnalysisError(startedAtMs, file, "Frames konnten nicht extrahiert werden", err)
return nil, nil, err
}
if len(samples) == 0 {
err := appErrorf("keine frame-samples vorhanden")
publishAnalysisError(startedAtMs, file, "Keine Frames vorhanden", err)
return nil, nil, err
}
total := len(samples)
if lastExtractPercent < 50 {
publishPercentRange(
&lastExtractPercent,
50,
total,
total,
true,
)
}
if ctx.Err() != nil {
return failCancelled()
}
paths := make([]string, 0, len(samples))
for _, sample := range samples {
if err := ctx.Err(); err != nil {
return failCancelled()
}
paths = append(paths, sample.Path)
}
lastInferencePercent := 50
publishAnalyzeInferenceProgress(
startedAtMs,
file,
0,
total,
"Analyse 50%",
)
if ctx.Err() != nil {
return failCancelled()
}
// Schneller AI-Server-Batch-Pfad für nsfw, highlights und all.
// Wichtig: ensureAnalyzeAllGoalsForVideoCtx ruft goal="all" auf.
// Ohne diesen Block fällt "all" auf die sehr langsame Einzelbild-Analyse zurück.
if goal == "nsfw" || goal == "highlights" || goal == "all" {
batchOK := true
// Für nsfw könnte detectorOnly=true reichen.
// Dein ai_server.py liefert aber ohnehin alle Felder aus YOLO-Resultaten,
// daher ist false für alle Goals okay und vermeidet Sonderlogik.
detectorOnly := false
for startIdx := 0; startIdx < len(samples); startIdx += analyzeFramePredictBatchSize {
if ctx.Err() != nil {
return failCancelled()
}
endIdx := startIdx + analyzeFramePredictBatchSize
if endIdx > len(samples) {
endIdx = len(samples)
}
predictions, batchErr := trainingPredictFramePathsBatchForAnalyze(
ctx,
paths[startIdx:endIdx],
detectorOnly,
)
if ctx.Err() != nil {
return failCancelled()
}
if batchErr != nil || len(predictions) < endIdx-startIdx {
appLogln("⚠️ video batch analyse fehlgeschlagen, fallback auf einzelbild-analyse:", batchErr)
batchOK = false
nsfwHits = nil
highlightHits = nil
lastInferencePercent = 50
publishAnalyzeInferenceProgress(
startedAtMs,
file,
0,
total,
"Analyse 50%",
)
break
}
for i := 0; i < endIdx-startIdx; i++ {
if ctx.Err() != nil {
return failCancelled()
}
sample := samples[startIdx+i]
pred := predictions[i]
switch goal {
case "nsfw":
nsfwHits = appendNSFWHitFromPrediction(nsfwHits, pred, sample.Time)
case "highlights":
highlightHits = appendHighlightHitsFromPrediction(highlightHits, pred, sample.Time)
default:
nsfwHits = appendNSFWHitFromPrediction(nsfwHits, pred, sample.Time)
highlightHits = appendHighlightHitsFromPrediction(highlightHits, pred, sample.Time)
}
}
globalPercent := 50 + int(math.Round((float64(endIdx)/float64(total))*50))
if globalPercent > 100 {
globalPercent = 100
}
publishPercentRange(
&lastInferencePercent,
globalPercent,
endIdx,
total,
false,
)
}
if batchOK {
if ctx.Err() != nil {
return failCancelled()
}
if lastInferencePercent < 100 {
publishPercentRange(
&lastInferencePercent,
100,
total,
total,
false,
)
}
cleanNSFWHits := mergeAnalyzeHits(nsfwHits)
cleanHighlightHits := mergeAnalyzeHits(highlightHits)
publishAnalysisFinished(startedAtMs, total, file, "Analyse abgeschlossen")
return cleanNSFWHits, cleanHighlightHits, nil
}
}
// Fallback: langsame Einzelbild-Analyse.
// Dieser Pfad sollte nur laufen, wenn der AI-Server-Batch fehlschlägt.
for i, sample := range samples {
if ctx.Err() != nil {
return failCancelled()
}
t := sample.Time
switch goal {
case "nsfw":
res, frameErr := classifyFramePathForAnalyze(ctx, sample.Path)
if ctx.Err() != nil {
return failCancelled()
}
if frameErr == nil {
bestLabel, bestScore := pickBestNSFWResult(res.Results)
if bestLabel != "" && bestScore >= nsfwThresholdForLabel(bestLabel) {
nsfwHits = append(nsfwHits, analyzeHit{
Time: t,
Label: bestLabel,
Score: bestScore,
Start: t,
End: t,
})
}
}
case "highlights":
pred := predictFramePathForAnalyze(ctx, sample.Path)
if ctx.Err() != nil {
return failCancelled()
}
highlightHits = appendHighlightHitsFromPrediction(highlightHits, pred, t)
default:
pred := predictFramePathForAnalyze(ctx, sample.Path)
if ctx.Err() != nil {
return failCancelled()
}
nsfwHits = appendNSFWHitFromPrediction(nsfwHits, pred, t)
highlightHits = appendHighlightHitsFromPrediction(highlightHits, pred, t)
}
current := i + 1
globalPercent := 50 + int(math.Round((float64(current)/float64(total))*50))
if globalPercent > 100 {
globalPercent = 100
}
publishPercentRange(
&lastInferencePercent,
globalPercent,
current,
total,
false,
)
}
if ctx.Err() != nil {
return failCancelled()
}
if lastInferencePercent < 100 {
publishPercentRange(
&lastInferencePercent,
100,
total,
total,
false,
)
}
cleanNSFWHits := mergeAnalyzeHits(nsfwHits)
cleanHighlightHits := mergeAnalyzeHits(highlightHits)
publishAnalysisFinished(startedAtMs, total, file, "Analyse abgeschlossen")
return cleanNSFWHits, cleanHighlightHits, nil
}
func sameAnalyzeComboLabel(a, b string) bool {
a = strings.ToLower(strings.TrimSpace(a))
b = strings.ToLower(strings.TrimSpace(b))
if !strings.HasPrefix(a, "combo:") || !strings.HasPrefix(b, "combo:") {
return false
}
parse := func(label string) (position string, parts map[string]bool) {
raw := strings.TrimPrefix(label, "combo:")
parts = map[string]bool{}
for _, part := range strings.Split(raw, "+") {
part = strings.ToLower(strings.TrimSpace(part))
if part == "" {
continue
}
if strings.HasPrefix(part, "position:") {
position = strings.TrimPrefix(part, "position:")
continue
}
normalized := normalizeSegmentLabel(part)
if normalized != "" {
parts[normalized] = true
}
}
return position, parts
}
posA, partsA := parse(a)
posB, partsB := parse(b)
// Unterschiedliche klare Hauptpositionen nicht zusammenführen.
// Beispiel: doggy != missionary
if posA != "" && posB != "" && posA != posB {
return false
}
// Wenn beide keine gemeinsame Kontext-Komponente haben, nicht mergen.
// Beispiel:
// combo:position:doggy+object:dildo
// combo:position:doggy+clothing:lingerie
// => kein gemeinsames Nicht-Positionssignal, also getrennt lassen.
for part := range partsA {
if partsB[part] {
return true
}
}
return false
}
func sameAnalyzeSegmentLabel(a, b string) bool {
a = strings.ToLower(strings.TrimSpace(a))
b = strings.ToLower(strings.TrimSpace(b))
if strings.HasPrefix(a, "combo:") || strings.HasPrefix(b, "combo:") {
return sameAnalyzeComboLabel(a, b)
}
return normalizeSegmentLabel(a) == normalizeSegmentLabel(b)
}
func preferAnalyzeSegmentLabel(a, b string) string {
a = strings.ToLower(strings.TrimSpace(a))
b = strings.ToLower(strings.TrimSpace(b))
if a == "" {
return b
}
if b == "" {
return a
}
if strings.HasPrefix(a, "combo:") && strings.HasPrefix(b, "combo:") {
if strings.Contains(a, "position:") && !strings.Contains(b, "position:") {
return a
}
if strings.Contains(b, "position:") && !strings.Contains(a, "position:") {
return b
}
if len(b) > len(a) {
return b
}
return a
}
// body: ist meist semantisch sauberer als detector:
if strings.HasPrefix(a, "body:") && !strings.HasPrefix(b, "body:") {
return a
}
if strings.HasPrefix(b, "body:") && !strings.HasPrefix(a, "body:") {
return b
}
// object:/clothing:/position: ebenfalls sauberer als detector:
preferredPrefix := func(s string) bool {
return strings.HasPrefix(s, "object:") ||
strings.HasPrefix(s, "clothing:") ||
strings.HasPrefix(s, "position:") ||
strings.HasPrefix(s, "combo:")
}
if preferredPrefix(a) && strings.HasPrefix(b, "detector:") {
return a
}
if preferredPrefix(b) && strings.HasPrefix(a, "detector:") {
return b
}
// Sonst kürzeres Label behalten, z. B. breasts statt detector:breasts.
if len(b) < len(a) {
return b
}
return a
}
func segmentLabelParts(label string) []string {
label = strings.ToLower(strings.TrimSpace(label))
if label == "" {
return nil
}
if strings.HasPrefix(label, "combo:") {
raw := strings.TrimPrefix(label, "combo:")
parts := strings.Split(raw, "+")
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.ToLower(strings.TrimSpace(part))
if part != "" {
out = append(out, part)
}
}
return out
}
return []string{label}
}
func segmentPositionFromAnalyzeLabel(label string) string {
for _, part := range segmentLabelParts(label) {
part = strings.TrimSpace(part)
part = strings.TrimPrefix(part, "position:")
if isKnownPositionLabel(part) {
return part
}
}
return ""
}
func segmentTagsFromAnalyzeLabel(label string) []string {
parts := segmentLabelParts(label)
if len(parts) == 0 {
return nil
}
out := make([]string, 0, len(parts))
seen := map[string]bool{}
for _, part := range parts {
part = strings.ToLower(strings.TrimSpace(part))
if part == "" {
continue
}
// Person nicht als Segment-Tag speichern.
if isPersonSegmentLabel(part) {
continue
}
if strings.HasPrefix(part, "position:") {
pos := strings.TrimPrefix(part, "position:")
if pos == "" || !isKnownPositionLabel(pos) {
continue
}
tag := "position:" + pos
if !seen[tag] {
seen[tag] = true
out = append(out, tag)
}
continue
}
if !seen[part] {
seen[part] = true
out = append(out, part)
}
}
if len(out) == 0 {
return nil
}
return out
}
func analyzeHitContinuationGapSeconds() float64 {
// Treffer bei 0s und 6s sollen bei 3s Sampling noch zusammengehören:
// 0s erkannt, 3s nicht erkannt, 6s wieder erkannt.
return float64(analyzeVideoFrameIntervalSeconds) + analyzeLabelInvisibleGraceSeconds + 0.25
}
func mergeAnalyzeHits(in []analyzeHit) []analyzeHit {
if len(in) == 0 {
return []analyzeHit{}
}
maxGap := analyzeHitContinuationGapSeconds()
byLabel := map[string][]analyzeHit{}
for _, h := range in {
label := strings.ToLower(strings.TrimSpace(h.Label))
if label == "" || label == "unknown" {
continue
}
if isIgnoredNSFWLabel(label) || isPersonSegmentLabel(label) {
continue
}
start := h.Start
end := h.End
if start < 0 && end < 0 {
start = h.Time
end = h.Time
} else {
if start < 0 {
start = h.Time
}
if end < 0 {
end = h.Time
}
}
if start > end {
start, end = end, start
}
h.Label = label
h.Start = start
h.End = end
key := normalizeSegmentLabel(label)
if key == "" {
continue
}
byLabel[key] = append(byLabel[key], h)
}
out := make([]analyzeHit, 0, len(in))
for _, items := range byLabel {
if len(items) == 0 {
continue
}
sort.SliceStable(items, func(i, j int) bool {
if items[i].Start != items[j].Start {
return items[i].Start < items[j].Start
}
if items[i].End != items[j].End {
return items[i].End < items[j].End
}
return items[i].Label < items[j].Label
})
cur := items[0]
for i := 1; i < len(items); i++ {
n := items[i]
gap := n.Start - cur.End
if gap >= -0.25 && gap <= maxGap {
cur.Label = preferAnalyzeSegmentLabel(cur.Label, n.Label)
if n.Start < cur.Start {
cur.Start = n.Start
}
if n.End > cur.End {
cur.End = n.End
}
if n.Score > cur.Score {
cur.Score = n.Score
}
cur.Time = (cur.Start + cur.End) / 2
continue
}
out = append(out, cur)
cur = n
}
out = append(out, cur)
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].Start != out[j].Start {
return out[i].Start < out[j].Start
}
if out[i].End != out[j].End {
return out[i].End < out[j].End
}
return normalizeSegmentLabel(out[i].Label) < normalizeSegmentLabel(out[j].Label)
})
return out
}
func inferAnalyzePointSpanSeconds(hits []analyzeHit, duration float64) float64 {
const fallback = 10.0
if len(hits) < 2 {
return fallback
}
times := make([]float64, 0, len(hits))
for _, h := range hits {
t := h.Time
if t < 0 {
if h.Start >= 0 {
t = h.Start
} else if h.End >= 0 {
t = h.End
}
}
if t < 0 {
continue
}
if duration > 0 {
t = math.Max(0, math.Min(t, duration))
}
times = append(times, t)
}
if len(times) < 2 {
return fallback
}
sort.Float64s(times)
gaps := make([]float64, 0, len(times)-1)
prev := times[0]
for _, t := range times[1:] {
gap := t - prev
if gap > 0.05 {
gaps = append(gaps, gap)
prev = t
}
}
if len(gaps) == 0 {
return fallback
}
sort.Float64s(gaps)
median := gaps[len(gaps)/2]
if len(gaps)%2 == 0 {
median = (gaps[len(gaps)/2-1] + gaps[len(gaps)/2]) / 2
}
// Ein einzelner Frame repräsentiert ungefähr seinen Sample-Abstand,
// aber wir deckeln, damit Sparse-Hits nicht riesig werden.
span := median * 0.90
if span < 6 {
span = 6
}
if span > 12 {
span = 12
}
return span
}
func expandAnalyzePointToSpan(t, span, duration float64) (float64, float64) {
if span <= 0 {
span = 3
}
if t < 0 {
t = 0
}
if duration > 0 {
t = math.Max(0, math.Min(t, duration))
}
half := span / 2
start := t - half
end := t + half
if start < 0 {
start = 0
}
if duration > 0 && end > duration {
end = duration
}
if end <= start {
if duration > 0 {
end = math.Min(duration, start+math.Max(1, span))
if end <= start {
start = math.Max(0, end-math.Max(1, span))
}
} else {
end = start + math.Max(1, span)
}
}
return start, end
}
type analyzeLabelSegmentPoint struct {
Label string
Start float64
End float64
Score float64
}
func isAllowedAnalyzeSegmentLabel(label string) bool {
label = strings.ToLower(strings.TrimSpace(label))
if label == "" || label == "unknown" {
return false
}
if isIgnoredNSFWLabel(label) || isPersonSegmentLabel(label) {
return false
}
if strings.HasPrefix(label, "combo:") {
for _, part := range segmentLabelParts(label) {
if isAllowedAnalyzeSegmentLabel(part) {
return true
}
}
return false
}
raw := normalizeSegmentLabel(label)
if raw == "" || raw == "unknown" {
return false
}
return shouldAutoSelectAnalyzeHit(raw) || isKnownPositionLabel(raw)
}
func buildLabelContinuitySegmentsFromAnalyzeHits(
hits []analyzeHit,
duration float64,
) []aiSegmentMeta {
if len(hits) == 0 || duration <= 0 {
return []aiSegmentMeta{}
}
sampleSpan := math.Max(1.0, float64(analyzeVideoFrameIntervalSeconds))
halfSample := sampleSpan / 2.0
maxGap := analyzeHitContinuationGapSeconds()
byLabel := map[string][]analyzeLabelSegmentPoint{}
for _, hit := range hits {
label := strings.ToLower(strings.TrimSpace(hit.Label))
if !isAllowedAnalyzeSegmentLabel(label) {
continue
}
start := hit.Start
end := hit.End
if start < 0 && end < 0 {
start = hit.Time
end = hit.Time
} else {
if start < 0 {
start = hit.Time
}
if end < 0 {
end = hit.Time
}
}
if start > end {
start, end = end, start
}
if start < 0 {
start = hit.Time
}
if end < 0 {
end = start
}
start = math.Max(0, math.Min(start, duration))
end = math.Max(0, math.Min(end, duration))
key := normalizeSegmentLabel(label)
if key == "" {
continue
}
score := hit.Score
if score <= 0 {
score = 1
}
byLabel[key] = append(byLabel[key], analyzeLabelSegmentPoint{
Label: label,
Start: start,
End: end,
Score: score,
})
}
out := make([]aiSegmentMeta, 0)
for _, points := range byLabel {
if len(points) == 0 {
continue
}
sort.SliceStable(points, func(i, j int) bool {
if points[i].Start != points[j].Start {
return points[i].Start < points[j].Start
}
if points[i].End != points[j].End {
return points[i].End < points[j].End
}
return points[i].Label < points[j].Label
})
curLabel := points[0].Label
curStartMarker := points[0].Start
curEndMarker := points[0].End
curScoreSum := points[0].Score
curScoreCount := 1
for i := 1; i < len(points); i++ {
n := points[i]
gap := n.Start - curEndMarker
if gap >= -0.25 && gap <= maxGap {
curLabel = preferAnalyzeSegmentLabel(curLabel, n.Label)
if n.Start < curStartMarker {
curStartMarker = n.Start
}
if n.End > curEndMarker {
curEndMarker = n.End
}
curScoreSum += n.Score
curScoreCount++
continue
}
segment := makeLabelContinuitySegment(
curLabel,
curStartMarker,
curEndMarker,
curScoreSum,
curScoreCount,
halfSample,
duration,
)
if segment.DurationSeconds > 0 {
out = append(out, segment)
}
curLabel = n.Label
curStartMarker = n.Start
curEndMarker = n.End
curScoreSum = n.Score
curScoreCount = 1
}
segment := makeLabelContinuitySegment(
curLabel,
curStartMarker,
curEndMarker,
curScoreSum,
curScoreCount,
halfSample,
duration,
)
if segment.DurationSeconds > 0 {
out = append(out, segment)
}
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].StartSeconds != out[j].StartSeconds {
return out[i].StartSeconds < out[j].StartSeconds
}
if out[i].EndSeconds != out[j].EndSeconds {
return out[i].EndSeconds < out[j].EndSeconds
}
return normalizeSegmentLabel(out[i].Label) < normalizeSegmentLabel(out[j].Label)
})
return out
}
func makeLabelContinuitySegment(
label string,
startMarker float64,
endMarker float64,
scoreSum float64,
scoreCount int,
halfSample float64,
duration float64,
) aiSegmentMeta {
label = strings.ToLower(strings.TrimSpace(label))
start := startMarker - halfSample
end := endMarker + halfSample
if start < 0 {
start = 0
}
if duration > 0 && end > duration {
end = duration
}
if end <= start {
end = math.Min(duration, start+math.Max(1, halfSample*2))
}
score := 0.0
if scoreCount > 0 {
score = scoreSum / float64(scoreCount)
}
return aiSegmentMeta{
Label: label,
StartSeconds: start,
EndSeconds: end,
DurationSeconds: math.Max(0, end-start),
Score: score,
AutoSelected: true,
Position: segmentPositionFromAnalyzeLabel(label),
Tags: segmentTagsFromAnalyzeLabel(label),
}
}
func buildSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegmentMeta {
return buildLabelContinuitySegmentsFromAnalyzeHits(hits, duration)
}
func buildHighlightSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegmentMeta {
return buildLabelContinuitySegmentsFromAnalyzeHits(hits, duration)
}
func buildAnalyzeSegmentsForGoal(
hits []analyzeHit,
duration float64,
goal string,
) []aiSegmentMeta {
goal = strings.ToLower(strings.TrimSpace(goal))
switch goal {
case "highlights":
return buildHighlightSegmentsFromAnalyzeHits(hits, duration)
case "nsfw":
return buildSegmentsFromAnalyzeHits(hits, duration)
default:
return []aiSegmentMeta{}
}
}
func durationSecondsForAnalyze(ctx context.Context, outPath string) (float64, error) {
ctx2, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
return durationSecondsCached(ctx2, outPath)
}
func videoIDFromOutputPath(outPath string) string {
base := filepath.Base(strings.TrimSpace(outPath))
if base == "" {
return ""
}
stem := strings.TrimSuffix(base, filepath.Ext(base))
stem = stripHotPrefix(stem)
return strings.TrimSpace(stem)
}