nsfwapp/backend/analyze.go
2026-05-05 15:06:59 +02:00

2906 lines
62 KiB
Go

// backend\analyze.go
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"image"
"image/draw"
"image/jpeg"
"math"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"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 spriteFrameCandidate struct {
Index int
Time float64
}
type videoFrameSample struct {
Index int
Time float64
Path string
}
const (
analyzeSegmentMergeGapSeconds = 8.0
nsfwThresholdModerate = 0.35
nsfwThresholdStrong = 0.60
// Sprite-Modus ist aktuell deaktiviert. Analyse läuft über Video-Frames.
analyzeMaxSpriteCandidates = 24
// 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"
)
var autoSelectedAILabels = map[string]struct{}{
// bodyParts aus detecton_labels.json
"anus": {},
"ass": {},
"breasts": {},
"penis": {},
"tongue": {},
"pussy": {},
// objects aus detecton_labels.json
"blindfold": {},
"buttplug": {},
"collar": {},
"dildo": {},
"handcuffs": {},
"shower": {},
"strapon": {},
"towel": {},
"vibrator": {},
// clothing aus detecton_labels.json
"bikini": {},
"bra": {},
"dress": {},
"heels": {},
"hotpants": {},
"lingerie": {},
"panties": {},
"skirt": {},
"stockings": {},
"croptop": {},
// sexPositions aus detecton_labels.json
"missionary": {},
"doggy": {},
"cowgirl": {},
"reverse_cowgirl": {},
"cunnilingus": {},
"prone_bone": {},
"standing": {},
"standing_doggy": {},
"spooning": {},
"sitting": {},
"facesitting": {},
"handjob": {},
"blowjob": {},
"toy_play": {},
"fingering": {},
"69": {},
"other": {},
}
var nsfwIgnoredLabels = map[string]struct{}{
// Personen sollen nicht als interessante Segmente auftauchen.
"person": {},
"person_male": {},
"person_female": {},
"person_unknown": {},
// Falls dein Detector irgendwann diese Varianten liefert:
"people_male": {},
"people_female": {},
}
func shouldAutoSelectAnalyzeHit(label string) bool {
label = strings.ToLower(strings.TrimSpace(label))
_, ok := autoSelectedAILabels[label]
return ok
}
func isIgnoredNSFWLabel(label string) bool {
label = strings.ToLower(strings.TrimSpace(label))
_, ok := nsfwIgnoredLabels[label]
return ok
}
func extractSpriteFrames(spritePath string, ps previewSpriteMetaFileInfo) ([]image.Image, error) {
f, err := os.Open(spritePath)
if err != nil {
return nil, err
}
defer f.Close()
img, _, err := image.Decode(f)
if err != nil {
return nil, err
}
b := img.Bounds()
if ps.Cols <= 0 || ps.Rows <= 0 {
return nil, appErrorf("sprite cols/rows fehlen")
}
cellW := b.Dx() / ps.Cols
cellH := b.Dy() / ps.Rows
if cellW <= 0 || cellH <= 0 {
return nil, appErrorf("ungültige sprite cell size")
}
count := ps.Count
if count <= 0 {
count = ps.Cols * ps.Rows
}
out := make([]image.Image, 0, count)
for i := 0; i < count; i++ {
col := i % ps.Cols
row := i / ps.Cols
if row >= ps.Rows {
break
}
srcRect := image.Rect(
b.Min.X+col*cellW,
b.Min.Y+row*cellH,
b.Min.X+(col+1)*cellW,
b.Min.Y+(row+1)*cellH,
)
dst := image.NewRGBA(image.Rect(0, 0, cellW, cellH))
draw.Draw(dst, dst.Bounds(), img, srcRect.Min, draw.Src)
out = append(out, dst)
}
return out, nil
}
func classifyFrameNSFW(ctx context.Context, img image.Image) (*NsfwImageResponse, error) {
_ = ctx
results, err := detectNSFWFromImage(img)
if err != nil {
return nil, err
}
return &NsfwImageResponse{
Ok: true,
Results: results,
}, nil
}
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:"+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 analyzeVideoFromSpriteAllGoals(ctx context.Context, outPath string) (nsfwHits []analyzeHit, highlightHits []analyzeHit, err error) {
id := strings.TrimSpace(videoIDFromOutputPath(outPath))
if id == "" {
return nil, nil, appErrorf("konnte keine video-id aus output ableiten")
}
metaPath, err := generatedMetaFile(id)
if err != nil || strings.TrimSpace(metaPath) == "" {
return nil, nil, appErrorf("meta.json nicht gefunden")
}
ps, ok := readPreviewSpriteMetaFromMetaFile(metaPath)
if !ok {
return nil, nil, appErrorf("previewSprite meta fehlt")
}
if ps.Count <= 0 {
return nil, nil, appErrorf("previewSprite count fehlt")
}
spritePath := filepath.Join(filepath.Dir(metaPath), "preview-sprite.jpg")
if fi, err := os.Stat(spritePath); err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
return nil, nil, appErrorf("preview-sprite.jpg nicht gefunden")
}
durationSec := ps.StepSeconds * math.Max(1, float64(ps.Count-1))
if durationSec <= 0 {
durationSec, _ = durationSecondsForAnalyze(ctx, outPath)
}
candidates := buildSpriteFrameCandidates(ps.Count, ps.StepSeconds, durationSec)
candidates = limitSpriteFrameCandidates(candidates, analyzeMaxSpriteCandidates)
if len(candidates) == 0 {
return nil, nil, appErrorf("keine sprite-kandidaten vorhanden")
}
// 1) Schneller Pfad: Python-Batch.
results, batchErr := trainingPredictSpriteBatch(ctx, spritePath, ps, candidates)
if batchErr == nil {
for _, item := range results {
pred := item.Prediction
if !pred.ModelAvailable {
continue
}
t := item.Time
nsfwResults := trainingPredictionToNSFWResults(pred)
bestLabel, bestScore := pickBestNSFWResult(nsfwResults)
if bestLabel != "" && bestScore >= nsfwThresholdForLabel(bestLabel) {
nsfwHits = append(nsfwHits, analyzeHit{
Time: t,
Label: bestLabel,
Score: bestScore,
Start: t,
End: t,
})
}
highlightHits = appendHighlightHitsFromPrediction(highlightHits, pred, t)
}
return mergeAnalyzeHits(nsfwHits), mergeAnalyzeHits(highlightHits), nil
}
// 2) Fallback: alte langsame Methode, damit Analyse nicht komplett fehlschlägt.
appLogln("⚠️ sprite batch analyse fehlgeschlagen, fallback auf langsame Analyse:", batchErr)
frames, err := extractSpriteFrames(spritePath, ps)
if err != nil {
return nil, nil, appErrorf("sprite frames extrahieren fehlgeschlagen: %w", err)
}
for _, c := range candidates {
if c.Index < 0 || c.Index >= len(frames) {
continue
}
pred := predictFrameForAnalyze(ctx, frames[c.Index])
if !pred.ModelAvailable {
continue
}
t := c.Time
nsfwResults := trainingPredictionToNSFWResults(pred)
bestLabel, bestScore := pickBestNSFWResult(nsfwResults)
if bestLabel != "" && bestScore >= nsfwThresholdForLabel(bestLabel) {
nsfwHits = append(nsfwHits, analyzeHit{
Time: t,
Label: bestLabel,
Score: bestScore,
Start: t,
End: t,
})
}
highlightHits = appendHighlightHitsFromPrediction(highlightHits, pred, t)
}
return mergeAnalyzeHits(nsfwHits), mergeAnalyzeHits(highlightHits), nil
}
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 {
return hits
}
nsfwResults := trainingPredictionToNSFWResults(pred)
bestLabel, bestScore := pickBestNSFWResult(nsfwResults)
if bestLabel == "" {
return hits
}
if bestScore < nsfwThresholdForLabel(bestLabel) {
return hits
}
return append(hits, analyzeHit{
Time: t,
Label: bestLabel,
Score: bestScore,
Start: t,
End: t,
})
}
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" {
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)
}
if len(best) < 2 {
return analyzeHit{}, false
}
signals := make([]highlightSignal, 0, len(best))
groupSeen := map[string]bool{}
nonPositionCount := 0
hasPosition := false
for _, sig := range best {
if sig.Label == "" {
continue
}
if sig.Group == "position" {
hasPosition = true
} else {
nonPositionCount++
}
groupSeen[sig.Group] = true
signals = append(signals, sig)
}
// Nur echte interessante Kombis:
// - Position + mindestens ein weiteres Signal
// - oder mindestens zwei Nicht-Positions-Signale
// - oder mindestens zwei unterschiedliche Signalgruppen
if len(signals) < 2 {
return analyzeHit{}, false
}
if hasPosition && nonPositionCount < 1 {
return analyzeHit{}, false
}
if !hasPosition && nonPositionCount < 2 {
return analyzeHit{}, false
}
if len(groupSeen) < 2 && len(signals) < 3 {
return analyzeHit{}, false
}
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 appendHighlightHitsFromPrediction(
hits []analyzeHit,
pred TrainingPrediction,
t float64,
) []analyzeHit {
hit, ok := buildCombinedHighlightHitFromPrediction(pred, t)
if !ok {
return hits
}
return append(hits, hit)
}
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 analyzeSpriteCandidatesWithAI(
ctx context.Context,
spritePath string,
ps previewSpriteMetaFileInfo,
candidates []spriteFrameCandidate,
goal string,
) ([]analyzeHit, error) {
frames, err := extractSpriteFrames(spritePath, ps)
if err != nil {
return nil, appErrorf("sprite frames extrahieren fehlgeschlagen: %w", err)
}
hits := make([]analyzeHit, 0, len(candidates))
for _, c := range candidates {
if c.Index < 0 || c.Index >= len(frames) {
continue
}
img := frames[c.Index]
switch goal {
case "nsfw":
res, err := classifyFrameForAnalyze(ctx, img)
if err != nil {
continue
}
bestLabel, bestScore := pickBestNSFWResult(res.Results)
if bestLabel == "" {
continue
}
threshold := nsfwThresholdForLabel(bestLabel)
if bestScore < threshold {
continue
}
hits = append(hits, analyzeHit{
Time: c.Time,
Label: bestLabel,
Score: bestScore,
Start: c.Time,
End: c.Time,
})
case "highlights":
pred := predictFrameForAnalyze(ctx, img)
if !pred.ModelAvailable {
continue
}
hits = appendHighlightHitsFromPrediction(hits, pred, c.Time)
}
}
return hits, 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
}
// 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 mergeAnalyzeHits(in []analyzeHit) []analyzeHit {
if len(in) == 0 {
return []analyzeHit{}
}
cp := make([]analyzeHit, 0, len(in))
for _, h := range in {
label := strings.ToLower(strings.TrimSpace(h.Label))
if label == "" {
continue
}
if isIgnoredNSFWLabel(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
}
}
h.Label = label
h.Start = start
h.End = end
cp = append(cp, h)
}
if len(cp) == 0 {
return []analyzeHit{}
}
sort.Slice(cp, func(i, j int) bool {
if cp[i].Start != cp[j].Start {
return cp[i].Start < cp[j].Start
}
if cp[i].End != cp[j].End {
return cp[i].End < cp[j].End
}
return cp[i].Label < cp[j].Label
})
out := make([]analyzeHit, 0, len(cp))
cur := cp[0]
for i := 1; i < len(cp); i++ {
n := cp[i]
// Direkt aufeinanderfolgende Treffer mit gleichem Label immer zusammenfassen.
// Sobald ein anderes Label dazwischen liegt, wird automatisch nicht gemergt.
sameLabel := sameAnalyzeSegmentLabel(cur.Label, n.Label)
gap := n.Start - cur.End
if sameLabel && gap >= -0.25 && gap <= analyzeSegmentMergeGapSeconds {
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)
return out
}
func inferAnalyzePointSpanSeconds(hits []analyzeHit, duration float64) float64 {
const fallback = 3.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 < 2 {
span = 2
}
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
}
func buildSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegmentMeta {
if len(hits) == 0 || duration <= 0 {
return []aiSegmentMeta{}
}
pointSpan := inferAnalyzePointSpanSeconds(hits, duration)
out := make([]aiSegmentMeta, 0, len(hits))
for _, hit := range hits {
if !shouldAutoSelectAnalyzeHit(hit.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
}
start = math.Max(0, math.Min(start, duration))
end = math.Max(0, math.Min(end, duration))
// Wichtig:
// Einzelne AI-Treffer sind oft Punkt-Treffer: Start == End.
// Für Segmente und Rating brauchen sie aber eine kleine Dauer.
if end <= start {
marker := hit.Time
if marker < 0 {
marker = start
}
start, end = expandAnalyzePointToSpan(marker, pointSpan, duration)
}
if end <= start {
continue
}
out = append(out, aiSegmentMeta{
Label: strings.ToLower(strings.TrimSpace(hit.Label)),
StartSeconds: start,
EndSeconds: end,
DurationSeconds: end - start,
Score: hit.Score,
AutoSelected: true,
})
}
if len(out) == 0 {
return []aiSegmentMeta{}
}
sort.Slice(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 out[i].Label < out[j].Label
})
merged := make([]aiSegmentMeta, 0, len(out))
cur := out[0]
for i := 1; i < len(out); i++ {
n := out[i]
gap := n.StartSeconds - cur.EndSeconds
if gap < 0 {
gap = 0
}
if sameAnalyzeSegmentLabel(cur.Label, n.Label) && gap <= analyzeSegmentMergeGapSeconds {
cur.Label = preferAnalyzeSegmentLabel(cur.Label, n.Label)
if n.StartSeconds < cur.StartSeconds {
cur.StartSeconds = n.StartSeconds
}
if n.EndSeconds > cur.EndSeconds {
cur.EndSeconds = n.EndSeconds
}
cur.DurationSeconds = cur.EndSeconds - cur.StartSeconds
if n.Score > cur.Score {
cur.Score = n.Score
}
cur.AutoSelected = cur.AutoSelected || n.AutoSelected
continue
}
merged = append(merged, cur)
cur = n
}
merged = append(merged, cur)
return mergeAdjacentAISegments(merged, analyzeSegmentMergeGapSeconds)
}
func buildHighlightSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegmentMeta {
if len(hits) == 0 || duration <= 0 {
return []aiSegmentMeta{}
}
pointSpan := inferAnalyzePointSpanSeconds(hits, duration)
out := make([]aiSegmentMeta, 0, len(hits))
for _, hit := range hits {
label := strings.ToLower(strings.TrimSpace(hit.Label))
if label == "" || label == "unknown" {
continue
}
if isIgnoredNSFWLabel(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
}
start = math.Max(0, math.Min(start, duration))
end = math.Max(0, math.Min(end, duration))
if end <= start {
marker := hit.Time
if marker < 0 {
marker = start
}
start, end = expandAnalyzePointToSpan(marker, pointSpan, duration)
}
if end <= start {
continue
}
out = append(out, aiSegmentMeta{
Label: label,
StartSeconds: start,
EndSeconds: end,
DurationSeconds: end - start,
Score: hit.Score,
AutoSelected: true,
})
}
if len(out) == 0 {
return []aiSegmentMeta{}
}
sort.Slice(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 out[i].Label < out[j].Label
})
merged := make([]aiSegmentMeta, 0, len(out))
cur := out[0]
for i := 1; i < len(out); i++ {
n := out[i]
gap := n.StartSeconds - cur.EndSeconds
if gap < 0 {
gap = 0
}
if sameAnalyzeSegmentLabel(cur.Label, n.Label) && gap <= analyzeSegmentMergeGapSeconds {
cur.Label = preferAnalyzeSegmentLabel(cur.Label, n.Label)
if n.StartSeconds < cur.StartSeconds {
cur.StartSeconds = n.StartSeconds
}
if n.EndSeconds > cur.EndSeconds {
cur.EndSeconds = n.EndSeconds
}
cur.DurationSeconds = cur.EndSeconds - cur.StartSeconds
if n.Score > cur.Score {
cur.Score = n.Score
}
cur.AutoSelected = cur.AutoSelected || n.AutoSelected
continue
}
merged = append(merged, cur)
cur = n
}
merged = append(merged, cur)
return mergeAdjacentAISegments(merged, analyzeSegmentMergeGapSeconds)
}
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 buildSpriteFrameCandidates(count int, stepSeconds, durationSec float64) []spriteFrameCandidate {
if count <= 0 {
return nil
}
out := make([]spriteFrameCandidate, 0, count)
stepLooksUsable := false
if stepSeconds > 0 && durationSec > 0 {
coverage := stepSeconds * math.Max(1, float64(count-1))
stepLooksUsable = coverage >= durationSec*0.7 && coverage <= durationSec*1.3
}
for i := 0; i < count; i++ {
var t float64
if stepLooksUsable {
t = float64(i) * stepSeconds
} else if durationSec > 0 && count > 1 {
t = (float64(i) / float64(count-1)) * durationSec
} else if stepSeconds > 0 {
t = float64(i) * stepSeconds
} else {
t = float64(i)
}
out = append(out, spriteFrameCandidate{
Index: i,
Time: t,
})
}
return out
}
func limitSpriteFrameCandidates(in []spriteFrameCandidate, max int) []spriteFrameCandidate {
if max <= 0 || len(in) <= max {
return in
}
out := make([]spriteFrameCandidate, 0, max)
seen := map[int]bool{}
if max == 1 {
return []spriteFrameCandidate{in[len(in)/2]}
}
for i := 0; i < max; i++ {
ratio := float64(i) / float64(max-1)
idx := int(math.Round(ratio * float64(len(in)-1)))
if idx < 0 {
idx = 0
}
if idx >= len(in) {
idx = len(in) - 1
}
if seen[idx] {
continue
}
seen[idx] = true
out = append(out, in[idx])
}
if len(out) == 0 {
return in
}
return out
}
func buildVideoSampleTimes(durationSec float64, sampleCount int) []float64 {
if durationSec <= 0 || sampleCount <= 0 {
return nil
}
// Nicht exakt bei 0.0s und nicht exakt am Videoende sampeln.
// Anfang/Ende sind häufiger schwarz, unscharf oder ffmpeg schlägt am Ende fehl.
startPad := math.Min(1.0, durationSec*0.05)
endPad := math.Min(1.0, durationSec*0.05)
start := startPad
end := durationSec - endPad
if end <= start {
start = 0
end = durationSec
}
if sampleCount == 1 {
return []float64{(start + end) / 2}
}
out := make([]float64, 0, sampleCount)
for i := 0; i < sampleCount; i++ {
ratio := float64(i) / float64(sampleCount-1)
t := start + ratio*(end-start)
if t < 0 {
t = 0
}
if t > durationSec {
t = durationSec
}
out = append(out, t)
}
return out
}
func inferredSpanSeconds(stepSeconds float64, fallback float64) float64 {
if stepSeconds > 0 {
return math.Max(2, stepSeconds*1.5)
}
return fallback
}
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)
}