3504 lines
75 KiB
Go
3504 lines
75 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"
|
|
"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 = 1
|
|
|
|
// 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
|
|
)
|
|
|
|
const (
|
|
analyzePositionClipWindowSeconds = 3.0
|
|
analyzePositionClipMinScore = 0.22
|
|
analyzePositionClipMinFrames = 2
|
|
analyzePositionConflictMargin = 0.08
|
|
analyzePositionMinStableSeconds = 8.0
|
|
analyzePositionShortMaxScore = 0.62
|
|
)
|
|
|
|
type analyzePositionEvidence struct {
|
|
Time float64
|
|
Start float64
|
|
End float64
|
|
Label string
|
|
Score float64
|
|
Source string
|
|
PersonCount int
|
|
HasPose bool
|
|
HasContext bool
|
|
HasClip bool
|
|
}
|
|
|
|
func isAnalyzeContextOnlyPositionLabel(label string) bool {
|
|
return false
|
|
}
|
|
|
|
func isAnalyzeTimelinePositionLabel(label string) bool {
|
|
label = strings.ToLower(strings.TrimSpace(label))
|
|
label = strings.TrimPrefix(label, "position:")
|
|
return !isNoSexPositionLabel(label) &&
|
|
isKnownPositionLabel(label) &&
|
|
!isAnalyzeContextOnlyPositionLabel(label)
|
|
}
|
|
|
|
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 isNoSexPositionLabel(label) {
|
|
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 isNoSexPositionLabel(label) {
|
|
return false
|
|
}
|
|
|
|
if isIgnoredNSFWLabel(label) || isPersonSegmentLabel(label) {
|
|
return false
|
|
}
|
|
|
|
labels := autoSelectedAILabelSet()
|
|
if _, ok := labels[label]; ok {
|
|
return true
|
|
}
|
|
|
|
// Robuster Fallback:
|
|
// Auch wenn trainingGroupedLabels()/detection_labels.json gerade leer,
|
|
// veraltet oder nicht ladbar ist, sollen bekannte Rating-/Analyze-Labels
|
|
// trotzdem durchkommen.
|
|
if bodyPartSeverityWeight(label) > 0 {
|
|
return true
|
|
}
|
|
if objectSeverityWeight(label) > 0 {
|
|
return true
|
|
}
|
|
if clothingSeverityWeight(label) > 0 {
|
|
return true
|
|
}
|
|
if isKnownPositionLabel(label) && positionSeverityWeight(label) > 0 {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
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 isNoSexPositionLabel(label) {
|
|
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 isNoSexPositionLabel(label) {
|
|
continue
|
|
}
|
|
|
|
addHighlightResult(best, prefix+":"+label, item.Score)
|
|
}
|
|
}
|
|
|
|
func trainingPredictionToHighlightResults(pred TrainingPrediction) []NsfwFrameResult {
|
|
best := map[string]float64{}
|
|
|
|
sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition))
|
|
if isAnalyzeTimelinePositionLabel(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 isNoSexPositionLabel(label) {
|
|
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 isAnalyzeTimelinePositionLabel(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 isNoSexPositionLabel(label) {
|
|
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 isNoSexPositionLabel(label) {
|
|
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...)
|
|
hideCommandWindow(cmd)
|
|
|
|
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,
|
|
)
|
|
|
|
hideCommandWindow(cmd)
|
|
|
|
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, analyzeStartedAtMs, _, 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))
|
|
|
|
publishAnalyzePersistProgress(analyzeStartedAtMs, file, 0.1, "")
|
|
|
|
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)
|
|
publishAnalysisError(analyzeStartedAtMs, file, "Analyse fehlgeschlagen", 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))
|
|
|
|
publishAnalyzePersistProgress(analyzeStartedAtMs, file, 0.45, "")
|
|
|
|
rating := computeHighlightRatingForVideo(segments, durationSec, outPath)
|
|
|
|
ai := &aiAnalysisMeta{
|
|
Goal: "highlights",
|
|
Mode: "video",
|
|
Completed: true,
|
|
Hits: hits,
|
|
Segments: segments,
|
|
Rating: rating,
|
|
AnalyzedAtUnix: time.Now().Unix(),
|
|
}
|
|
|
|
persistCtx, persistCancel := context.WithTimeout(ctx, 90*time.Second)
|
|
defer persistCancel()
|
|
|
|
if err := writeVideoAIForFile(persistCtx, outPath, "", ai); err != nil {
|
|
logAnalyzeError("write-meta-ai", file, outPath, err)
|
|
publishAnalysisError(analyzeStartedAtMs, file, "Speichern fehlgeschlagen", 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
|
|
}
|
|
|
|
autoDeleteLowRatedDownloadAfterAnalysis(persistCtx, outPath, rating)
|
|
publishAnalyzePersistProgress(analyzeStartedAtMs, file, 1, "")
|
|
publishAnalysisFinished(analyzeStartedAtMs, analyzeProgressTotal, file, "Analyse abgeschlossen")
|
|
|
|
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 isNoSexPositionLabel(label) {
|
|
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 isNoSexPositionLabel(raw) {
|
|
return ""
|
|
}
|
|
return "body:" + raw
|
|
|
|
case strings.HasPrefix(label, "object:"):
|
|
raw := strings.TrimPrefix(label, "object:")
|
|
if isNoSexPositionLabel(raw) {
|
|
return ""
|
|
}
|
|
return "object:" + raw
|
|
|
|
case strings.HasPrefix(label, "clothing:"):
|
|
raw := strings.TrimPrefix(label, "clothing:")
|
|
if isNoSexPositionLabel(raw) {
|
|
return ""
|
|
}
|
|
return "clothing:" + raw
|
|
|
|
case strings.HasPrefix(label, "position:"):
|
|
raw := strings.TrimPrefix(label, "position:")
|
|
if !isAnalyzeTimelinePositionLabel(raw) {
|
|
return ""
|
|
}
|
|
return "position:" + raw
|
|
|
|
default:
|
|
if isIgnoredNSFWLabel(label) {
|
|
return ""
|
|
}
|
|
|
|
if isAnalyzeTimelinePositionLabel(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 isNoSexPositionLabel(raw) {
|
|
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 isNoSexPositionLabel(label) {
|
|
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 predictionHasAnyAnalyzeSignal(pred TrainingPrediction) bool {
|
|
sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition))
|
|
if !isNoSexPositionLabel(sexPosition) {
|
|
return true
|
|
}
|
|
|
|
if len(pred.BodyPartsPresent) > 0 {
|
|
return true
|
|
}
|
|
if len(pred.ObjectsPresent) > 0 {
|
|
return true
|
|
}
|
|
if len(pred.ClothingPresent) > 0 {
|
|
return true
|
|
}
|
|
if len(pred.Boxes) > 0 {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func predictionUsableForAnalyze(pred TrainingPrediction) bool {
|
|
if pred.ModelAvailable {
|
|
return true
|
|
}
|
|
|
|
// Fallback:
|
|
// Einige AI-Server/Antworten liefern verwertbare Labels/Boxes,
|
|
// setzen modelAvailable aber nicht sauber.
|
|
return predictionHasAnyAnalyzeSignal(pred)
|
|
}
|
|
|
|
func buildCombinedHighlightHitFromPrediction(pred TrainingPrediction, t float64) (analyzeHit, bool) {
|
|
if !predictionUsableForAnalyze(pred) {
|
|
return analyzeHit{}, false
|
|
}
|
|
|
|
best := map[string]highlightSignal{}
|
|
|
|
sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition))
|
|
|
|
if isAnalyzeTimelinePositionLabel(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 isNoSexPositionLabel(label) {
|
|
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 !predictionUsableForAnalyze(pred) {
|
|
return nil
|
|
}
|
|
|
|
best := map[string]highlightSignal{}
|
|
|
|
sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition))
|
|
if isAnalyzeTimelinePositionLabel(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 isNoSexPositionLabel(label) {
|
|
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 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 analyzeHitWithoutRawPosition(hit analyzeHit) (analyzeHit, bool) {
|
|
label := strings.ToLower(strings.TrimSpace(hit.Label))
|
|
if label == "" {
|
|
return analyzeHit{}, false
|
|
}
|
|
|
|
if strings.HasPrefix(label, "position:") {
|
|
return analyzeHit{}, false
|
|
}
|
|
|
|
if !strings.HasPrefix(label, "combo:") {
|
|
hit.Label = label
|
|
return hit, true
|
|
}
|
|
|
|
parts := []string{}
|
|
for _, part := range strings.Split(strings.TrimPrefix(label, "combo:"), "+") {
|
|
part = strings.ToLower(strings.TrimSpace(part))
|
|
if part == "" || strings.HasPrefix(part, "position:") {
|
|
continue
|
|
}
|
|
parts = append(parts, part)
|
|
}
|
|
|
|
switch len(parts) {
|
|
case 0:
|
|
return analyzeHit{}, false
|
|
case 1:
|
|
hit.Label = parts[0]
|
|
default:
|
|
hit.Label = "combo:" + strings.Join(parts, "+")
|
|
}
|
|
|
|
return hit, true
|
|
}
|
|
|
|
func appendVideoFrameHighlightHitsFromPrediction(
|
|
hits []analyzeHit,
|
|
pred TrainingPrediction,
|
|
t float64,
|
|
) []analyzeHit {
|
|
next := appendHighlightHitsFromPrediction(nil, pred, t)
|
|
if len(next) == 0 {
|
|
return hits
|
|
}
|
|
|
|
for _, hit := range next {
|
|
if stripped, ok := analyzeHitWithoutRawPosition(hit); ok {
|
|
hits = append(hits, stripped)
|
|
}
|
|
}
|
|
|
|
return hits
|
|
}
|
|
|
|
func analyzePositionEvidenceFromPrediction(
|
|
pred TrainingPrediction,
|
|
t float64,
|
|
) (analyzePositionEvidence, bool) {
|
|
if !predictionUsableForAnalyze(pred) {
|
|
return analyzePositionEvidence{}, false
|
|
}
|
|
|
|
label := strings.ToLower(strings.TrimSpace(pred.SexPosition))
|
|
if !isAnalyzeTimelinePositionLabel(label) {
|
|
return analyzePositionEvidence{}, false
|
|
}
|
|
|
|
score := pred.SexPositionScore
|
|
if score <= 0 {
|
|
score = 0.35
|
|
}
|
|
score = clamp01(score)
|
|
if score < 0.16 {
|
|
return analyzePositionEvidence{}, false
|
|
}
|
|
|
|
source := strings.ToLower(strings.TrimSpace(pred.Source))
|
|
personCount := len(pred.Persons)
|
|
if personCount == 0 {
|
|
for _, box := range pred.Boxes {
|
|
if trainingIsPersonLikeLabel(box.Label) {
|
|
personCount++
|
|
}
|
|
}
|
|
}
|
|
|
|
return analyzePositionEvidence{
|
|
Time: t,
|
|
Start: t,
|
|
End: t,
|
|
Label: label,
|
|
Score: score,
|
|
Source: source,
|
|
PersonCount: personCount,
|
|
HasPose: strings.Contains(source, "yolo_pose") || len(pred.Persons) > 0,
|
|
HasContext: strings.Contains(source, "box_context") || len(pred.Boxes) > 0,
|
|
}, true
|
|
}
|
|
|
|
func analyzePositionEvidenceWeight(item analyzePositionEvidence) float64 {
|
|
weight := 1.0
|
|
|
|
if item.HasClip && (item.HasPose || item.HasContext) {
|
|
weight = 1.70
|
|
} else if item.HasClip {
|
|
weight = 1.55
|
|
} else if item.HasPose && item.HasContext {
|
|
weight = 1.05
|
|
} else if item.HasPose {
|
|
weight = 0.76
|
|
} else if item.HasContext {
|
|
weight = 0.82
|
|
}
|
|
|
|
if item.PersonCount >= 2 {
|
|
weight += 0.08
|
|
}
|
|
|
|
return weight
|
|
}
|
|
|
|
type analyzePositionAggregate struct {
|
|
Label string
|
|
WeightedSum float64
|
|
WeightSum float64
|
|
Count int
|
|
PoseCount int
|
|
ContextCount int
|
|
ClipCount int
|
|
Start float64
|
|
End float64
|
|
Marker float64
|
|
BestScore float64
|
|
}
|
|
|
|
type analyzePositionCandidate struct {
|
|
Agg *analyzePositionAggregate
|
|
Score float64
|
|
}
|
|
|
|
type analyzePositionLedgerEntry struct {
|
|
Start float64
|
|
End float64
|
|
Center float64
|
|
Candidates []analyzePositionCandidate
|
|
Winner analyzePositionCandidate
|
|
Conflict bool
|
|
}
|
|
|
|
func analyzePositionEvidenceSpan(item analyzePositionEvidence) (float64, float64) {
|
|
start := item.Start
|
|
end := item.End
|
|
|
|
if start == 0 && end == 0 {
|
|
start = item.Time
|
|
end = item.Time
|
|
}
|
|
if start < 0 {
|
|
start = item.Time
|
|
}
|
|
if end < 0 {
|
|
end = item.Time
|
|
}
|
|
if start > end {
|
|
start, end = end, start
|
|
}
|
|
|
|
return start, end
|
|
}
|
|
|
|
func analyzePositionAggregateScore(agg *analyzePositionAggregate, requiredFrames int) (float64, bool) {
|
|
if agg == nil || agg.WeightSum <= 0 {
|
|
return 0, false
|
|
}
|
|
|
|
minCount := requiredFrames
|
|
if agg.ClipCount > 0 {
|
|
// Ein VideoMAE-Clip steht bereits für ein Zeitfenster und muss nicht
|
|
// zusätzlich noch mehrere Einzel-Frames derselben Position haben.
|
|
minCount = 1
|
|
} else if agg.PoseCount > 0 && agg.ContextCount == 0 {
|
|
// Pose-only braucht etwas mehr zeitliche Stabilität.
|
|
if minCount < 3 {
|
|
minCount = 3
|
|
}
|
|
}
|
|
|
|
if agg.Count < minCount {
|
|
return 0, false
|
|
}
|
|
|
|
avg := clamp01(agg.WeightedSum / agg.WeightSum)
|
|
stability := clamp01(float64(agg.Count) / math.Max(float64(requiredFrames), 3))
|
|
sourceBonus := 0.0
|
|
if agg.ClipCount > 0 && (agg.PoseCount > 0 || agg.ContextCount > 0) {
|
|
sourceBonus = 0.07
|
|
} else if agg.ClipCount > 0 {
|
|
sourceBonus = 0.06
|
|
} else if agg.PoseCount > 0 && agg.ContextCount > 0 {
|
|
sourceBonus = 0.04
|
|
}
|
|
|
|
score := clamp01(avg*(0.86+0.14*stability) + sourceBonus)
|
|
if agg.ClipCount == 0 && agg.PoseCount == 0 {
|
|
score = math.Min(score, trainingPositionContextMaxScore)
|
|
}
|
|
if agg.ClipCount == 0 && agg.ContextCount == 0 {
|
|
score = math.Min(score, trainingPoseStrongUnconfirmedMaxScore)
|
|
}
|
|
|
|
if score < analyzePositionClipMinScore {
|
|
return 0, false
|
|
}
|
|
|
|
return score, true
|
|
}
|
|
|
|
func analyzePositionCandidateConflict(best, other analyzePositionCandidate) bool {
|
|
if best.Agg == nil || other.Agg == nil {
|
|
return false
|
|
}
|
|
if best.Agg.Label == other.Agg.Label {
|
|
return false
|
|
}
|
|
|
|
// VideoMAE darf bei gleicher Groessenordnung als zeitliches Hauptsignal
|
|
// gewinnen. Wenn aber zwei Clip-Positionen fast gleich stark sind, ist
|
|
// das echter Konflikt und wird lieber nicht hart segmentiert.
|
|
if best.Agg.ClipCount > 0 {
|
|
if other.Agg.ClipCount > 0 {
|
|
return other.Score >= best.Score-analyzePositionConflictMargin
|
|
}
|
|
return false
|
|
}
|
|
|
|
if other.Agg.ClipCount > 0 {
|
|
return true
|
|
}
|
|
|
|
return other.Score >= best.Score-analyzePositionConflictMargin
|
|
}
|
|
|
|
func analyzePositionLedgerCenters(evidence []analyzePositionEvidence) []float64 {
|
|
centers := make([]float64, 0, len(evidence)*3)
|
|
|
|
for _, item := range evidence {
|
|
start, end := analyzePositionEvidenceSpan(item)
|
|
center := item.Time
|
|
if center < start || center > end {
|
|
center = (start + end) / 2
|
|
}
|
|
|
|
centers = append(centers, center)
|
|
|
|
if item.HasClip && end > start {
|
|
centers = append(centers, start, end)
|
|
}
|
|
}
|
|
|
|
sort.Float64s(centers)
|
|
|
|
out := centers[:0]
|
|
for _, center := range centers {
|
|
if len(out) == 0 || math.Abs(center-out[len(out)-1]) > 0.20 {
|
|
out = append(out, center)
|
|
}
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func analyzePositionCandidatesForWindow(
|
|
evidence []analyzePositionEvidence,
|
|
windowStart float64,
|
|
windowEnd float64,
|
|
requiredFrames int,
|
|
) []analyzePositionCandidate {
|
|
byLabel := map[string]*analyzePositionAggregate{}
|
|
|
|
for _, item := range evidence {
|
|
itemStart, itemEnd := analyzePositionEvidenceSpan(item)
|
|
if itemEnd < windowStart-0.001 || itemStart > windowEnd+0.001 {
|
|
continue
|
|
}
|
|
|
|
label := strings.ToLower(strings.TrimSpace(item.Label))
|
|
if !isAnalyzeTimelinePositionLabel(label) {
|
|
continue
|
|
}
|
|
|
|
score := clamp01(item.Score)
|
|
if score <= 0 {
|
|
continue
|
|
}
|
|
|
|
agg := byLabel[label]
|
|
if agg == nil {
|
|
agg = &analyzePositionAggregate{
|
|
Label: label,
|
|
Start: itemStart,
|
|
End: itemEnd,
|
|
Marker: item.Time,
|
|
}
|
|
byLabel[label] = agg
|
|
}
|
|
|
|
weight := analyzePositionEvidenceWeight(item)
|
|
agg.WeightedSum += score * weight
|
|
agg.WeightSum += weight
|
|
agg.Count++
|
|
if item.HasPose {
|
|
agg.PoseCount++
|
|
}
|
|
if item.HasContext {
|
|
agg.ContextCount++
|
|
}
|
|
if item.HasClip {
|
|
agg.ClipCount++
|
|
}
|
|
if itemStart < agg.Start {
|
|
agg.Start = itemStart
|
|
}
|
|
if itemEnd > agg.End {
|
|
agg.End = itemEnd
|
|
}
|
|
if score > agg.BestScore {
|
|
agg.BestScore = score
|
|
agg.Marker = item.Time
|
|
}
|
|
}
|
|
|
|
candidates := make([]analyzePositionCandidate, 0, len(byLabel))
|
|
for _, agg := range byLabel {
|
|
score, ok := analyzePositionAggregateScore(agg, requiredFrames)
|
|
if ok {
|
|
candidates = append(candidates, analyzePositionCandidate{
|
|
Agg: agg,
|
|
Score: score,
|
|
})
|
|
}
|
|
}
|
|
|
|
sort.SliceStable(candidates, func(i, j int) bool {
|
|
if candidates[i].Score != candidates[j].Score {
|
|
return candidates[i].Score > candidates[j].Score
|
|
}
|
|
if candidates[i].Agg.ClipCount != candidates[j].Agg.ClipCount {
|
|
return candidates[i].Agg.ClipCount > candidates[j].Agg.ClipCount
|
|
}
|
|
return candidates[i].Agg.Label < candidates[j].Agg.Label
|
|
})
|
|
|
|
return candidates
|
|
}
|
|
|
|
func stabilizeAnalyzePositionHits(in []analyzeHit, duration float64) []analyzeHit {
|
|
if len(in) == 0 {
|
|
return []analyzeHit{}
|
|
}
|
|
|
|
out := make([]analyzeHit, 0, len(in))
|
|
for _, hit := range in {
|
|
label := strings.ToLower(strings.TrimSpace(hit.Label))
|
|
position := strings.TrimPrefix(label, "position:")
|
|
if !isAnalyzeTimelinePositionLabel(position) {
|
|
continue
|
|
}
|
|
|
|
start := hit.Start
|
|
end := hit.End
|
|
if end < start {
|
|
start, end = end, start
|
|
}
|
|
if duration > 0 {
|
|
start = math.Max(0, math.Min(duration, start))
|
|
end = math.Max(0, math.Min(duration, end))
|
|
}
|
|
|
|
span := end - start
|
|
if duration >= 60 &&
|
|
span < analyzePositionMinStableSeconds &&
|
|
hit.Score < analyzePositionShortMaxScore {
|
|
continue
|
|
}
|
|
|
|
hit.Label = "position:" + position
|
|
hit.Start = start
|
|
hit.End = end
|
|
out = append(out, hit)
|
|
}
|
|
|
|
return mergeAnalyzeHits(out)
|
|
}
|
|
|
|
func buildAnalyzePositionLedger(
|
|
evidence []analyzePositionEvidence,
|
|
duration float64,
|
|
requiredFrames int,
|
|
) []analyzePositionLedgerEntry {
|
|
if len(evidence) == 0 {
|
|
return []analyzePositionLedgerEntry{}
|
|
}
|
|
|
|
halfWindow := analyzePositionClipWindowSeconds / 2
|
|
if halfWindow <= 0 {
|
|
halfWindow = math.Max(1, float64(analyzeVideoFrameIntervalSeconds))
|
|
}
|
|
|
|
centers := analyzePositionLedgerCenters(evidence)
|
|
ledger := make([]analyzePositionLedgerEntry, 0, len(centers))
|
|
|
|
for _, center := range centers {
|
|
windowStart := math.Max(0, center-halfWindow)
|
|
windowEnd := center + halfWindow
|
|
if duration > 0 {
|
|
windowEnd = math.Min(duration, windowEnd)
|
|
}
|
|
if windowEnd <= windowStart {
|
|
windowEnd = windowStart + math.Max(1, float64(analyzeVideoFrameIntervalSeconds))
|
|
if duration > 0 {
|
|
windowEnd = math.Min(duration, windowEnd)
|
|
}
|
|
}
|
|
|
|
entry := analyzePositionLedgerEntry{
|
|
Start: windowStart,
|
|
End: windowEnd,
|
|
Center: center,
|
|
}
|
|
entry.Candidates = analyzePositionCandidatesForWindow(
|
|
evidence,
|
|
windowStart,
|
|
windowEnd,
|
|
requiredFrames,
|
|
)
|
|
|
|
if len(entry.Candidates) > 0 {
|
|
if len(entry.Candidates) > 1 &&
|
|
analyzePositionCandidateConflict(entry.Candidates[0], entry.Candidates[1]) {
|
|
entry.Conflict = true
|
|
} else {
|
|
entry.Winner = entry.Candidates[0]
|
|
}
|
|
}
|
|
|
|
ledger = append(ledger, entry)
|
|
}
|
|
|
|
return ledger
|
|
}
|
|
|
|
func buildClipPositionHitsFromEvidence(
|
|
evidence []analyzePositionEvidence,
|
|
duration float64,
|
|
) []analyzeHit {
|
|
if len(evidence) == 0 {
|
|
return []analyzeHit{}
|
|
}
|
|
|
|
sort.SliceStable(evidence, func(i, j int) bool {
|
|
if evidence[i].Time != evidence[j].Time {
|
|
return evidence[i].Time < evidence[j].Time
|
|
}
|
|
return evidence[i].Label < evidence[j].Label
|
|
})
|
|
|
|
requiredFrames := analyzePositionClipMinFrames
|
|
if len(evidence) < requiredFrames {
|
|
requiredFrames = len(evidence)
|
|
}
|
|
if requiredFrames < 1 {
|
|
requiredFrames = 1
|
|
}
|
|
|
|
ledger := buildAnalyzePositionLedger(evidence, duration, requiredFrames)
|
|
hits := make([]analyzeHit, 0, len(ledger))
|
|
|
|
var cur analyzeHit
|
|
hasCur := false
|
|
flush := func() {
|
|
if !hasCur {
|
|
return
|
|
}
|
|
if cur.End <= cur.Start {
|
|
cur.End = cur.Start + math.Max(1, float64(analyzeVideoFrameIntervalSeconds))
|
|
if duration > 0 {
|
|
cur.End = math.Min(duration, cur.End)
|
|
}
|
|
}
|
|
cur.Time = (cur.Start + cur.End) / 2
|
|
hits = append(hits, cur)
|
|
hasCur = false
|
|
}
|
|
|
|
for _, entry := range ledger {
|
|
if entry.Conflict || entry.Winner.Agg == nil {
|
|
flush()
|
|
continue
|
|
}
|
|
|
|
label := "position:" + entry.Winner.Agg.Label
|
|
if hasCur &&
|
|
cur.Label == label &&
|
|
entry.Start <= cur.End+analyzeHitContinuationGapSeconds() {
|
|
if entry.End > cur.End {
|
|
cur.End = entry.End
|
|
}
|
|
if entry.Winner.Score > cur.Score {
|
|
cur.Score = entry.Winner.Score
|
|
}
|
|
continue
|
|
}
|
|
|
|
flush()
|
|
cur = analyzeHit{
|
|
Time: entry.Center,
|
|
Label: label,
|
|
Score: entry.Winner.Score,
|
|
Start: entry.Start,
|
|
End: entry.End,
|
|
}
|
|
hasCur = true
|
|
}
|
|
flush()
|
|
|
|
return stabilizeAnalyzePositionHits(mergeAnalyzeHits(hits), duration)
|
|
}
|
|
|
|
func analyzeVideoFromFrames(ctx context.Context, outPath string) ([]analyzeHit, int64, int, error) {
|
|
return analyzeVideoFromFramesForGoal(ctx, outPath)
|
|
}
|
|
|
|
const (
|
|
analyzeProgressTotal = 1000
|
|
analyzeProgressExtractEnd = 450
|
|
analyzeProgressInferenceStart = analyzeProgressExtractEnd
|
|
analyzeProgressInferenceEnd = 850
|
|
analyzeProgressVideoMAEStart = analyzeProgressInferenceEnd
|
|
analyzeProgressVideoMAEEnd = 950
|
|
analyzeProgressPersistStart = analyzeProgressVideoMAEEnd
|
|
analyzeProgressPersistEnd = 990
|
|
)
|
|
|
|
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,
|
|
) {
|
|
publishAnalyzePhaseProgress(
|
|
startedAtMs,
|
|
file,
|
|
"Analyse",
|
|
0,
|
|
analyzeProgressExtractEnd,
|
|
progress,
|
|
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))
|
|
|
|
publishAnalyzePhaseProgress(
|
|
startedAtMs,
|
|
file,
|
|
"Analyse",
|
|
analyzeProgressInferenceStart,
|
|
analyzeProgressInferenceEnd,
|
|
ratio,
|
|
message,
|
|
)
|
|
}
|
|
|
|
func publishAnalyzeVideoMAEProgress(startedAtMs int64, file string, currentClip int, totalClips int) {
|
|
if totalClips <= 0 {
|
|
totalClips = 1
|
|
}
|
|
|
|
ratio := float64(currentClip) / float64(totalClips)
|
|
publishAnalyzePhaseProgress(
|
|
startedAtMs,
|
|
file,
|
|
"Analyse",
|
|
analyzeProgressVideoMAEStart,
|
|
analyzeProgressVideoMAEEnd,
|
|
ratio,
|
|
"",
|
|
)
|
|
}
|
|
|
|
func publishAnalyzePersistProgress(startedAtMs int64, file string, progress float64, message string) {
|
|
publishAnalyzePhaseProgress(
|
|
startedAtMs,
|
|
file,
|
|
"Speichern",
|
|
analyzeProgressPersistStart,
|
|
analyzeProgressPersistEnd,
|
|
progress,
|
|
message,
|
|
)
|
|
}
|
|
|
|
func publishAnalyzePhaseProgress(
|
|
startedAtMs int64,
|
|
file string,
|
|
phase string,
|
|
rangeStart int,
|
|
rangeEnd int,
|
|
progress float64,
|
|
message string,
|
|
) {
|
|
progress = math.Max(0, math.Min(1, progress))
|
|
|
|
if rangeEnd < rangeStart {
|
|
rangeEnd = rangeStart
|
|
}
|
|
|
|
current := rangeStart + int(math.Round(progress*float64(rangeEnd-rangeStart)))
|
|
if current < 0 {
|
|
current = 0
|
|
}
|
|
if current > analyzeProgressTotal {
|
|
current = analyzeProgressTotal
|
|
}
|
|
|
|
message = strings.TrimSpace(message)
|
|
if message == "" || strings.EqualFold(message, "Analyse") || strings.Contains(message, "Frames werden extrahiert") {
|
|
phase = strings.TrimSpace(phase)
|
|
if phase == "" {
|
|
phase = "Analyse"
|
|
}
|
|
message = fmt.Sprintf("%s %d%%", phase, analyzeGlobalPercentFromCurrent(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, startedAtMs int64, totalFrames int, 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, startedAtMs, 0, err
|
|
}
|
|
|
|
durationSec, _ := durationSecondsForAnalyze(ctx, outPath)
|
|
if err := ctx.Err(); err != nil {
|
|
publishAnalysisError(startedAtMs, file, "Analyse abgebrochen", err)
|
|
return nil, startedAtMs, 0, err
|
|
}
|
|
|
|
if durationSec <= 0 {
|
|
err := appErrorf("videolänge konnte nicht bestimmt werden")
|
|
publishAnalysisError(startedAtMs, file, "Analyse fehlgeschlagen", err)
|
|
return nil, startedAtMs, 0, 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++ {
|
|
if extractPhase {
|
|
ratio := float64(p) / 50.0
|
|
if ratio < 0 {
|
|
ratio = 0
|
|
}
|
|
if ratio > 1 {
|
|
ratio = 1
|
|
}
|
|
|
|
publishAnalyzeExtractProgress(
|
|
startedAtMs,
|
|
file,
|
|
ratio,
|
|
"",
|
|
)
|
|
} else {
|
|
inferenceCurrent := current
|
|
inferenceTotal := total
|
|
|
|
if p >= 50 {
|
|
inferenceTotal = 50
|
|
inferenceCurrent = p - 50
|
|
}
|
|
|
|
publishAnalyzeInferenceProgress(
|
|
startedAtMs,
|
|
file,
|
|
inferenceCurrent,
|
|
inferenceTotal,
|
|
"",
|
|
)
|
|
}
|
|
}
|
|
|
|
*lastPercent = nextPercent
|
|
}
|
|
|
|
failCancelled := func() ([]analyzeHit, int64, int, error) {
|
|
err := ctx.Err()
|
|
if err == nil {
|
|
err = context.Canceled
|
|
}
|
|
|
|
publishAnalysisError(startedAtMs, file, "Analyse abgebrochen", err)
|
|
return nil, startedAtMs, 0, 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, startedAtMs, 0, err
|
|
}
|
|
|
|
if len(samples) == 0 {
|
|
err := appErrorf("keine frame-samples vorhanden")
|
|
publishAnalysisError(startedAtMs, file, "Keine Frames vorhanden", err)
|
|
return nil, startedAtMs, 0, 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,
|
|
"",
|
|
)
|
|
|
|
if ctx.Err() != nil {
|
|
return failCancelled()
|
|
}
|
|
|
|
batchOK := true
|
|
detectorOnly := false
|
|
positionEvidence := []analyzePositionEvidence{}
|
|
|
|
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,
|
|
"",
|
|
)
|
|
|
|
break
|
|
}
|
|
|
|
for i := 0; i < endIdx-startIdx; i++ {
|
|
if ctx.Err() != nil {
|
|
return failCancelled()
|
|
}
|
|
|
|
sample := samples[startIdx+i]
|
|
pred := predictions[i]
|
|
|
|
if !pred.ModelAvailable && predictionHasAnyAnalyzeSignal(pred) {
|
|
appLogf(
|
|
"⚠️ [analyze] prediction has signals but modelAvailable=false file=%q t=%.3f sex=%q body=%d objects=%d clothing=%d boxes=%d",
|
|
file,
|
|
sample.Time,
|
|
strings.TrimSpace(pred.SexPosition),
|
|
len(pred.BodyPartsPresent),
|
|
len(pred.ObjectsPresent),
|
|
len(pred.ClothingPresent),
|
|
len(pred.Boxes),
|
|
)
|
|
}
|
|
|
|
highlightHits = appendVideoFrameHighlightHitsFromPrediction(highlightHits, pred, sample.Time)
|
|
if item, ok := analyzePositionEvidenceFromPrediction(pred, sample.Time); ok {
|
|
positionEvidence = append(positionEvidence, item)
|
|
}
|
|
}
|
|
|
|
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,
|
|
)
|
|
}
|
|
|
|
highlightHits, positionEvidence = applyVideoMAEPositionClipsForAnalyze(
|
|
ctx,
|
|
file,
|
|
samples,
|
|
durationSec,
|
|
highlightHits,
|
|
positionEvidence,
|
|
func(current int, total int) {
|
|
publishAnalyzeVideoMAEProgress(startedAtMs, file, current, total)
|
|
},
|
|
)
|
|
if ctx.Err() != nil {
|
|
return failCancelled()
|
|
}
|
|
|
|
highlightHits = append(
|
|
highlightHits,
|
|
buildClipPositionHitsFromEvidence(positionEvidence, durationSec)...,
|
|
)
|
|
|
|
cleanHighlightHits := mergeAnalyzeHits(highlightHits)
|
|
|
|
return cleanHighlightHits, startedAtMs, total, 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 = appendVideoFrameHighlightHitsFromPrediction(highlightHits, pred, sample.Time)
|
|
if item, ok := analyzePositionEvidenceFromPrediction(pred, sample.Time); ok {
|
|
positionEvidence = append(positionEvidence, item)
|
|
}
|
|
|
|
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,
|
|
)
|
|
}
|
|
|
|
highlightHits, positionEvidence = applyVideoMAEPositionClipsForAnalyze(
|
|
ctx,
|
|
file,
|
|
samples,
|
|
durationSec,
|
|
highlightHits,
|
|
positionEvidence,
|
|
func(current int, total int) {
|
|
publishAnalyzeVideoMAEProgress(startedAtMs, file, current, total)
|
|
},
|
|
)
|
|
if ctx.Err() != nil {
|
|
return failCancelled()
|
|
}
|
|
|
|
highlightHits = append(
|
|
highlightHits,
|
|
buildClipPositionHitsFromEvidence(positionEvidence, durationSec)...,
|
|
)
|
|
|
|
cleanHighlightHits := mergeAnalyzeHits(highlightHits)
|
|
|
|
return cleanHighlightHits, startedAtMs, total, 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 isAnalyzeTimelinePositionLabel(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 !isAnalyzeTimelinePositionLabel(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 isNoSexPositionLabel(label) {
|
|
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
|
|
Time float64
|
|
Score float64
|
|
}
|
|
|
|
func isAllowedAnalyzeSegmentLabel(label string) bool {
|
|
label = strings.ToLower(strings.TrimSpace(label))
|
|
if isNoSexPositionLabel(label) {
|
|
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 isNoSexPositionLabel(raw) {
|
|
return false
|
|
}
|
|
if strings.HasPrefix(label, "position:") && !isAnalyzeTimelinePositionLabel(label) {
|
|
return false
|
|
}
|
|
if isAnalyzeContextOnlyPositionLabel(raw) {
|
|
return false
|
|
}
|
|
|
|
return shouldAutoSelectAnalyzeHit(raw) || isAnalyzeTimelinePositionLabel(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
|
|
}
|
|
|
|
markerTime := hit.Time
|
|
if markerTime < 0 {
|
|
markerTime = (start + end) / 2
|
|
}
|
|
if duration > 0 {
|
|
markerTime = math.Max(0, math.Min(markerTime, duration))
|
|
}
|
|
|
|
byLabel[key] = append(byLabel[key], analyzeLabelSegmentPoint{
|
|
Label: label,
|
|
Start: start,
|
|
End: end,
|
|
Time: markerTime,
|
|
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
|
|
curMarkerTime := points[0].Time
|
|
curBestScore := points[0].Score
|
|
|
|
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++
|
|
if n.Score > curBestScore {
|
|
curBestScore = n.Score
|
|
curMarkerTime = n.Time
|
|
}
|
|
continue
|
|
}
|
|
|
|
segment := makeLabelContinuitySegment(
|
|
curLabel,
|
|
curStartMarker,
|
|
curEndMarker,
|
|
curMarkerTime,
|
|
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
|
|
curMarkerTime = n.Time
|
|
curBestScore = n.Score
|
|
}
|
|
|
|
segment := makeLabelContinuitySegment(
|
|
curLabel,
|
|
curStartMarker,
|
|
curEndMarker,
|
|
curMarkerTime,
|
|
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,
|
|
markerTime 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)
|
|
}
|
|
|
|
if markerTime < 0 {
|
|
markerTime = (start + end) / 2
|
|
}
|
|
if duration > 0 {
|
|
markerTime = math.Max(0, math.Min(markerTime, duration))
|
|
}
|
|
|
|
return aiSegmentMeta{
|
|
Label: label,
|
|
StartSeconds: start,
|
|
EndSeconds: end,
|
|
DurationSeconds: math.Max(0, end-start),
|
|
Score: score,
|
|
AutoSelected: true,
|
|
Position: segmentPositionFromAnalyzeLabel(label),
|
|
Tags: segmentTagsFromAnalyzeLabel(label),
|
|
MarkerSeconds: markerTime,
|
|
PreviewSeconds: markerTime,
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|