nsfwapp/backend/analyze.go
2026-05-12 14:54:02 +02:00

2725 lines
57 KiB
Go

// backend\analyze.go
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"image"
"image/jpeg"
"io"
"math"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime/debug"
"sort"
"strings"
"sync"
"syscall"
"time"
)
type analyzeVideoReq struct {
JobID string `json:"jobId"`
Output string `json:"output"`
Mode string `json:"mode"` // "video"
Goal string `json:"goal"` // "highlights"
}
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
// 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
// > 0 = Frames auf diese Breite skalieren.
// <= 0 = Originalgröße des Videos behalten.
analyzeVideoFrameWidth = 0
// 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"
)
const (
analyzeMinPositionScore = 0.30
analyzeMinBodyScore = 0.25
analyzeMinObjectScore = 0.28
analyzeMinClothingScore = 0.40
analyzeMinDetectorScore = 0.35
analyzeMinSingleSeverity = 0.60
analyzeMinSingleScore = 0.25
analyzeMinSingleQuality = 0.24
analyzeMinComboScore = 0.36
)
func analyzeVideoFrameFilter(intervalSeconds int) string {
if intervalSeconds <= 0 {
intervalSeconds = 1
}
fps := fmt.Sprintf("fps=1/%d", intervalSeconds)
// Originalgröße behalten.
if analyzeVideoFrameWidth <= 0 {
return fps
}
return fmt.Sprintf(
"%s,scale=%d:-2:flags=fast_bilinear",
fps,
analyzeVideoFrameWidth,
)
}
func analyzeSingleFrameFilter() string {
// Originalgröße behalten.
if analyzeVideoFrameWidth <= 0 {
return ""
}
return fmt.Sprintf(
"scale=%d:-2:flags=fast_bilinear",
analyzeVideoFrameWidth,
)
}
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 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" && isKnownPositionLabel(sexPosition) {
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" && isKnownPositionLabel(sexPosition) {
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 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 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,omitempty"`
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,
}
if analyzeVideoFrameWidth > 0 {
payload.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")
addAIServerAuth(req)
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()
rawBody, readErr := io.ReadAll(res.Body)
if readErr != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return nil, ctxErr
}
return nil, readErr
}
var parsed analyzeBatchPredictResp
if err := json.Unmarshal(rawBody, &parsed); err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return nil, ctxErr
}
appLogf(
"❌ [analyze] AI server invalid JSON status=%d url=%s body=%s",
res.StatusCode,
url,
strings.TrimSpace(string(rawBody)),
)
return nil, appErrorf(
"AI server lieferte ungültiges JSON: HTTP %d: %s",
res.StatusCode,
strings.TrimSpace(string(rawBody)),
)
}
if res.StatusCode < 200 || res.StatusCode >= 300 || !parsed.OK {
msg := strings.TrimSpace(parsed.Error)
if msg == "" {
msg = fmt.Sprintf("AI server HTTP %d", res.StatusCode)
}
appLogf(
"❌ [analyze] AI server error status=%d url=%s error=%s body=%s",
res.StatusCode,
url,
msg,
strings.TrimSpace(string(rawBody)),
)
return nil, appErrorf("%s", msg)
}
if len(parsed.Predictions) == 0 {
return nil, appErrorf("AI server lieferte keine predictions")
}
return parsed.Predictions, nil
}
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"
}
args := []string{
"-ss", fmt.Sprintf("%.3f", atSec),
"-i", outPath,
"-frames:v", "1",
}
if vf := analyzeSingleFrameFilter(); vf != "" {
args = append(args, "-vf", vf)
}
args = append(args,
"-q:v", "2",
"-y",
tmpPath,
)
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
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 := analyzeVideoFrameFilter(intervalSeconds)
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 {
msg := strings.TrimSpace(stderr.String())
appLogf(
"❌ [analyze] ffmpeg frame extract failed input=%q err=%v stderr=%s",
outPath,
waitErr,
msg,
)
return nil, nil, appErrorf(
"ffmpeg frames extrahieren fehlgeschlagen: %v: %s",
waitErr,
msg,
)
}
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
file := ""
outPath := ""
defer func() {
if rec := recover(); rec != nil {
msg := fmt.Sprintf("panic in analyse: %v", rec)
appLogln("❌ [analyze]", msg)
appLogln("❌ [analyze] stack:\n" + string(debug.Stack()))
respondJSON(w, analyzeVideoResp{
OK: false,
Mode: "video",
Goal: "highlights",
Hits: []analyzeHit{},
Error: msg,
})
}
}()
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
logAnalyzeError("decode-request", "", "", err)
respondJSON(w, analyzeVideoResp{
OK: false,
Mode: "video",
Goal: "highlights",
Hits: []analyzeHit{},
Error: "ungültiger body: " + err.Error(),
})
return
}
req.Mode = "video"
req.Goal = "highlights"
outPath = strings.TrimSpace(req.Output)
file = filepath.Base(outPath)
appLogf("🧪 [analyze] request file=%q path=%q", file, outPath)
if outPath == "" {
err := appErrorf("output fehlt")
logAnalyzeError("validate-output", file, outPath, err)
respondJSON(w, analyzeVideoResp{
OK: false,
Mode: req.Mode,
Goal: req.Goal,
Hits: []analyzeHit{},
Error: err.Error(),
})
return
}
fi, err := os.Stat(outPath)
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
if err == nil {
err = appErrorf("output datei nicht gefunden oder leer")
}
logAnalyzeError("stat-output", file, outPath, err)
respondJSON(w, analyzeVideoResp{
OK: false,
Mode: req.Mode,
Goal: req.Goal,
Hits: []analyzeHit{},
Error: "output datei nicht gefunden: " + err.Error(),
})
return
}
appLogf(
"🧪 [analyze] file ok file=%q size=%d mod=%s",
file,
fi.Size(),
fi.ModTime().Format(time.RFC3339),
)
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Minute)
defer cancel()
hits, err := analyzeVideoFromFrames(ctx, outPath)
if err != nil {
logAnalyzeError("analyze-frames", file, outPath, err)
respondJSON(w, analyzeVideoResp{
OK: false,
Mode: req.Mode,
Goal: req.Goal,
Hits: []analyzeHit{},
Error: err.Error(),
})
return
}
appLogf("🧪 [analyze] hits file=%q count=%d", file, len(hits))
durationSec, derr := durationSecondsForAnalyze(ctx, outPath)
if derr != nil {
logAnalyzeError("duration-after-analyze", file, outPath, derr)
}
if durationSec <= 0 {
err := appErrorf("videolänge konnte nach analyse nicht bestimmt werden")
logAnalyzeError("duration-invalid", file, outPath, err)
respondJSON(w, analyzeVideoResp{
OK: false,
Mode: req.Mode,
Goal: req.Goal,
Hits: hits,
Error: err.Error(),
})
return
}
segments := buildAnalyzeSegmentsForGoal(hits, durationSec)
appLogf("🧪 [analyze] raw segments file=%q count=%d", file, len(segments))
segments = prepareAIRatingSegments(segments)
appLogf("🧪 [analyze] rating segments file=%q count=%d", file, len(segments))
rating := computeHighlightRatingForVideo(segments, durationSec, outPath)
ai := &aiAnalysisMeta{
Goal: "highlights",
Mode: "video",
Hits: hits,
Segments: segments,
Rating: rating,
AnalyzedAtUnix: time.Now().Unix(),
}
if err := writeVideoAIForFile(ctx, outPath, "", ai); err != nil {
logAnalyzeError("write-meta-ai", file, outPath, err)
respondJSON(w, analyzeVideoResp{
OK: false,
Mode: req.Mode,
Goal: req.Goal,
Hits: hits,
Segments: segments,
Rating: rating,
Error: "analyse fertig, aber meta konnte nicht gespeichert werden: " + err.Error(),
})
return
}
appLogf(
"✅ [analyze] done file=%q hits=%d segments=%d rating=%.1f stars=%d",
file,
len(hits),
len(segments),
func() float64 {
if rating == nil {
return 0
}
return rating.Score
}(),
func() int {
if rating == nil {
return 0
}
return rating.Stars
}(),
)
respondJSON(w, analyzeVideoResp{
OK: true,
Mode: req.Mode,
Goal: req.Goal,
Hits: hits,
Segments: segments,
Rating: rating,
})
}
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 rawAnalyzeSignalSeverityWeight(label string) float64 {
label = normalizeHighlightSignalLabel(label)
if label == "" {
return 0
}
raw := normalizeSegmentLabel(label)
if raw == "" || raw == "unknown" {
return 0
}
switch {
case strings.HasPrefix(label, "position:"):
return positionSeverityWeight(raw)
case strings.HasPrefix(label, "body:"):
return bodyPartSeverityWeight(raw)
case strings.HasPrefix(label, "object:"):
return objectSeverityWeight(raw)
case strings.HasPrefix(label, "clothing:"):
return clothingSeverityWeight(raw)
case strings.HasPrefix(label, "detector:"):
return detectorSeverityWeight(raw)
default:
return detectorSeverityWeight(raw)
}
}
func highlightSignalInterestingEnough(label string, score float64) bool {
label = normalizeHighlightSignalLabel(label)
if label == "" {
return false
}
if score <= 0 {
score = 1
}
rawSev := rawAnalyzeSignalSeverityWeight(label)
switch {
case strings.HasPrefix(label, "position:"):
return score >= analyzeMinPositionScore && rawSev > 0
case strings.HasPrefix(label, "body:"):
return score >= analyzeMinBodyScore && rawSev >= 0.65
case strings.HasPrefix(label, "object:"):
return score >= analyzeMinObjectScore && rawSev >= 0.50
case strings.HasPrefix(label, "clothing:"):
return score >= analyzeMinClothingScore && rawSev >= 0.50
case strings.HasPrefix(label, "detector:"):
return score >= analyzeMinDetectorScore && rawSev >= 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.
// Die Grenzwerte stehen oben als Konstanten, damit du sie später
// leichter feintunen kannst:
//
// analyzeMinSingleSeverity = 0.60
// analyzeMinSingleScore = 0.25
// analyzeMinSingleQuality = 0.24
if sev < analyzeMinSingleSeverity {
return analyzeHit{}, false
}
if bestSingle.Score < analyzeMinSingleScore {
return analyzeHit{}, false
}
if bestSingleQuality < analyzeMinSingleQuality {
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 < analyzeMinComboScore {
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 {
// Wichtig:
// Wenn eine Kombi erkannt wurde, KEINE Einzel-Hits aus demselben Frame
// zusätzlich anhängen. Sonst entstehen z. B. gleichzeitig:
// - combo:position:toy_play+object:vibrator
// - object:vibrator
if combined, ok := buildCombinedHighlightHitFromPrediction(pred, t); ok {
return append(hits, combined)
}
next := buildHighlightHitsFromPrediction(pred, t)
if len(next) == 0 {
return hits
}
return append(hits, next...)
}
func analyzeVideoFromFrames(ctx context.Context, outPath string) ([]analyzeHit, error) {
return analyzeVideoFromFramesForGoal(ctx, outPath)
}
const analyzeProgressTotal = 1000
func logAnalyzeError(stage string, file string, outPath string, err error) {
if err == nil {
return
}
appLogf(
"❌ [analyze] stage=%s file=%q path=%q err=%v",
strings.TrimSpace(stage),
strings.TrimSpace(file),
strings.TrimSpace(outPath),
err,
)
}
func logAnalyzeInfo(stage string, file string, format string, args ...any) {
prefix := fmt.Sprintf("🧪 [analyze] stage=%s file=%q ", strings.TrimSpace(stage), strings.TrimSpace(file))
appLogf(prefix+format, args...)
}
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,
) (highlightHits []analyzeHit, err error) {
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, err
}
durationSec, _ := durationSecondsForAnalyze(ctx, outPath)
if err := ctx.Err(); err != nil {
publishAnalysisError(startedAtMs, file, "Analyse abgebrochen", err)
return nil, err
}
if durationSec <= 0 {
err := appErrorf("videolänge konnte nicht bestimmt werden")
publishAnalysisError(startedAtMs, file, "Analyse fehlgeschlagen", err)
return 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, error) {
err := ctx.Err()
if err == nil {
err = context.Canceled
}
publishAnalysisError(startedAtMs, file, "Analyse abgebrochen", err)
return 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, err
}
if len(samples) == 0 {
err := appErrorf("keine frame-samples vorhanden")
publishAnalysisError(startedAtMs, file, "Keine Frames vorhanden", err)
return 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 ctx.Err() != nil {
return failCancelled()
}
paths = append(paths, sample.Path)
}
lastInferencePercent := 50
publishAnalyzeInferenceProgress(
startedAtMs,
file,
0,
total,
"Analyse 50%",
)
if ctx.Err() != nil {
return failCancelled()
}
batchOK := true
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 {
if batchErr != nil {
appLogf(
"❌ [analyze] batch failed file=%q batch=%d:%d err=%v",
file,
startIdx,
endIdx,
batchErr,
)
} else {
appLogf(
"❌ [analyze] batch returned too few predictions file=%q batch=%d:%d got=%d expected=%d",
file,
startIdx,
endIdx,
len(predictions),
endIdx-startIdx,
)
}
appLogln("⚠️ video batch analyse fehlgeschlagen, fallback auf einzelbild-analyse")
batchOK = false
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]
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,
)
}
cleanHighlightHits := mergeAnalyzeHits(highlightHits)
publishAnalysisFinished(startedAtMs, total, file, "Analyse abgeschlossen")
return cleanHighlightHits, nil
}
// Fallback: langsame Einzelbild-Analyse, nur wenn der AI-Server-Batch fehlschlägt.
for i, sample := range samples {
if ctx.Err() != nil {
return failCancelled()
}
pred := predictFramePathForAnalyze(ctx, sample.Path)
if ctx.Err() != nil {
return failCancelled()
}
highlightHits = appendHighlightHitsFromPrediction(highlightHits, pred, sample.Time)
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,
)
}
cleanHighlightHits := mergeAnalyzeHits(highlightHits)
publishAnalysisFinished(startedAtMs, total, file, "Analyse abgeschlossen")
return 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,
) []aiSegmentMeta {
return buildHighlightSegmentsFromAnalyzeHits(hits, duration)
}
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)
}