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