updated ai detection

This commit is contained in:
Linrador 2026-03-14 14:28:33 +01:00
parent 9f2dd0ff3b
commit e7a13652f5
34 changed files with 4301 additions and 406 deletions

819
backend/analyze.go Normal file
View File

@ -0,0 +1,819 @@
// backend\analyze.go
package main
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"image"
"image/draw"
"image/jpeg"
"math"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"golang.org/x/image/webp"
)
type analyzeVideoReq struct {
JobID string `json:"jobId"`
Output string `json:"output"`
Mode string `json:"mode"` // "sprite" | "video"
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"`
Error string `json:"error,omitempty"`
}
type spriteFrameCandidate struct {
Index int
Time float64
}
const (
nsfwThresholdModerate = 0.35
nsfwThresholdStrong = 0.60
)
var autoSelectedAILabels = map[string]struct{}{
"anus_exposed": {},
"female_genitalia_exposed": {},
"male_genitalia_exposed": {},
"female_breast_exposed": {},
"buttocks_exposed": {},
}
var nsfwIgnoredLabels = map[string]struct{}{
"face_female": {},
"face_male": {},
"belly_covered": {},
"armpits_covered": {},
"anus_covered": {},
}
func shouldAutoSelectAnalyzeHit(label string) bool {
label = strings.ToLower(strings.TrimSpace(label))
_, ok := autoSelectedAILabels[label]
return ok
}
func isIgnoredNSFWLabel(label string) bool {
label = strings.ToLower(strings.TrimSpace(label))
_, ok := nsfwIgnoredLabels[label]
return ok
}
func extractSpriteFrames(spritePath string, ps previewSpriteMetaFileInfo) ([]image.Image, error) {
f, err := os.Open(spritePath)
if err != nil {
return nil, err
}
defer f.Close()
img, err := webp.Decode(f)
if err != nil {
return nil, err
}
b := img.Bounds()
if ps.Cols <= 0 || ps.Rows <= 0 {
return nil, fmt.Errorf("sprite cols/rows fehlen")
}
cellW := b.Dx() / ps.Cols
cellH := b.Dy() / ps.Rows
if cellW <= 0 || cellH <= 0 {
return nil, fmt.Errorf("ungültige sprite cell size")
}
count := ps.Count
if count <= 0 {
count = ps.Cols * ps.Rows
}
out := make([]image.Image, 0, count)
for i := 0; i < count; i++ {
col := i % ps.Cols
row := i / ps.Cols
if row >= ps.Rows {
break
}
srcRect := image.Rect(
b.Min.X+col*cellW,
b.Min.Y+row*cellH,
b.Min.X+(col+1)*cellW,
b.Min.Y+(row+1)*cellH,
)
dst := image.NewRGBA(image.Rect(0, 0, cellW, cellH))
draw.Draw(dst, dst.Bounds(), img, srcRect.Min, draw.Src)
out = append(out, dst)
}
return out, nil
}
func encodeImageJPEGBase64(img image.Image) (string, error) {
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(buf.Bytes()), nil
}
func classifyFrameNSFW(ctx context.Context, img image.Image) (*NsfwImageResponse, error) {
_ = ctx
b64, err := encodeImageJPEGBase64(img)
if err != nil {
return nil, err
}
results, err := detectNSFWFromBase64(b64)
if err != nil {
return nil, err
}
return &NsfwImageResponse{
Ok: true,
Results: results,
}, nil
}
func nsfwLabelPriority(label string) int {
label = strings.ToLower(strings.TrimSpace(label))
switch label {
case
"anus_exposed",
"female_genitalia_exposed",
"male_genitalia_exposed",
"female_breast_exposed",
"buttocks_exposed":
return 300
case
"female_genitalia_covered",
"male_genitalia_covered",
"female_breast_covered",
"buttocks_covered",
"male_breast_exposed",
"male_breast_covered":
return 200
case
"belly_exposed",
"armpits_exposed",
"feet_exposed",
"feet_covered":
return 100
case
"face_female",
"face_male",
"belly_covered",
"armpits_covered",
"anus_covered":
return 10
default:
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",
"-q:v", "2",
"-y",
tmpPath,
)
if out, err := cmd.CombinedOutput(); err != nil {
return nil, fmt.Errorf("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 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 = strings.ToLower(strings.TrimSpace(req.Mode))
req.Goal = strings.ToLower(strings.TrimSpace(req.Goal))
if req.Mode == "" {
req.Mode = "sprite"
}
if req.Goal == "" {
req.Goal = "highlights"
}
switch req.Mode {
case "sprite", "video":
default:
http.Error(w, "mode muss 'sprite' oder 'video' sein", http.StatusBadRequest)
return
}
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(), 45*time.Second)
defer cancel()
var hits []analyzeHit
switch req.Mode {
case "sprite":
hits, err = analyzeVideoFromSprite(ctx, outPath, req.Goal)
case "video":
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 := buildSegmentsFromAnalyzeHits(hits, durationSec)
ai := &aiAnalysisMeta{
Goal: req.Goal,
Mode: req.Mode,
Hits: hits,
Segments: segments,
AnalyzedAtUnix: time.Now().Unix(),
}
if err := writeVideoAIForFile(ctx, outPath, "", ai); err != nil {
fmt.Println("⚠️ writeVideoAIForFile:", err)
}
respondJSON(w, analyzeVideoResp{
OK: true,
Mode: req.Mode,
Goal: req.Goal,
Hits: hits,
Segments: segments,
})
}
func analyzeVideoFromSprite(ctx context.Context, outPath, goal string) ([]analyzeHit, error) {
id := strings.TrimSpace(videoIDFromOutputPath(outPath))
if id == "" {
return nil, fmt.Errorf("konnte keine video-id aus output ableiten")
}
metaPath, err := generatedMetaFile(id)
if err != nil || strings.TrimSpace(metaPath) == "" {
return nil, fmt.Errorf("meta.json nicht gefunden")
}
ps, ok := readPreviewSpriteMetaFromMetaFile(metaPath)
if !ok {
return nil, fmt.Errorf("previewSprite meta fehlt")
}
if ps.Count <= 0 {
return nil, fmt.Errorf("previewSprite count fehlt")
}
spritePath := filepath.Join(filepath.Dir(metaPath), "preview-sprite.webp")
if fi, err := os.Stat(spritePath); err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
return nil, fmt.Errorf("preview-sprite.webp nicht gefunden")
}
durationSec, _ := durationSecondsForAnalyze(ctx, outPath)
candidates := buildSpriteFrameCandidates(ps.Count, ps.StepSeconds, durationSec)
if len(candidates) == 0 {
return nil, fmt.Errorf("keine sprite-kandidaten vorhanden")
}
// ----------------------------------------------------------------
// HIER ist der Hook für echte AI/Vision-Analyse.
//
// Aktuell:
// - erzeugen wir brauchbare Zeitpunkte aus den Preview-Frames
// - gruppieren sie zu Treffern
//
// Später kannst du hier:
// - spritePath + frame indices an ein Vision-Modell geben
// - pro Frame Labels / Scores zurückbekommen
// - daraus Trefferbereiche bilden
// ----------------------------------------------------------------
frameHits, err := analyzeSpriteCandidatesWithAI(ctx, spritePath, ps, candidates, goal)
if err != nil {
return nil, err
}
return mergeAnalyzeHits(frameHits), nil
}
func nsfwThresholdForLabel(label string) float64 {
label = strings.ToLower(strings.TrimSpace(label))
switch label {
case
"anus_exposed",
"female_genitalia_exposed",
"male_genitalia_exposed",
"female_breast_exposed",
"buttocks_exposed":
return nsfwThresholdStrong
case
"female_breast_covered",
"male_breast_exposed",
"male_breast_covered",
"buttocks_covered",
"female_genitalia_covered",
"male_genitalia_covered",
"belly_exposed",
"armpits_exposed",
"feet_exposed",
"feet_covered":
return nsfwThresholdModerate
default:
return 0.50
}
}
func analyzeVideoFromFrames(ctx context.Context, outPath, goal string) ([]analyzeHit, error) {
if goal != "nsfw" {
return []analyzeHit{}, nil
}
durationSec, _ := durationSecondsForAnalyze(ctx, outPath)
if durationSec <= 0 {
return nil, fmt.Errorf("videolänge konnte nicht bestimmt werden")
}
sampleTimes := buildVideoSampleTimes(durationSec, 24)
if len(sampleTimes) == 0 {
return nil, fmt.Errorf("keine frame-samples berechnet")
}
hits := make([]analyzeHit, 0, len(sampleTimes))
for _, t := range sampleTimes {
img, err := extractVideoFrameAt(ctx, outPath, t)
if err != nil {
return nil, fmt.Errorf("frame extraktion bei %.3fs fehlgeschlagen: %w", t, err)
}
res, err := classifyFrameNSFW(ctx, img)
if err != nil {
continue
}
bestLabel, bestScore := pickBestNSFWResult(res.Results)
if bestLabel == "" {
continue
}
threshold := nsfwThresholdForLabel(bestLabel)
if bestScore < threshold {
continue
}
hits = append(hits, analyzeHit{
Time: t,
Label: bestLabel,
Score: bestScore,
Start: math.Max(0, t-4),
End: t + 4,
})
}
return mergeAnalyzeHits(hits), nil
}
func analyzeSpriteCandidatesWithAI(
ctx context.Context,
spritePath string,
ps previewSpriteMetaFileInfo,
candidates []spriteFrameCandidate,
goal string,
) ([]analyzeHit, error) {
if goal != "nsfw" {
return []analyzeHit{}, nil
}
frames, err := extractSpriteFrames(spritePath, ps)
if err != nil {
return nil, fmt.Errorf("sprite frames extrahieren fehlgeschlagen: %w", err)
}
hits := make([]analyzeHit, 0, len(candidates))
for _, c := range candidates {
if c.Index < 0 || c.Index >= len(frames) {
continue
}
res, err := classifyFrameNSFW(ctx, frames[c.Index])
if err != nil {
continue
}
bestLabel, bestScore := pickBestNSFWResult(res.Results)
if bestLabel == "" {
continue
}
threshold := nsfwThresholdForLabel(bestLabel)
if bestScore < threshold {
continue
}
span := inferredSpanSeconds(ps.StepSeconds, 8)
start := math.Max(0, c.Time-(span/2))
end := c.Time + (span / 2)
hits = append(hits, analyzeHit{
Time: c.Time,
Label: bestLabel,
Score: bestScore,
Start: start,
End: end,
})
}
return hits, nil
}
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]
// Nur direkt aufeinanderfolgende Treffer mit gleichem Label zusammenfassen
const mergeGapSeconds = 1.0
sameLabel := strings.EqualFold(cur.Label, n.Label)
touchesOrNear := n.Start <= cur.End+mergeGapSeconds
if sameLabel && touchesOrNear {
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 buildSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegmentMeta {
if len(hits) == 0 || duration <= 0 {
return []aiSegmentMeta{}
}
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))
if end <= start {
continue
}
out = append(out, aiSegmentMeta{
Label: strings.ToLower(strings.TrimSpace(hit.Label)),
StartSeconds: start,
EndSeconds: end,
DurationSeconds: end - start,
Score: hit.Score,
AutoSelected: true,
})
}
if len(out) == 0 {
return []aiSegmentMeta{}
}
sort.Slice(out, func(i, j int) bool {
if out[i].StartSeconds != out[j].StartSeconds {
return out[i].StartSeconds < out[j].StartSeconds
}
if out[i].EndSeconds != out[j].EndSeconds {
return out[i].EndSeconds < out[j].EndSeconds
}
return out[i].Label < out[j].Label
})
merged := make([]aiSegmentMeta, 0, len(out))
cur := out[0]
for i := 1; i < len(out); i++ {
n := out[i]
const mergeGapSeconds = 15.0
sameLabel := strings.EqualFold(cur.Label, n.Label)
nearEnough := n.StartSeconds <= cur.EndSeconds+mergeGapSeconds
if sameLabel && nearEnough {
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 merged
}
func buildSpriteFrameCandidates(count int, stepSeconds, durationSec float64) []spriteFrameCandidate {
if count <= 0 {
return nil
}
out := make([]spriteFrameCandidate, 0, count)
stepLooksUsable := false
if stepSeconds > 0 && durationSec > 0 {
coverage := stepSeconds * math.Max(1, float64(count-1))
stepLooksUsable = coverage >= durationSec*0.7 && coverage <= durationSec*1.3
}
for i := 0; i < count; i++ {
var t float64
if stepLooksUsable {
t = float64(i) * stepSeconds
} else if durationSec > 0 && count > 1 {
t = (float64(i) / float64(count-1)) * durationSec
} else if stepSeconds > 0 {
t = float64(i) * stepSeconds
} else {
t = float64(i)
}
out = append(out, spriteFrameCandidate{
Index: i,
Time: t,
})
}
return out
}
func buildVideoSampleTimes(durationSec float64, sampleCount int) []float64 {
if durationSec <= 0 || sampleCount <= 0 {
return nil
}
if sampleCount == 1 {
return []float64{0}
}
out := make([]float64, 0, sampleCount)
for i := 0; i < sampleCount; i++ {
ratio := float64(i) / float64(sampleCount-1)
t := ratio * durationSec
out = append(out, t)
}
return out
}
func inferredSpanSeconds(stepSeconds float64, fallback float64) float64 {
if stepSeconds > 0 {
return math.Max(2, stepSeconds*1.5)
}
return fallback
}
func durationSecondsForAnalyze(ctx context.Context, outPath string) (float64, error) {
ctx2, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
return durationSecondsCached(ctx2, outPath)
}
func videoIDFromOutputPath(outPath string) string {
base := filepath.Base(strings.TrimSpace(outPath))
if base == "" {
return ""
}
stem := strings.TrimSuffix(base, filepath.Ext(base))
stem = stripHotPrefix(stem)
return strings.TrimSpace(stem)
}

Binary file not shown.

Binary file not shown.

View File

@ -1,8 +1,9 @@
// backend/generate.go
// backend\assets_generate.go
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
@ -267,8 +268,18 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi)
out.MetaOK = meta.ok
// Wenn alles da ist: skipped
if thumbBefore && previewBefore && spriteBefore && meta.ok {
// Wenn alles da ist: als skipped markieren,
// aber NICHT sofort returnen, damit meta.json
// (previewClips / previewSprite) trotzdem sauber geschrieben wird.
metaHasSprite := false
if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil && oldMeta.PreviewSprite != nil {
metaHasSprite = true
}
metaHasAI := hasAIResultsForOutput(videoPath)
// Nur dann wirklich komplett "fertig", wenn auch AI vorhanden ist
if thumbBefore && previewBefore && spriteBefore && meta.ok && metaHasSprite && metaHasAI {
out.Skipped = true
progress(1)
return out, nil
@ -412,22 +423,13 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
}
// ----------------
// Preview Sprite (stashapp-like scrubber)
// Preview Sprite (festes Layout)
// ----------------
var spriteMeta *previewSpriteMeta
if spriteBefore {
// Meta trotzdem vorbereiten (für JSON)
if meta.durSec > 0 {
stepSec := 5.0
count := int(math.Floor(meta.durSec/stepSec)) + 1
if count < 1 {
count = 1
}
if count > 200 {
count = 200 // Schutz
}
cols, rows := chooseSpriteGrid(count)
cols, rows, count, cellW, cellH := fixedPreviewSpriteLayout()
stepSec := previewSpriteStepSeconds(meta.durSec)
spriteMeta = &previewSpriteMeta{
Path: fmt.Sprintf("/api/preview-sprite/%s", id),
@ -435,15 +437,17 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
Cols: cols,
Rows: rows,
StepSeconds: stepSec,
CellWidth: cellW,
CellHeight: cellH,
}
}
} else {
if !spriteBefore {
func() {
if sourceInputInvalid {
return
}
// nur sinnvoll wenn wir Dauer kennen
if !(meta.durSec > 0) {
return
}
@ -456,37 +460,24 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
}
defer genSem.Release()
stepSec := 5.0
count := int(math.Floor(meta.durSec/stepSec)) + 1
if count < 1 {
count = 1
}
if count > 200 {
count = 200 // Schutz gegen riesige Sprites
}
cols, rows, _, cellW, cellH := fixedPreviewSpriteLayout()
stepSec := previewSpriteStepSeconds(meta.durSec)
cols, rows := chooseSpriteGrid(count)
// Zellgröße (16:9) für Gallery-Thumbs
cellW := 160
cellH := 90
if err := generatePreviewSpriteWebP(genCtx, videoPath, spritePath, cols, rows, stepSec, cellW, cellH); err != nil {
if err := generatePreviewSpriteWebP(
genCtx,
videoPath,
spritePath,
cols,
rows,
stepSec,
cellW,
cellH,
); err != nil {
fmt.Println("⚠️ preview sprite:", err)
return
}
out.SpriteGenerated = true
spriteMeta = &previewSpriteMeta{
Path: fmt.Sprintf("/api/preview-sprite/%s", id),
Count: count,
Cols: cols,
Rows: rows,
StepSeconds: stepSec,
CellWidth: cellW,
CellHeight: cellH,
}
}()
}
@ -524,3 +515,120 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
progress(1)
return out, nil
}
func hasAIResultsForOutput(outPath string) bool {
outPath = strings.TrimSpace(outPath)
if outPath == "" {
return false
}
id := assetIDFromVideoPath(outPath)
if id == "" {
return false
}
metaPath, err := generatedMetaFile(id)
if err != nil || strings.TrimSpace(metaPath) == "" {
return false
}
b, err := os.ReadFile(metaPath)
if err != nil || len(b) == 0 {
return false
}
var m map[string]any
dec := json.NewDecoder(strings.NewReader(string(b)))
dec.UseNumber()
if err := dec.Decode(&m); err != nil {
return false
}
aiMap, ok := m["ai"].(map[string]any)
if !ok || aiMap == nil {
return false
}
rawHits, hasHits := aiMap["hits"].([]any)
rawSegs, hasSegs := aiMap["segments"].([]any)
return (hasHits && len(rawHits) > 0) || (hasSegs && len(rawSegs) > 0)
}
type PrepareSplitResult struct {
AssetsReady bool
AnalyzeReady bool
SpriteReady bool
MetaOK bool
}
func prepareVideoForSplit(ctx context.Context, videoPath, sourceURL, goal string) (PrepareSplitResult, error) {
var out PrepareSplitResult
videoPath = strings.TrimSpace(videoPath)
if videoPath == "" {
return out, fmt.Errorf("empty videoPath")
}
fi, err := os.Stat(videoPath)
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
return out, fmt.Errorf("video datei nicht gefunden")
}
// 1) Assets sicherstellen (preview.webp / preview.mp4 / preview-sprite.webp / meta.json)
assetsRes, err := ensureAssetsForVideoDetailed(ctx, videoPath, sourceURL, nil)
if err != nil {
return out, err
}
_ = assetsRes
id := assetIDFromVideoPath(videoPath)
if id == "" {
return out, fmt.Errorf("konnte asset id nicht ableiten")
}
ps := previewSpriteTruthForID(id)
out.SpriteReady = ps.Exists
out.AssetsReady = ps.Exists
out.MetaOK = true
// 2) AI-Segmente prüfen
if hasAIResultsForOutput(videoPath) {
out.AnalyzeReady = true
return out, nil
}
goal = strings.ToLower(strings.TrimSpace(goal))
if goal == "" {
goal = "nsfw"
}
// 3) AI nur ausführen, wenn Sprite vorhanden ist
if !ps.Exists {
return out, nil
}
durationSec, _ := durationSecondsForAnalyze(ctx, videoPath)
hits, aerr := analyzeVideoFromSprite(ctx, videoPath, goal)
if aerr != nil {
return out, nil
}
segments := buildSegmentsFromAnalyzeHits(hits, durationSec)
ai := &aiAnalysisMeta{
Goal: goal,
Mode: "sprite",
Hits: hits,
Segments: segments,
AnalyzedAtUnix: time.Now().Unix(),
}
if werr := writeVideoAIForFile(ctx, videoPath, sourceURL, ai); werr != nil {
return out, nil
}
out.AnalyzeReady = len(segments) > 0
return out, nil
}

View File

@ -5,53 +5,35 @@ package main
import (
"context"
"fmt"
"math"
"os"
"os/exec"
"path/filepath"
"strings"
)
// chooseSpriteGrid wählt ein sinnvolles cols/rows-Grid für count Frames.
// Ziel: wenig leere Zellen + eher horizontales Layout (passt gut zu 16:9 Cells).
func chooseSpriteGrid(count int) (cols, rows int) {
if count <= 0 {
return 1, 1
}
if count == 1 {
return 1, 1
const (
previewSpriteCols = 10
previewSpriteRows = 8
previewSpriteFrameCount = previewSpriteCols * previewSpriteRows
previewSpriteCellW = 160
previewSpriteCellH = 90
)
func fixedPreviewSpriteLayout() (cols, rows, count, cellW, cellH int) {
return previewSpriteCols, previewSpriteRows, previewSpriteFrameCount, previewSpriteCellW, previewSpriteCellH
}
targetRatio := 16.0 / 9.0 // wir bevorzugen horizontale Spritesheets
bestCols, bestRows := 1, count
bestWaste := math.MaxInt
bestRatioScore := math.MaxFloat64
for c := 1; c <= count; c++ {
r := int(math.Ceil(float64(count) / float64(c)))
if r <= 0 {
r = 1
func previewSpriteStepSeconds(durationSec float64) float64 {
if durationSec <= 0 {
return 5
}
waste := c*r - count
ratio := float64(c) / float64(r)
ratioScore := math.Abs(ratio - targetRatio)
// Priorität:
// 1) weniger leere Zellen
// 2) näher an targetRatio
// 3) bei Gleichstand weniger Rows (lieber breiter als hoch)
if waste < bestWaste ||
(waste == bestWaste && ratioScore < bestRatioScore) ||
(waste == bestWaste && ratioScore == bestRatioScore && r < bestRows) {
bestWaste = waste
bestRatioScore = ratioScore
bestCols = c
bestRows = r
}
step := durationSec / float64(previewSpriteFrameCount)
if step < 0.5 {
step = 0.5
}
return bestCols, bestRows
return step
}
// generatePreviewSpriteWebP erzeugt ein statisches WebP-Spritesheet aus einem Video.
@ -85,23 +67,22 @@ func generatePreviewSpriteWebP(
return fmt.Errorf("generatePreviewSpriteWebP: invalid cell size %dx%d", cellW, cellH)
}
// Zielordner sicherstellen
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
return fmt.Errorf("mkdir sprite dir: %w", err)
}
// Temp-Datei im gleichen Verzeichnis für atomaren Replace.
// Wichtig: ffmpeg erkennt das Output-Format über die Endung.
// Deshalb muss .webp am Ende stehen (nicht "...webp.tmp").
ext := filepath.Ext(outPath)
if ext == "" {
ext = ".webp"
}
base := strings.TrimSuffix(outPath, ext)
tmpPath := base + ".tmp" + ext // z.B. preview-sprite.tmp.webp
tmpPath := base + ".tmp" + ext
ffmpegPath := strings.TrimSpace(getSettings().FFmpegPath)
if ffmpegPath == "" {
ffmpegPath = "ffmpeg"
}
// fps=1/stepSec nimmt alle stepSec Sekunden einen Frame
// scale+pad erzwingt feste Zellgröße (wichtig für korrektes background-positioning im Frontend)
vf := fmt.Sprintf(
"fps=1/%g,scale=%d:%d:force_original_aspect_ratio=decrease:flags=lanczos,"+
"pad=%d:%d:(ow-iw)/2:(oh-ih)/2:black,tile=%dx%d:margin=0:padding=0",
@ -111,10 +92,9 @@ func generatePreviewSpriteWebP(
cols, rows,
)
// Statisches WebP-Spritesheet
cmd := exec.CommandContext(
ctx,
"ffmpeg",
ffmpegPath,
"-hide_banner",
"-loglevel", "error",
"-y",
@ -122,6 +102,7 @@ func generatePreviewSpriteWebP(
"-an",
"-sn",
"-vf", vf,
"-vsync", "vfr",
"-frames:v", "1",
"-c:v", "libwebp",
"-lossless", "0",
@ -149,7 +130,6 @@ func generatePreviewSpriteWebP(
return fmt.Errorf("sprite temp file invalid/empty")
}
// Windows: Ziel vorher löschen, damit Rename klappt
_ = os.Remove(outPath)
if err := os.Rename(tmpPath, outPath); err != nil {
_ = os.Remove(tmpPath)

View File

@ -182,39 +182,41 @@ func inFlightBytesForJob(j *RecordJob) uint64 {
return sizeOfPathBestEffort(j.Output)
}
func minRelevantInFlightBytes() uint64 {
s := getSettings()
// Nur wenn Auto-Delete kleine Downloads aktiv ist und eine sinnvolle Schwelle gesetzt ist
if !s.AutoDeleteSmallDownloads {
return 0
}
mb := s.AutoDeleteSmallDownloadsBelowMB
if mb <= 0 {
return 0
}
// MB -> Bytes (MiB passend zum restlichen Code mit GiB)
return uint64(mb) * 1024 * 1024
}
const giB = uint64(1024 * 1024 * 1024)
// computeDiskThresholds:
// Pause = ceil( (2 * inFlightBytes) / GiB )
// Pause = max(lowDiskPauseBelowGB, ceil(relevantInFlightBytes / GiB))
// Resume = Pause + 3 GB (Hysterese)
// Wenn inFlight==0 => Pause/Resume = 0
func computeDiskThresholds() (pauseGB int, resumeGB int, inFlight uint64, pauseNeed uint64, resumeNeed uint64) {
inFlight = sumInFlightBytes()
if inFlight == 0 {
return 0, 0, 0, 0, 0
//
// relevantInFlightBytes = Summe aller laufenden Downloads,
// deren aktuelle Dateigröße über AutoDeleteSmallDownloadsBelowMB liegt.
//
// Damit greift immer mindestens die konfigurierte Mindestschwelle,
// zusätzlich aber auch eine dynamische Schwelle basierend auf den
// "relevanten" Downloads, die nicht automatisch gelöscht würden.
func computeDiskThresholds() (pauseGB int, resumeGB int, relevantInFlight uint64, pauseNeed uint64, resumeNeed uint64) {
s := getSettings()
relevantInFlight = sumInFlightBytesAboveAutoDeleteThreshold()
configPauseGB := s.LowDiskPauseBelowGB
if configPauseGB <= 0 {
configPauseGB = 5
}
need := inFlight * 2
pauseGB = int((need + giB - 1) / giB) // ceil
dynamicPauseGB := 0
if relevantInFlight > 0 {
dynamicPauseGB = int((relevantInFlight + giB - 1) / giB) // ceil
}
// größere Schwelle nehmen:
// - manuelle Mindestreserve
// - dynamische Reserve für relevante laufende Downloads
pauseGB = dynamicPauseGB
if pauseGB <= 0 {
pauseGB = configPauseGB
}
// Safety cap (nur zur Sicherheit, falls irgendwas eskaliert)
if pauseGB > 10_000 {
pauseGB = 10_000
}
@ -233,7 +235,6 @@ func computeDiskThresholds() (pauseGB int, resumeGB int, inFlight uint64, pauseN
// Idee: Für TS->MP4 Peak brauchst du grob nochmal die Größe der aktuellen Datei als Reserve.
func sumInFlightBytes() uint64 {
var sum uint64
minKeepBytes := minRelevantInFlightBytes()
jobsMu.Lock()
defer jobsMu.Unlock()
@ -246,19 +247,47 @@ func sumInFlightBytes() uint64 {
continue
}
b := inFlightBytesForJob(j)
sum += inFlightBytesForJob(j)
}
// ✅ Nur "relevante" Dateien berücksichtigen:
// Wenn Auto-Delete kleine Downloads aktiv ist, zählen wir nur Jobs,
// deren aktuelle Dateigröße bereits über der Schwelle liegt.
//
// Hinweis: Ein Job kann später noch über die Schwelle wachsen.
// Diese Logik ist bewusst "weniger konservativ", so wie gewünscht.
if minKeepBytes > 0 && b > 0 && b < minKeepBytes {
return sum
}
func sumInFlightBytesAboveAutoDeleteThreshold() uint64 {
s := getSettings()
thresholdMB := s.AutoDeleteSmallDownloadsBelowMB
if thresholdMB < 0 {
thresholdMB = 0
}
thresholdBytes := uint64(thresholdMB) * 1024 * 1024
var sum uint64
jobsMu.Lock()
defer jobsMu.Unlock()
for _, j := range jobs {
if j == nil {
continue
}
if j.Status != JobRunning {
continue
}
sum += b
size := inFlightBytesForJob(j)
if size == 0 {
continue
}
// Nur Downloads berücksichtigen, die über der Auto-Delete-Grenze liegen.
// Kleine Dateien würden später ohnehin automatisch entfernt.
if size <= thresholdBytes {
continue
}
sum += size
}
return sum
@ -294,11 +323,11 @@ func startDiskSpaceGuard() {
}
free := u.Free
// ✅ Dynamische Schwellen:
// Pause = ceil((2 * inFlight) / GiB)
// ✅ Schwellen:
// Pause = max(config lowDiskPauseBelowGB, ceil((2 * inFlight) / GiB))
// Resume = Pause + 3 GB
// pauseNeed/resumeNeed sind die benötigten freien Bytes
pauseGB, resumeGB, inFlight, pauseNeed, resumeNeed := computeDiskThresholds()
pauseGB, resumeGB, relevantInFlight, pauseNeed, resumeNeed := computeDiskThresholds()
// ✅ diskEmergency NICHT sticky behalten.
// Stattdessen dynamisch mit Hysterese setzen/löschen:
@ -310,20 +339,6 @@ func startDiskSpaceGuard() {
wasEmergency := atomic.LoadInt32(&diskEmergency) == 1
// Wenn aktuell nichts läuft, brauchen wir keine Reservierung.
// Dann diskEmergency freigeben (falls gesetzt), damit Autostart wieder möglich ist.
// (User-Pause bleibt davon unberührt.)
if inFlight == 0 {
if wasEmergency {
atomic.StoreInt32(&diskEmergency, 0)
broadcastAutostartPaused()
fmt.Printf("✅ [disk] Emergency cleared (no in-flight jobs). free=%s (%dB) path=%s\n",
formatBytesSI(u64ToI64(free)), free, dir,
)
}
continue
}
isLowForPause := free < pauseNeed
isHighEnoughForResume := free >= resumeNeed
@ -337,11 +352,11 @@ func startDiskSpaceGuard() {
broadcastAutostartPaused()
fmt.Printf(
"🛑 [disk] Low space: free=%s (%dB) (< %s, %dB, pause=%dGB resume=%dGB, inFlight=%s, %dB) -> stop jobs + block autostart via diskEmergency (path=%s)\n",
"🛑 [disk] Low space: free=%s (%dB) (< %s, %dB, pause=%dGB resume=%dGB, relevantInFlight=%s, %dB) -> stop jobs + block autostart via diskEmergency (path=%s)\n",
formatBytesSI(u64ToI64(free)), free,
formatBytesSI(u64ToI64(pauseNeed)), pauseNeed,
pauseGB, resumeGB,
formatBytesSI(u64ToI64(inFlight)), inFlight,
formatBytesSI(u64ToI64(relevantInFlight)), relevantInFlight,
dir,
)
@ -358,11 +373,11 @@ func startDiskSpaceGuard() {
broadcastAutostartPaused()
fmt.Printf(
"✅ [disk] Space recovered: free=%s (%dB) (>= %s, %dB, resume=%dGB, inFlight=%s, %dB) -> unblock autostart (path=%s)\n",
"✅ [disk] Space recovered: free=%s (%dB) (>= %s, %dB, resume=%dGB, relevantInFlight=%s, %dB) -> unblock autostart (path=%s)\n",
formatBytesSI(u64ToI64(free)), free,
formatBytesSI(u64ToI64(resumeNeed)), resumeNeed,
resumeGB,
formatBytesSI(u64ToI64(inFlight)), inFlight,
formatBytesSI(u64ToI64(relevantInFlight)), relevantInFlight,
dir,
)
}

View File

@ -1,118 +1,38 @@
// backend\frontend.go
package main
import (
"embed"
"fmt"
"io/fs"
"net/http"
"os"
"path"
"path/filepath"
"strings"
)
// Frontend (Vite build) als SPA ausliefern: Dateien aus dist, sonst index.html
func registerFrontend(mux *http.ServeMux) {
// Kandidaten: zuerst ENV, dann typische Ordner
candidates := []string{
strings.TrimSpace(os.Getenv("FRONTEND_DIST")),
"web/dist",
"dist",
}
var distAbs string
for _, c := range candidates {
if c == "" {
continue
}
abs, err := resolvePathRelativeToApp(c)
if err != nil {
continue
}
if fi, err := os.Stat(filepath.Join(abs, "index.html")); err == nil && !fi.IsDir() {
distAbs = abs
break
}
}
if distAbs == "" {
fmt.Println("⚠️ Frontend dist nicht gefunden (tried: FRONTEND_DIST, frontend/dist, dist) API läuft trotzdem.")
return
}
fmt.Println("🖼️ Frontend dist:", distAbs)
fileServer := http.FileServer(http.Dir(distAbs))
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// /api bleibt bei deinen API-Routen (längeres Pattern gewinnt),
// aber falls mal was durchrutscht:
if strings.HasPrefix(r.URL.Path, "/api/") {
http.NotFound(w, r)
return
}
// 1) Wenn echte Datei existiert -> ausliefern
reqPath := r.URL.Path
if reqPath == "" || reqPath == "/" {
// index.html
w.Header().Set("Cache-Control", "no-store")
http.ServeFile(w, r, filepath.Join(distAbs, "index.html"))
return
}
// URL-Pfad in Dateisystem-Pfad umwandeln (ohne Traversal)
clean := path.Clean("/" + reqPath) // path.Clean (für URL-Slashes)
rel := strings.TrimPrefix(clean, "/")
onDisk := filepath.Join(distAbs, filepath.FromSlash(rel))
if fi, err := os.Stat(onDisk); err == nil && !fi.IsDir() {
// Statische Assets ruhig cachen (Vite hashed assets)
ext := strings.ToLower(filepath.Ext(onDisk))
if ext != "" && ext != ".html" {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-store")
}
fileServer.ServeHTTP(w, r)
return
}
// 2) SPA-Fallback: alle "Routen" ohne Datei -> index.html
w.Header().Set("Cache-Control", "no-store")
http.ServeFile(w, r, filepath.Join(distAbs, "index.html"))
})
}
// Vite-Build einbetten.
// Beim Go-Build muss backend/web/dist bereits existieren.
//
//go:embed web/dist web/dist/*
var embeddedFrontend embed.FS
func makeFrontendHandler() (http.Handler, bool) {
// Kandidaten: zuerst ENV, dann typische Ordner
candidates := []string{
strings.TrimSpace(os.Getenv("FRONTEND_DIST")),
"web/dist",
"dist",
}
var distAbs string
for _, c := range candidates {
if c == "" {
continue
}
abs, err := resolvePathRelativeToApp(c)
distFS, err := fs.Sub(embeddedFrontend, "web/dist")
if err != nil {
continue
}
if fi, err := os.Stat(filepath.Join(abs, "index.html")); err == nil && !fi.IsDir() {
distAbs = abs
break
}
}
if distAbs == "" {
fmt.Println("⚠️ Frontend dist nicht gefunden (tried: FRONTEND_DIST, web/dist, dist) API läuft trotzdem.")
fmt.Println("⚠️ Frontend dist nicht im Binary gefunden API läuft trotzdem:", err)
return nil, false
}
fmt.Println("🖼️ Frontend dist:", distAbs)
// Prüfen, ob index.html vorhanden ist
if _, err := fs.Stat(distFS, "index.html"); err != nil {
fmt.Println("⚠️ Frontend index.html nicht im Binary gefunden API läuft trotzdem:", err)
return nil, false
}
fileServer := http.FileServer(http.Dir(distAbs))
fmt.Println("🖼️ Frontend dist: embedded web/dist")
fileServer := http.FileServer(http.FS(distFS))
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// /api bleibt API
@ -124,17 +44,23 @@ func makeFrontendHandler() (http.Handler, bool) {
reqPath := r.URL.Path
if reqPath == "" || reqPath == "/" {
w.Header().Set("Cache-Control", "no-store")
http.ServeFile(w, r, filepath.Join(distAbs, "index.html"))
indexBytes, err := fs.ReadFile(distFS, "index.html")
if err != nil {
http.Error(w, "index.html nicht gefunden", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(indexBytes)
return
}
// URL-Pfad in Dateisystem-Pfad umwandeln (ohne Traversal)
// URL-Pfad bereinigen
clean := path.Clean("/" + reqPath)
rel := strings.TrimPrefix(clean, "/")
onDisk := filepath.Join(distAbs, filepath.FromSlash(rel))
if fi, err := os.Stat(onDisk); err == nil && !fi.IsDir() {
ext := strings.ToLower(filepath.Ext(onDisk))
// Wenn echte Datei im embedded FS existiert -> ausliefern
if fi, err := fs.Stat(distFS, rel); err == nil && !fi.IsDir() {
ext := strings.ToLower(path.Ext(rel))
if ext != "" && ext != ".html" {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
@ -146,8 +72,22 @@ func makeFrontendHandler() (http.Handler, bool) {
// SPA-Fallback
w.Header().Set("Cache-Control", "no-store")
http.ServeFile(w, r, filepath.Join(distAbs, "index.html"))
indexBytes, err := fs.ReadFile(distFS, "index.html")
if err != nil {
http.Error(w, "index.html nicht gefunden", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(indexBytes)
})
return h, true
}
func registerFrontend(mux *http.ServeMux) {
h, ok := makeFrontendHandler()
if !ok {
return
}
mux.Handle("/", h)
}

View File

@ -9,6 +9,7 @@ require (
github.com/jackc/pgx/v5 v5.8.0
github.com/pquerna/otp v1.5.0
github.com/r3labs/sse/v2 v2.10.0
github.com/yalue/onnxruntime_go v1.27.0
golang.org/x/crypto v0.47.0
)
@ -24,8 +25,8 @@ require (
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.35.0 // indirect
gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect
)
@ -34,7 +35,7 @@ require (
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/shirou/gopsutil/v3 v3.24.5
github.com/sqweek/dialog v0.0.0-20240226140203-065105509627
golang.org/x/image v0.35.0
golang.org/x/image v0.37.0
golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.40.0 // indirect
)

View File

@ -53,6 +53,8 @@ github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFA
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/yalue/onnxruntime_go v1.27.0 h1:c1YSgDNtpf0WGtxj3YeRIb8VC5LmM1J+Ve3uHdteC1U=
github.com/yalue/onnxruntime_go v1.27.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
@ -64,8 +66,8 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@ -90,8 +92,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -126,8 +128,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=

View File

@ -77,14 +77,6 @@ func maybeBlockHLSOnPreview(w http.ResponseWriter, r *http.Request, basePath, fi
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Preview-HLS-Disabled", "1")
// Optionales Debug (hilft dir, den Auslöser zu finden)
fmt.Printf("[HLS-BLOCK] %s file=%q referer=%q ua=%q\n",
r.URL.String(),
file,
r.Referer(),
r.Header.Get("User-Agent"),
)
http.Error(w, "HLS disabled on /api/preview; use /api/preview/live", http.StatusGone) // 410
return true
}

View File

@ -543,9 +543,6 @@ func startAdaptiveSemController(ctx context.Context) {
genSem.SetMax(genSem.Max() + 1)
thumbSem.SetMax(thumbSem.Max() + 1)
}
// optional Debug:
// fmt.Printf("CPU %.1f%% -> preview=%d thumb=%d gen=%d\n", usage, previewSem.Max(), thumbSem.Max(), genSem.Max())
}
}
}()

View File

@ -39,15 +39,33 @@ type videoMeta struct {
VideoWidth int `json:"videoWidth,omitempty"`
VideoHeight int `json:"videoHeight,omitempty"`
FPS float64 `json:"fps,omitempty"`
Resolution string `json:"resolution,omitempty"` // z.B. "1920x1080"
Resolution string `json:"resolution,omitempty"`
SourceURL string `json:"sourceUrl,omitempty"`
PreviewClips []previewClip `json:"previewClips,omitempty"`
PreviewSprite *previewSpriteMeta `json:"previewSprite,omitempty"`
AI *aiAnalysisMeta `json:"ai,omitempty"`
UpdatedAtUnix int64 `json:"updatedAtUnix"`
}
type aiSegmentMeta struct {
Label string `json:"label"`
StartSeconds float64 `json:"startSeconds"`
EndSeconds float64 `json:"endSeconds"`
DurationSeconds float64 `json:"durationSeconds"`
Score float64 `json:"score,omitempty"`
AutoSelected bool `json:"autoSelected,omitempty"`
}
type aiAnalysisMeta struct {
Goal string `json:"goal,omitempty"`
Mode string `json:"mode,omitempty"`
Hits []analyzeHit `json:"hits,omitempty"`
Segments []aiSegmentMeta `json:"segments,omitempty"`
AnalyzedAtUnix int64 `json:"analyzedAtUnix,omitempty"`
}
// liest Meta (v2 ODER altes v1) und validiert gegen fi (Size/ModTime)
func readVideoMeta(metaPath string, fi os.FileInfo) (dur float64, w int, h int, fps float64, ok bool) {
b, err := os.ReadFile(metaPath)
@ -338,14 +356,15 @@ func writeVideoMeta(metaPath string, fi os.FileInfo, dur float64, w int, h int,
SourceURL: strings.TrimSpace(sourceURL),
UpdatedAtUnix: time.Now().Unix(),
// ✅ bestehende Preview-Daten behalten
PreviewClips: nil,
PreviewSprite: nil,
AI: nil,
}
if existing != nil {
m.PreviewClips = existing.PreviewClips
m.PreviewSprite = existing.PreviewSprite
m.AI = existing.AI
}
buf, err := json.Marshal(m)
if err != nil {
@ -378,6 +397,10 @@ func writeVideoMetaWithPreviewClips(metaPath string, fi os.FileInfo, dur float64
UpdatedAtUnix: time.Now().Unix(),
}
if existing != nil {
m.AI = existing.AI
}
// ✅ vorhandenes Sprite (inkl. stepSeconds) nicht wegwerfen
if existing != nil && existing.PreviewSprite != nil {
m.PreviewSprite = existing.PreviewSprite
@ -431,6 +454,10 @@ func writeVideoMetaWithPreviewClipsAndSprite(
}
}
if old, ok := readVideoMetaIfValid(metaPath, fi); ok && old != nil && old.AI != nil {
m.AI = old.AI
}
buf, err := json.Marshal(m)
if err != nil {
return err
@ -538,3 +565,137 @@ func sanitizeID(id string) (string, error) {
}
return id, nil
}
func writeVideoMetaAI(
metaPath string,
fi os.FileInfo,
dur float64,
w int,
h int,
fps float64,
sourceURL string,
ai *aiAnalysisMeta,
) error {
if strings.TrimSpace(metaPath) == "" || dur <= 0 {
return nil
}
var existing *videoMeta
if old, ok := readVideoMetaIfValid(metaPath, fi); ok && old != nil {
existing = old
}
m := videoMeta{
Version: 2,
DurationSeconds: dur,
FileSize: fi.Size(),
FileModUnix: fi.ModTime().Unix(),
VideoWidth: w,
VideoHeight: h,
FPS: fps,
Resolution: formatResolution(w, h),
SourceURL: strings.TrimSpace(sourceURL),
UpdatedAtUnix: time.Now().Unix(),
PreviewClips: nil,
PreviewSprite: nil,
AI: ai,
}
if existing != nil {
m.PreviewClips = existing.PreviewClips
m.PreviewSprite = existing.PreviewSprite
if m.VideoWidth <= 0 {
m.VideoWidth = existing.VideoWidth
}
if m.VideoHeight <= 0 {
m.VideoHeight = existing.VideoHeight
}
if m.FPS <= 0 {
m.FPS = existing.FPS
}
if m.Resolution == "" {
m.Resolution = existing.Resolution
}
if m.SourceURL == "" {
m.SourceURL = existing.SourceURL
}
}
buf, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
buf = append(buf, '\n')
return atomicWriteFile(metaPath, buf)
}
func writeVideoAIForFile(
ctx context.Context,
fullPath string,
sourceURL string,
ai *aiAnalysisMeta,
) error {
fullPath = strings.TrimSpace(fullPath)
if fullPath == "" || ai == nil {
return nil
}
fi, err := os.Stat(fullPath)
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
return fmt.Errorf("datei nicht gefunden")
}
m, ok := ensureVideoMetaForFileBestEffort(ctx, fullPath, sourceURL)
if !ok || m == nil {
return fmt.Errorf("meta konnte nicht erzeugt werden")
}
stem := strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath))
assetID := stripHotPrefix(strings.TrimSpace(stem))
if assetID == "" {
return fmt.Errorf("asset id fehlt")
}
assetID, err = sanitizeID(assetID)
if err != nil || assetID == "" {
return fmt.Errorf("asset id ungültig: %w", err)
}
metaPath, err := metaJSONPathForAssetID(assetID)
if err != nil {
return err
}
return writeVideoMetaAI(
metaPath,
fi,
m.DurationSeconds,
m.VideoWidth,
m.VideoHeight,
m.FPS,
sourceURL,
ai,
)
}
func readVideoMetaAI(metaPath string) (map[string]any, bool) {
b, err := os.ReadFile(metaPath)
if err != nil || len(b) == 0 {
return nil, false
}
var m map[string]any
dec := json.NewDecoder(strings.NewReader(string(b)))
dec.UseNumber()
if err := dec.Decode(&m); err != nil {
return nil, false
}
ai, ok := m["ai"].(map[string]any)
if !ok || ai == nil {
return nil, false
}
return ai, true
}

117
backend/nsfw_assets.go Normal file
View File

@ -0,0 +1,117 @@
// backend\nsfw_assets.go
package main
import (
"embed"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"runtime"
"strings"
)
//go:embed all:assets/nsfw
var embeddedNSFWAssets embed.FS
const nsfwRuntimeVersion = "nsfw-onnx-v1"
func nsfwRuntimeBaseDir() (string, error) {
appName := "nsfwapp"
if runtime.GOOS == "windows" {
base := strings.TrimSpace(os.Getenv("LOCALAPPDATA"))
if base == "" {
base = strings.TrimSpace(os.Getenv("TEMP"))
}
if base == "" {
return "", fmt.Errorf("LOCALAPPDATA/TEMP nicht gefunden")
}
return filepath.Join(base, appName, nsfwRuntimeVersion), nil
}
base, err := os.UserCacheDir()
if err != nil {
return "", err
}
return filepath.Join(base, appName, nsfwRuntimeVersion), nil
}
func ensureNSFWAssetsExtracted() (string, error) {
root, err := nsfwRuntimeBaseDir()
if err != nil {
return "", err
}
marker := filepath.Join(root, ".extract-ok")
if fi, err := os.Stat(marker); err == nil && !fi.IsDir() {
return root, nil
}
if _, err := fs.Stat(embeddedNSFWAssets, "assets/nsfw"); err != nil {
return "", fmt.Errorf("embedded assets/nsfw nicht gefunden: %w", err)
}
if err := os.RemoveAll(root); err != nil {
return "", fmt.Errorf("runtime-ordner konnte nicht bereinigt werden: %w", err)
}
if err := os.MkdirAll(root, 0o755); err != nil {
return "", fmt.Errorf("runtime-ordner konnte nicht erstellt werden: %w", err)
}
if err := extractEmbeddedDir("assets/nsfw", root); err != nil {
return "", fmt.Errorf("nsfw-assets extrahieren fehlgeschlagen: %w", err)
}
if err := os.WriteFile(marker, []byte("ok"), 0o644); err != nil {
return "", fmt.Errorf("markerdatei konnte nicht geschrieben werden: %w", err)
}
return root, nil
}
func extractEmbeddedDir(srcRoot, dstRoot string) error {
return fs.WalkDir(embeddedNSFWAssets, srcRoot, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(srcRoot, p)
if err != nil {
return err
}
if rel == "." {
return os.MkdirAll(dstRoot, 0o755)
}
dstPath := filepath.Join(dstRoot, rel)
if d.IsDir() {
return os.MkdirAll(dstPath, 0o755)
}
if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
return err
}
in, err := embeddedNSFWAssets.Open(p)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dstPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o755)
if err != nil {
return err
}
_, copyErr := io.Copy(out, in)
closeErr := out.Close()
if copyErr != nil {
return copyErr
}
return closeErr
})
}

409
backend/nsfw_detector.go Normal file
View File

@ -0,0 +1,409 @@
// backend\nsfw_detector.go
package main
import (
"bytes"
"encoding/base64"
"fmt"
"image"
_ "image/jpeg"
_ "image/png"
"math"
"os"
"path/filepath"
"sort"
"strings"
"sync"
ort "github.com/yalue/onnxruntime_go"
xdraw "golang.org/x/image/draw"
)
const (
nsfwInputSize = 320
nsfwNumClasses = 18
nsfwNumAnchors = 2100 // 320er YOLOv8: 40*40 + 20*20 + 10*10
nsfwConfThresh = 0.20
nsfwNMSThresh = 0.45
)
var nsfwLabels = []string{
"female_genitalia_covered",
"face_female",
"buttocks_exposed",
"female_breast_exposed",
"female_genitalia_exposed",
"male_breast_exposed",
"anus_exposed",
"feet_exposed",
"belly_covered",
"feet_covered",
"armpits_covered",
"armpits_exposed",
"face_male",
"belly_exposed",
"male_genitalia_exposed",
"anus_covered",
"female_breast_covered",
"buttocks_covered",
}
type nsfwDetector struct {
mu sync.Mutex
initialized bool
runtimeRoot string
modelPath string
dllPath string
inputTensor *ort.Tensor[float32]
outputTensor *ort.Tensor[float32]
session *ort.AdvancedSession
}
type yoloDet struct {
classID int
score float32
x1 float32
y1 float32
x2 float32
y2 float32
}
var globalNSFW nsfwDetector
func initNSFWDetector() error {
globalNSFW.mu.Lock()
defer globalNSFW.mu.Unlock()
if globalNSFW.initialized {
return nil
}
root, err := ensureNSFWAssetsExtracted()
if err != nil {
return err
}
dllPath := filepath.Join(root, "onnxruntime.dll")
modelPath := filepath.Join(root, "320n.onnx")
if _, err := os.Stat(dllPath); err != nil {
return fmt.Errorf("onnxruntime.dll nicht gefunden: %w", err)
}
if _, err := os.Stat(modelPath); err != nil {
return fmt.Errorf("320n.onnx nicht gefunden: %w", err)
}
ort.SetSharedLibraryPath(dllPath)
if err := ort.InitializeEnvironment(); err != nil {
return fmt.Errorf("onnxruntime init fehlgeschlagen: %w", err)
}
inputShape := ort.NewShape(1, 3, nsfwInputSize, nsfwInputSize)
inputData := make([]float32, 1*3*nsfwInputSize*nsfwInputSize)
inputTensor, err := ort.NewTensor(inputShape, inputData)
if err != nil {
ort.DestroyEnvironment()
return fmt.Errorf("input tensor fehlgeschlagen: %w", err)
}
outputShape := ort.NewShape(1, 4+nsfwNumClasses, nsfwNumAnchors)
outputTensor, err := ort.NewEmptyTensor[float32](outputShape)
if err != nil {
inputTensor.Destroy()
ort.DestroyEnvironment()
return fmt.Errorf("output tensor fehlgeschlagen: %w", err)
}
session, err := ort.NewAdvancedSession(
modelPath,
[]string{"images"},
[]string{"output0"},
[]ort.Value{inputTensor},
[]ort.Value{outputTensor},
nil,
)
if err != nil {
outputTensor.Destroy()
inputTensor.Destroy()
ort.DestroyEnvironment()
return fmt.Errorf("onnx session fehlgeschlagen: %w", err)
}
globalNSFW.runtimeRoot = root
globalNSFW.modelPath = modelPath
globalNSFW.dllPath = dllPath
globalNSFW.inputTensor = inputTensor
globalNSFW.outputTensor = outputTensor
globalNSFW.session = session
globalNSFW.initialized = true
fmt.Println("[NSFW] ONNX detector bereit")
fmt.Println("[NSFW] model:", modelPath)
fmt.Println("[NSFW] dll:", dllPath)
return nil
}
func closeNSFWDetector() error {
globalNSFW.mu.Lock()
defer globalNSFW.mu.Unlock()
if !globalNSFW.initialized {
return nil
}
if globalNSFW.session != nil {
globalNSFW.session.Destroy()
globalNSFW.session = nil
}
if globalNSFW.outputTensor != nil {
globalNSFW.outputTensor.Destroy()
globalNSFW.outputTensor = nil
}
if globalNSFW.inputTensor != nil {
globalNSFW.inputTensor.Destroy()
globalNSFW.inputTensor = nil
}
ort.DestroyEnvironment()
globalNSFW.initialized = false
return nil
}
func detectNSFWFromBase64(imageB64 string) ([]NsfwFrameResult, error) {
globalNSFW.mu.Lock()
defer globalNSFW.mu.Unlock()
if !globalNSFW.initialized || globalNSFW.session == nil {
return nil, fmt.Errorf("nsfw detector nicht initialisiert")
}
img, err := decodeBase64Image(imageB64)
if err != nil {
return nil, err
}
fillInputTensor(globalNSFW.inputTensor.GetData(), img)
if err := globalNSFW.session.Run(); err != nil {
return nil, fmt.Errorf("onnx run fehlgeschlagen: %w", err)
}
raw := globalNSFW.outputTensor.GetData()
dets := parseYOLOOutput(raw, nsfwConfThresh)
dets = applyNMS(dets, nsfwNMSThresh)
bestByLabel := map[string]float64{}
for _, d := range dets {
if d.classID < 0 || d.classID >= len(nsfwLabels) {
continue
}
label := nsfwLabels[d.classID]
score := float64(d.score)
if score > bestByLabel[label] {
bestByLabel[label] = score
}
}
out := make([]NsfwFrameResult, 0, len(bestByLabel))
for label, score := range bestByLabel {
out = append(out, NsfwFrameResult{
Label: label,
Score: score,
})
}
sort.Slice(out, func(i, j int) bool {
return out[i].Score > out[j].Score
})
return out, nil
}
func decodeBase64Image(imageB64 string) (image.Image, error) {
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(imageB64))
if err != nil {
return nil, fmt.Errorf("base64 decode fehlgeschlagen: %w", err)
}
img, _, err := image.Decode(bytes.NewReader(raw))
if err != nil {
return nil, fmt.Errorf("bild decode fehlgeschlagen: %w", err)
}
return img, nil
}
func fillInputTensor(dst []float32, src image.Image) {
rgba, scale, padX, padY := letterboxToRGBA(src, nsfwInputSize, nsfwInputSize)
hw := nsfwInputSize * nsfwInputSize
for y := 0; y < nsfwInputSize; y++ {
for x := 0; x < nsfwInputSize; x++ {
i := y*rgba.Stride + x*4
r := float32(rgba.Pix[i+0]) / 255.0
g := float32(rgba.Pix[i+1]) / 255.0
b := float32(rgba.Pix[i+2]) / 255.0
idx := y*nsfwInputSize + x
dst[idx] = r
dst[hw+idx] = g
dst[2*hw+idx] = b
}
}
_ = scale
_ = padX
_ = padY
}
func letterboxToRGBA(src image.Image, dstW, dstH int) (*image.RGBA, float64, int, int) {
sb := src.Bounds()
sw := sb.Dx()
sh := sb.Dy()
scale := math.Min(float64(dstW)/float64(sw), float64(dstH)/float64(sh))
nw := int(math.Round(float64(sw) * scale))
nh := int(math.Round(float64(sh) * scale))
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
for y := 0; y < dstH; y++ {
for x := 0; x < dstW; x++ {
i := y*dst.Stride + x*4
dst.Pix[i+0] = 114
dst.Pix[i+1] = 114
dst.Pix[i+2] = 114
dst.Pix[i+3] = 255
}
}
resized := image.NewRGBA(image.Rect(0, 0, nw, nh))
xdraw.ApproxBiLinear.Scale(resized, resized.Bounds(), src, sb, xdraw.Over, nil)
padX := (dstW - nw) / 2
padY := (dstH - nh) / 2
for y := 0; y < nh; y++ {
copy(
dst.Pix[(y+padY)*dst.Stride+padX*4:(y+padY)*dst.Stride+padX*4+nw*4],
resized.Pix[y*resized.Stride:y*resized.Stride+nw*4],
)
}
return dst, scale, padX, padY
}
func parseYOLOOutput(raw []float32, confThresh float32) []yoloDet {
// output0: [1, 22, 2100] = [batch, 4+18, anchors]
out := make([]yoloDet, 0, 64)
channels := 4 + nsfwNumClasses
if len(raw) != channels*nsfwNumAnchors {
return out
}
for a := 0; a < nsfwNumAnchors; a++ {
cx := raw[0*nsfwNumAnchors+a]
cy := raw[1*nsfwNumAnchors+a]
w := raw[2*nsfwNumAnchors+a]
h := raw[3*nsfwNumAnchors+a]
bestClass := -1
bestScore := float32(0)
for c := 0; c < nsfwNumClasses; c++ {
s := raw[(4+c)*nsfwNumAnchors+a]
if s > bestScore {
bestScore = s
bestClass = c
}
}
if bestClass < 0 || bestScore < confThresh {
continue
}
x1 := cx - w/2
y1 := cy - h/2
x2 := cx + w/2
y2 := cy + h/2
out = append(out, yoloDet{
classID: bestClass,
score: bestScore,
x1: x1,
y1: y1,
x2: x2,
y2: y2,
})
}
return out
}
func applyNMS(dets []yoloDet, iouThresh float32) []yoloDet {
if len(dets) == 0 {
return dets
}
sort.Slice(dets, func(i, j int) bool {
return dets[i].score > dets[j].score
})
kept := make([]yoloDet, 0, len(dets))
used := make([]bool, len(dets))
for i := 0; i < len(dets); i++ {
if used[i] {
continue
}
kept = append(kept, dets[i])
for j := i + 1; j < len(dets); j++ {
if used[j] || dets[i].classID != dets[j].classID {
continue
}
if iou(dets[i], dets[j]) >= iouThresh {
used[j] = true
}
}
}
return kept
}
func iou(a, b yoloDet) float32 {
ix1 := maxf(a.x1, b.x1)
iy1 := maxf(a.y1, b.y1)
ix2 := minf(a.x2, b.x2)
iy2 := minf(a.y2, b.y2)
iw := maxf(0, ix2-ix1)
ih := maxf(0, iy2-iy1)
inter := iw * ih
aw := maxf(0, a.x2-a.x1)
ah := maxf(0, a.y2-a.y1)
bw := maxf(0, b.x2-b.x1)
bh := maxf(0, b.y2-b.y1)
union := aw*ah + bw*bh - inter
if union <= 0 {
return 0
}
return inter / union
}
func minf(a, b float32) float32 {
if a < b {
return a
}
return b
}
func maxf(a, b float32) float32 {
if a > b {
return a
}
return b
}

14
backend/nsfw_types.go Normal file
View File

@ -0,0 +1,14 @@
// backend\nsfw_types.go
package main
type NsfwFrameResult struct {
Label string `json:"label"`
Score float64 `json:"score"`
}
type NsfwImageResponse struct {
Ok bool `json:"ok"`
Results []NsfwFrameResult `json:"results"`
Error string `json:"error,omitempty"`
}

View File

@ -144,10 +144,7 @@ func (pq *PostWorkQueue) workerLoop(id int) {
continue
}
// 1) Heavy-Gate: erst wenn ein Slot frei ist, gilt der Task als "running"
pq.ffmpegSem <- struct{}{}
// 2) Ab hier startet er wirklich → waiting -> running
// Task startet jetzt wirklich → waiting -> running
pq.mu.Lock()
pq.removeWaitingKeyLocked(task.Key)
pq.runningKeys[task.Key] = struct{}{}
@ -170,9 +167,6 @@ func (pq *PostWorkQueue) workerLoop(id int) {
pq.queued--
}
pq.mu.Unlock()
// Slot freigeben
<-pq.ffmpegSem
}()
// 3) Optional: Task timeout (gegen hängende ffmpeg)
@ -253,7 +247,7 @@ func (pq *PostWorkQueue) StatusForKey(key string) PostWorkKeyStatus {
}
// global (oder in deinem app struct halten)
var postWorkQ = NewPostWorkQueue(512, 2) // maxParallelFFmpeg = 4
var postWorkQ = NewPostWorkQueue(512, 6) // maxParallelFFmpeg = 6
// --- Status Refresher (ehemals postwork_refresh.go) ---

View File

@ -1746,18 +1746,12 @@ func servePreviewForFinishedFile(w http.ResponseWriter, r *http.Request, id stri
if tStr := strings.TrimSpace(r.URL.Query().Get("t")); tStr != "" {
if sec, err := strconv.ParseFloat(tStr, 64); err == nil && sec >= 0 {
secI := int64(sec + 0.5)
if secI < 0 {
secI = 0
if sec < 0 {
sec = 0
}
framePath := filepath.Join(assetDir, fmt.Sprintf("t_%d.webp", secI))
if fi, err := os.Stat(framePath); err == nil && !fi.IsDir() && fi.Size() > 0 {
servePreviewWebPFile(w, r, framePath)
return
}
img, err := extractFrameAtTimeWebP(outPath, float64(secI))
img, err := extractFrameAtTimeWebP(outPath, sec)
if err == nil && len(img) > 0 {
_ = atomicWriteFile(framePath, img)
servePreviewWebPBytes(w, img)
return
}

View File

@ -22,6 +22,18 @@ import (
// ---------------- Types ----------------
type prepareSplitReq struct {
Output string `json:"output"`
Goal string `json:"goal,omitempty"` // z.B. "nsfw"
}
type prepareSplitResp struct {
OK bool `json:"ok"`
AssetsReady bool `json:"assetsReady"`
AnalyzeReady bool `json:"analyzeReady"`
Error string `json:"error,omitempty"`
}
type RecordRequest struct {
URL string `json:"url"`
Cookie string `json:"cookie,omitempty"`
@ -54,6 +66,7 @@ type doneMetaFileResp struct {
FPS float64 `json:"fps,omitempty"`
SourceURL string `json:"sourceUrl,omitempty"`
PreviewSprite previewSpriteMetaResp `json:"previewSprite"`
AI any `json:"ai,omitempty"`
Error string `json:"error,omitempty"`
}
@ -115,6 +128,38 @@ func mustMethod(w http.ResponseWriter, r *http.Request, methods ...string) bool
return false
}
func recordPrepareSplit(w http.ResponseWriter, r *http.Request) {
if !mustMethod(w, r, http.MethodPost) {
return
}
var req prepareSplitReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungültiger body: "+err.Error(), http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second)
defer cancel()
res, err := prepareVideoForSplit(ctx, req.Output, "", req.Goal)
if err != nil {
respondJSON(w, prepareSplitResp{
OK: false,
AssetsReady: false,
AnalyzeReady: false,
Error: err.Error(),
})
return
}
respondJSON(w, prepareSplitResp{
OK: true,
AssetsReady: res.AssetsReady,
AnalyzeReady: res.AnalyzeReady,
})
}
// ---------------- Preview sprite truth (shared) ----------------
type previewSpriteMetaFileInfo struct {
@ -1222,6 +1267,7 @@ func recordDoneMeta(w http.ResponseWriter, r *http.Request) {
if mp, merr := generatedMetaFile(id); merr == nil && strings.TrimSpace(mp) != "" {
if mfi, serr := os.Stat(mp); serr == nil && mfi != nil && !mfi.IsDir() && mfi.Size() > 0 {
resp.MetaExists = true
if dur, w2, h2, fps2, ok := readVideoMeta(mp, fi); ok {
resp.DurationSeconds = dur
resp.Width = w2
@ -1231,6 +1277,10 @@ func recordDoneMeta(w http.ResponseWriter, r *http.Request) {
if u, ok := readVideoMetaSourceURL(mp, fi); ok {
resp.SourceURL = u
}
if ai, ok := readVideoMetaAI(mp); ok {
resp.AI = ai
}
}
}
}
@ -1298,21 +1348,61 @@ func recordDoneMeta(w http.ResponseWriter, r *http.Request) {
sortedAll := doneCache.sortedIdx
doneCache.mu.Unlock()
isActivePostworkOutput := func(fullPath string) bool {
base := strings.TrimSpace(filepath.Base(fullPath))
if base == "" {
return false
}
jobsMu.Lock()
defer jobsMu.Unlock()
for _, j := range jobs {
if j == nil {
continue
}
if !isPostworkJob(j) {
continue
}
if isTerminalJobStatus(j.Status) {
continue
}
if strings.EqualFold(filepath.Base(strings.TrimSpace(j.Output)), base) {
return true
}
}
return false
}
count := 0
if qModel == "" {
incKey := "0"
if includeKeep {
incKey = "1"
}
count = len(sortedAll[incKey+"|completed_desc"])
for _, idx := range sortedAll[incKey+"|completed_desc"] {
it := items[idx]
if isActivePostworkOutput(it.job.Output) {
continue
}
count++
}
} else {
for _, it := range items {
if !includeKeep && it.fromKeep {
continue
}
if it.modelKey == qModel {
count++
if it.modelKey != qModel {
continue
}
if isActivePostworkOutput(it.job.Output) {
continue
}
count++
}
}
@ -1531,7 +1621,48 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
})
}
totalCount := len(idx)
isActivePostworkOutput := func(fullPath string) bool {
base := strings.TrimSpace(filepath.Base(fullPath))
if base == "" {
return false
}
jobsMu.Lock()
defer jobsMu.Unlock()
for _, j := range jobs {
if j == nil {
continue
}
if !isPostworkJob(j) {
continue
}
if isTerminalJobStatus(j.Status) {
continue
}
if strings.EqualFold(filepath.Base(strings.TrimSpace(j.Output)), base) {
return true
}
}
return false
}
filteredIdx := make([]int, 0, len(idx))
for _, ii := range idx {
it := items[ii]
if it.job == nil {
continue
}
if isActivePostworkOutput(it.job.Output) {
continue
}
filteredIdx = append(filteredIdx, ii)
}
totalCount := len(filteredIdx)
start := 0
end := totalCount
@ -1554,7 +1685,7 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
out := make([]*RecordJob, 0, max(0, end-start))
for _, ii := range idx[start:end] {
for _, ii := range filteredIdx[start:end] {
base := items[ii].job
if base == nil {
continue

View File

@ -85,6 +85,20 @@ func setNoStoreHeaders(w http.ResponseWriter) {
// ---------- Resolve dirs ----------
func exeDir() (string, error) {
exePath, err := os.Executable()
if err != nil {
return "", err
}
exePath, err = filepath.Abs(exePath)
if err != nil {
return "", err
}
return filepath.Dir(exePath), nil
}
func resolvePathRelativeToApp(p string) (string, error) {
p = strings.TrimSpace(p)
if p == "" {
@ -96,10 +110,9 @@ func resolvePathRelativeToApp(p string) (string, error) {
return p, nil
}
exe, err := os.Executable()
baseDir, err := exeDir()
if err == nil {
exeDir := filepath.Dir(exe)
low := strings.ToLower(exeDir)
low := strings.ToLower(baseDir)
// Heuristik: go run / tests -> exe liegt in Temp/go-build
isTemp := strings.Contains(low, `\appdata\local\temp`) ||
@ -110,7 +123,7 @@ func resolvePathRelativeToApp(p string) (string, error) {
strings.Contains(low, `/go-build`)
if !isTemp {
return filepath.Join(exeDir, p), nil
return filepath.Join(baseDir, p), nil
}
}

View File

@ -37,13 +37,15 @@ func setJobProgress(job *RecordJob, phase string, pct int) {
case "postwork":
return rng{0, 8}
case "remuxing":
return rng{8, 42}
return rng{8, 38}
case "moving":
return rng{42, 58}
return rng{38, 54}
case "probe":
return rng{58, 72}
return rng{54, 70}
case "assets":
return rng{72, 99}
return rng{70, 88}
case "analyze":
return rng{88, 99}
default:
return rng{0, 100}
}
@ -448,7 +450,8 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
job.PostWorkKey = ""
job.PostWork = nil
jobsMu.Unlock()
publishJobUpsert(job)
publishJobRemove(job)
notifyDoneChanged()
return
}
@ -696,6 +699,37 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
}
setPhase("assets", 100)
// 6) AI Analyze -> meta.json.ai
setPhase("analyze", 5)
{
actx, cancel := context.WithTimeout(ctx, 45*time.Second)
durationSec, _ := durationSecondsForAnalyze(actx, out)
hits, aerr := analyzeVideoFromSprite(actx, out, "nsfw")
if aerr != nil {
fmt.Println("⚠️ postwork analyze:", aerr)
} else {
setPhase("analyze", 65)
segments := buildSegmentsFromAnalyzeHits(hits, durationSec)
ai := &aiAnalysisMeta{
Goal: "nsfw",
Mode: "sprite",
Hits: hits,
Segments: segments,
AnalyzedAtUnix: time.Now().Unix(),
}
if werr := writeVideoAIForFile(actx, out, job.SourceURL, ai); werr != nil {
fmt.Println("⚠️ writeVideoAIForFile:", werr)
}
}
cancel()
}
setPhase("analyze", 100)
// Finalize
jobsMu.Lock()
job.Status = postTarget
@ -704,7 +738,8 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
job.PostWorkKey = ""
job.PostWork = nil
jobsMu.Unlock()
publishJobUpsert(job)
publishJobRemove(job)
notifyDoneChanged()
return nil
},
@ -724,7 +759,8 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
job.PostWorkKey = ""
job.PostWork = nil
jobsMu.Unlock()
publishJobUpsert(job)
publishJobRemove(job)
notifyDoneChanged()
}
}

View File

@ -10,6 +10,7 @@
"useMyFreeCamsWatcher": true,
"autoDeleteSmallDownloads": false,
"autoDeleteSmallDownloadsBelowMB": 50,
"lowDiskPauseBelowGB": 5,
"blurPreviews": false,
"teaserPlayback": "hover",
"teaserAudio": false,

View File

@ -54,12 +54,15 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
api.HandleFunc("/api/record/list", recordList)
api.HandleFunc("/api/record/done/meta", recordDoneMeta)
api.HandleFunc("/api/record/video", recordVideo)
api.HandleFunc("/api/record/split", recordSplitVideo)
api.HandleFunc("/api/record/analyze", recordAnalyzeVideo)
api.HandleFunc("/api/record/done", recordDoneList)
api.HandleFunc("/api/record/delete", recordDeleteVideo)
api.HandleFunc("/api/record/toggle-hot", recordToggleHot)
api.HandleFunc("/api/record/keep", recordKeepVideo)
api.HandleFunc("/api/record/unkeep", recordUnkeepVideo)
api.HandleFunc("/api/record/restore", recordRestoreVideo)
api.HandleFunc("/api/record/prepare-split", recordPrepareSplit)
api.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler)
api.HandleFunc("/api/chaturbate/biocontext", chaturbateBioContextHandler)

View File

@ -3,9 +3,13 @@
package main
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
// --- main ---
@ -18,16 +22,21 @@ func main() {
go startGeneratedGarbageCollector()
// ✅ NSFW-ONNX Detector initialisieren
if err := initNSFWDetector(); err != nil {
fmt.Println("❌ NSFW-ONNX Fehler:", err)
os.Exit(1)
}
defer func() {
_ = closeNSFWDetector()
}()
mux := http.NewServeMux()
// ✅ AuthManager erstellen (Beispiel)
// Du brauchst hier typischerweise:
// - ein Secret/Key (Cookie signen / Sessions)
// - Username+Pass Hash oder config
// - optional 2FA store
auth, err := NewAuthManager()
if err != nil {
fmt.Println("❌ auth init:", err)
_ = closeNSFWDetector()
os.Exit(1)
}
@ -45,8 +54,31 @@ func main() {
fmt.Println("🌐 HTTP-API aktiv: http://localhost:9999")
handler := withCORS(mux)
if err := http.ListenAndServe(":9999", handler); err != nil {
srv := &http.Server{
Addr: ":9999",
Handler: handler,
}
// Shutdown-Signale
stopSig := make(chan os.Signal, 1)
signal.Notify(stopSig, os.Interrupt, syscall.SIGTERM)
go func() {
<-stopSig
fmt.Println("🛑 Beende Server...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)
_ = closeNSFWDetector()
}()
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fmt.Println("❌ HTTP-Server Fehler:", err)
_ = closeNSFWDetector()
os.Exit(1)
}
_ = closeNSFWDetector()
}

View File

@ -30,6 +30,7 @@ type RecorderSettings struct {
// Wenn aktiv, werden fertige Downloads automatisch gelöscht, wenn sie kleiner als der Grenzwert sind.
AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"`
AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"`
LowDiskPauseBelowGB int `json:"lowDiskPauseBelowGB"`
BlurPreviews bool `json:"blurPreviews"`
TeaserPlayback string `json:"teaserPlayback"` // still | hover | all
@ -58,6 +59,7 @@ var (
UseMyFreeCamsWatcher: false,
AutoDeleteSmallDownloads: false,
AutoDeleteSmallDownloadsBelowMB: 50,
LowDiskPauseBelowGB: 5,
BlurPreviews: false,
TeaserPlayback: "hover",
@ -119,6 +121,12 @@ func loadSettings() {
if s.AutoDeleteSmallDownloadsBelowMB > 100_000 {
s.AutoDeleteSmallDownloadsBelowMB = 100_000
}
if s.LowDiskPauseBelowGB < 1 {
s.LowDiskPauseBelowGB = 1
}
if s.LowDiskPauseBelowGB > 10_000 {
s.LowDiskPauseBelowGB = 10_000
}
settingsMu.Lock()
settings = s
@ -205,6 +213,7 @@ type RecorderSettingsPublic struct {
AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"`
AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"`
LowDiskPauseBelowGB int `json:"lowDiskPauseBelowGB"`
BlurPreviews bool `json:"blurPreviews"`
TeaserPlayback string `json:"teaserPlayback"`
@ -230,6 +239,7 @@ func toPublicSettings(s RecorderSettings) RecorderSettingsPublic {
AutoDeleteSmallDownloads: s.AutoDeleteSmallDownloads,
AutoDeleteSmallDownloadsBelowMB: s.AutoDeleteSmallDownloadsBelowMB,
LowDiskPauseBelowGB: s.LowDiskPauseBelowGB,
BlurPreviews: s.BlurPreviews,
TeaserPlayback: s.TeaserPlayback,
@ -294,6 +304,12 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
if in.AutoDeleteSmallDownloadsBelowMB > 100_000 {
in.AutoDeleteSmallDownloadsBelowMB = 100_000
}
if in.LowDiskPauseBelowGB < 1 {
in.LowDiskPauseBelowGB = 1
}
if in.LowDiskPauseBelowGB > 10_000 {
in.LowDiskPauseBelowGB = 10_000
}
// --- ensure folders (Fehler zurückgeben, falls z.B. keine Rechte) ---
recAbs, err := resolvePathRelativeToApp(in.RecordDir)

297
backend/split.go Normal file
View File

@ -0,0 +1,297 @@
// backend\split.go
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
)
type splitVideoRequest struct {
File string `json:"file"` // z. B. "model_01_01_2026__12-00-00.mp4"
Splits []float64 `json:"splits"` // Sekunden, z. B. [120.5, 300.0]
}
type splitVideoSegmentResponse struct {
Index int `json:"index"`
Start float64 `json:"start"`
End float64 `json:"end"`
Duration float64 `json:"duration"`
File string `json:"file"`
Path string `json:"path"`
}
type splitVideoResponse struct {
OK bool `json:"ok"`
File string `json:"file"`
Source string `json:"source"`
Segments []splitVideoSegmentResponse `json:"segments"`
}
func recordSplitVideo(w http.ResponseWriter, r *http.Request) {
if !mustMethod(w, r, http.MethodPost) {
return
}
var req splitVideoRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungültiger JSON-Body: "+err.Error(), http.StatusBadRequest)
return
}
req.File = strings.TrimSpace(req.File)
if req.File == "" {
http.Error(w, "file fehlt", http.StatusBadRequest)
return
}
if !isAllowedVideoExt(req.File) {
http.Error(w, "nur .mp4 oder .ts erlaubt", http.StatusBadRequest)
return
}
s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil {
http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
if strings.TrimSpace(doneAbs) == "" {
http.Error(w, "doneDir ist leer", http.StatusBadRequest)
return
}
srcPath, _, fi, err := resolveDoneFileByName(doneAbs, req.File)
if err != nil {
http.Error(w, "quelldatei nicht gefunden", http.StatusNotFound)
return
}
if fi == nil || fi.IsDir() || fi.Size() <= 0 {
http.Error(w, "quelldatei ungültig", http.StatusBadRequest)
return
}
srcPath = filepath.Clean(srcPath)
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
durationSec, err := durationSecondsCached(ctx, srcPath)
if err != nil || durationSec <= 0 {
http.Error(w, "videodauer konnte nicht ermittelt werden", http.StatusInternalServerError)
return
}
points, err := normalizeSplitPoints(req.Splits, durationSec)
if err != nil {
http.Error(w, "splits ungültig: "+err.Error(), http.StatusBadRequest)
return
}
if len(points) == 0 {
http.Error(w, "keine gültigen splits übergeben", http.StatusBadRequest)
return
}
segments := buildSplitSegments(points, durationSec)
if len(segments) < 2 {
http.Error(w, "zu wenige segmente nach split-berechnung", http.StatusBadRequest)
return
}
outDir := filepath.Join(filepath.Dir(srcPath), "_split")
if err := os.MkdirAll(outDir, 0o755); err != nil {
http.Error(w, "zielordner konnte nicht erstellt werden: "+err.Error(), http.StatusInternalServerError)
return
}
base := strings.TrimSuffix(filepath.Base(srcPath), filepath.Ext(srcPath))
ext := strings.ToLower(filepath.Ext(srcPath))
if ext == "" {
ext = ".mp4"
}
resp := splitVideoResponse{
OK: true,
File: req.File,
Source: srcPath,
}
for i, seg := range segments {
outName := fmt.Sprintf("%s__part_%02d%s", base, i+1, ext)
outPath := filepath.Join(outDir, outName)
if err := splitSingleSegment(r.Context(), srcPath, outPath, seg.Start, seg.Duration); err != nil {
http.Error(
w,
fmt.Sprintf("segment %d konnte nicht erzeugt werden: %v", i+1, err),
http.StatusInternalServerError,
)
return
}
resp.Segments = append(resp.Segments, splitVideoSegmentResponse{
Index: i + 1,
Start: seg.Start,
End: seg.End,
Duration: seg.Duration,
File: outName,
Path: outPath,
})
}
notifyDoneChanged()
respondJSON(w, resp)
}
type normalizedSegment struct {
Start float64
End float64
Duration float64
}
func normalizeSplitPoints(raw []float64, duration float64) ([]float64, error) {
if duration <= 0 {
return nil, fmt.Errorf("duration <= 0")
}
out := make([]float64, 0, len(raw))
for _, v := range raw {
if v <= 0 {
continue
}
if v >= duration {
continue
}
out = append(out, v)
}
if len(out) == 0 {
return nil, fmt.Errorf("alle split-punkte liegen außerhalb der videodauer")
}
sort.Float64s(out)
dedup := make([]float64, 0, len(out))
for _, v := range out {
if len(dedup) == 0 || absFloat(dedup[len(dedup)-1]-v) >= 0.20 {
dedup = append(dedup, v)
}
}
if len(dedup) == 0 {
return nil, fmt.Errorf("keine eindeutigen split-punkte übrig")
}
return dedup, nil
}
func buildSplitSegments(points []float64, duration float64) []normalizedSegment {
all := make([]float64, 0, len(points)+2)
all = append(all, 0)
all = append(all, points...)
all = append(all, duration)
out := make([]normalizedSegment, 0, len(all)-1)
for i := 0; i < len(all)-1; i++ {
start := all[i]
end := all[i+1]
dur := end - start
if dur <= 0.10 {
continue
}
out = append(out, normalizedSegment{
Start: start,
End: end,
Duration: dur,
})
}
return out
}
func splitSingleSegment(parentCtx context.Context, srcPath, outPath string, startSec, durSec float64) error {
if strings.TrimSpace(srcPath) == "" {
return fmt.Errorf("srcPath leer")
}
if strings.TrimSpace(outPath) == "" {
return fmt.Errorf("outPath leer")
}
if durSec <= 0 {
return fmt.Errorf("dauer <= 0")
}
tmpPath := outPath + ".part"
_ = os.Remove(tmpPath)
_ = os.Remove(outPath)
ctx, cancel := context.WithTimeout(parentCtx, 2*time.Minute)
defer cancel()
// Re-Encode ist robuster/framesauberer als -c copy
args := []string{
"-y",
"-hide_banner",
"-loglevel", "error",
"-ss", formatFFSec(startSec),
"-i", srcPath,
"-t", formatFFSec(durSec),
"-map", "0:v:0",
"-map", "0:a?",
"-c:v", "libx264",
"-preset", "veryfast",
"-crf", "20",
"-pix_fmt", "yuv420p",
"-c:a", "aac",
"-b:a", "128k",
"-movflags", "+faststart",
tmpPath,
}
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
_ = os.Remove(tmpPath)
msg := strings.TrimSpace(stderr.String())
if msg != "" {
return fmt.Errorf("%w (%s)", err, msg)
}
return err
}
fi, err := os.Stat(tmpPath)
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
_ = os.Remove(tmpPath)
return fmt.Errorf("ffmpeg hat keine gültige datei erzeugt")
}
if err := os.Rename(tmpPath, outPath); err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("rename fehlgeschlagen: %w", err)
}
return nil
}
func formatFFSec(v float64) string {
return strconv.FormatFloat(v, 'f', 3, 64)
}
func absFloat(v float64) float64 {
if v < 0 {
return -v
}
return v
}

View File

@ -301,7 +301,13 @@ func runGenerateMissingAssets(ctx context.Context) {
return
}
// ✅ Progress + Counters + SSE Push
if _, aerr := prepareVideoForSplit(ctx, it.path, sourceURL, "nsfw"); aerr != nil {
updateAssetsState(func(st *AssetsTaskState) {
st.Error = "mindestens ein Eintrag konnte nicht vollständig analysiert werden (siehe Logs)"
})
fmt.Println("⚠️ tasks generate assets analyze:", aerr)
}
updateAssetsState(func(st *AssetsTaskState) {
if res.Skipped {
st.Skipped++

View File

@ -19,6 +19,7 @@ import { useNotify } from './components/ui/notify'
//import { startChaturbateOnlinePolling } from './lib/chaturbateOnlinePoller'
import CategoriesTab from './components/ui/CategoriesTab'
import LoginPage from './components/ui/LoginPage'
import VideoSplitModal from './components/ui/VideoSplitModal'
const COOKIE_STORAGE_KEY = 'record_cookies'
@ -101,7 +102,7 @@ const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = {
blurPreviews: false,
teaserPlayback: 'hover',
teaserAudio: false,
lowDiskPauseBelowGB: 3000,
lowDiskPauseBelowGB: 5,
}
type StoredModel = {
@ -295,6 +296,13 @@ function mfcUserFromUrl(normUrl: string): string {
const baseName = (p: string) => (p || '').replaceAll('\\', '/').split('/').pop() || ''
function videoSrcFromJob(job: RecordJob | null): string {
if (!job) return ''
const file = baseName(job.output || '')
if (!file) return ''
return `/api/record/video?file=${encodeURIComponent(file)}`
}
function replaceBasename(fullPath: string, newBase: string) {
const norm = (fullPath || '').replaceAll('\\', '/')
const parts = norm.split('/')
@ -443,6 +451,9 @@ export default function App() {
const [playerJob, setPlayerJob] = useState<RecordJob | null>(null)
const [playerExpanded, setPlayerExpanded] = useState(false)
const [playerStartAtSec, setPlayerStartAtSec] = useState<number | null>(null)
const [splitJob, setSplitJob] = useState<RecordJob | null>(null)
const [splitModalOpen, setSplitModalOpen] = useState(false)
const [splitModalKey, setSplitModalKey] = useState(0)
const [assetNonce, setAssetNonce] = useState(0)
const bumpAssets = useCallback(() => setAssetNonce((n) => n + 1), [])
@ -516,6 +527,9 @@ export default function App() {
setDoneCount(0)
setDonePage(1)
setSplitJob(null)
setSplitModalOpen(false)
setModelsByKey({})
setModelsCount(0)
@ -1154,6 +1168,40 @@ export default function App() {
return () => window.removeEventListener('open-model-details', onOpen as any)
}, [])
useEffect(() => {
const onOpen = (ev: Event) => {
const e = ev as CustomEvent<{ jobId?: string; output?: string }>
const jobId = String(e.detail?.jobId ?? '').trim()
const output = String(e.detail?.output ?? '').trim()
let hit: RecordJob | null = null
if (jobId) {
hit =
jobs.find((j) => String((j as any)?.id ?? '').trim() === jobId) ??
doneJobs.find((j) => String((j as any)?.id ?? '').trim() === jobId) ??
null
}
if (!hit && output) {
const wanted = baseName(output)
hit =
jobs.find((j) => baseName(j.output || '') === wanted) ??
doneJobs.find((j) => baseName(j.output || '') === wanted) ??
null
}
if (hit) {
setSplitJob(hit)
setSplitModalKey((k) => k + 1)
setSplitModalOpen(true)
}
}
window.addEventListener('open-video-splitter', onOpen as EventListener)
return () => window.removeEventListener('open-video-splitter', onOpen as EventListener)
}, [jobs, doneJobs])
const upsertModelCache = useCallback((m: StoredModel) => {
const now = Date.now()
const cur = modelsCacheRef.current
@ -1240,7 +1288,6 @@ export default function App() {
}, [jobs])
// pending start falls gerade busy
const pendingStartUrlRef = useRef<string | null>(null)
const lastClipboardUrlRef = useRef<string>('')
// --- START QUEUE (parallel) ---
@ -1649,6 +1696,19 @@ export default function App() {
setRoomStatusByModelKey(next)
}, [jobs, modelsByKey, recSettings.useChaturbateApi])
function shouldQueueForRoomStatus(
show: string
): boolean {
const s = String(show || '').trim().toLowerCase()
return (
s === 'private' ||
s === 'hidden' ||
s === 'away' ||
s === 'offline' ||
s === 'unknown'
)
}
// ✅ StartURL (hier habe ich den alten Online-Fetch entfernt und nur Snapshot genutzt)
const startUrl = useCallback(async (rawUrl: string, opts?: { silent?: boolean }): Promise<boolean> => {
const norm0 = normalizeHttpUrl(rawUrl)
@ -1694,10 +1754,20 @@ export default function App() {
})
const mkLower = String(parsed?.modelKey ?? '').trim().toLowerCase()
if (mkLower) {
const upsertPendingRow = (showRaw?: unknown) => {
const upsertPendingRow = (opts?: {
show?: unknown
imageUrl?: string
chatRoomUrl?: string
}) => {
const model = modelsByKeyRef.current[mkLower] as any
const show = normalizePendingShow(showRaw ?? model?.roomStatus)
const show = normalizePendingShow(opts?.show ?? model?.roomStatus)
const imageUrl =
String(opts?.imageUrl ?? '').trim() ||
String(model?.imageUrl ?? '').trim() ||
undefined
setPendingWatchedRooms((prev) => {
const nextItem: PendingWatchedRoom = {
@ -1705,10 +1775,13 @@ export default function App() {
modelKey: mkLower,
url: norm,
currentShow: show,
imageUrl: String(model?.imageUrl ?? '').trim() || undefined,
imageUrl,
}
const idx = prev.findIndex((x) => String(x.modelKey ?? '').trim().toLowerCase() === mkLower)
const idx = prev.findIndex(
(x) => String(x.modelKey ?? '').trim().toLowerCase() === mkLower
)
if (idx >= 0) {
const copy = [...prev]
copy[idx] = { ...copy[idx], ...nextItem }
@ -1719,31 +1792,95 @@ export default function App() {
})
}
// 1) Wenn bereits busy: immer in Waiting
const enqueuePending = (opts?: {
show?: unknown
imageUrl?: string
chatRoomUrl?: string
}) => {
setPendingAutoStartByKey((prev) => {
const next = { ...(prev || {}), [mkLower]: norm }
pendingAutoStartByKeyRef.current = next
return next
})
upsertPendingRow(opts)
applyPendingRoomSnapshot(mkLower, {
show: normalizePendingShow(opts?.show),
imageUrl: String(opts?.imageUrl ?? '').trim() || undefined,
chatRoomUrl: String(opts?.chatRoomUrl ?? '').trim() || undefined,
})
}
// Wenn gerade andere Starts laufen -> direkt in Warteschlange
if (busyRef.current) {
setPendingAutoStartByKey((prev) => ({ ...(prev || {}), [mkLower]: norm }))
upsertPendingRow('public')
enqueuePending({ show: 'unknown' })
return true
}
// 2) aktuellen room_status aus dem Store prüfen
const model = modelsByKeyRef.current[mkLower] as any
const show = normalizePendingShow(model?.roomStatus)
// Live current_show prüfen
const live = await fetchChaturbateCurrentShow(mkLower)
const liveShow = normalizePendingShow(live?.show)
if (show === 'private' || show === 'hidden' || show === 'away') {
setPendingAutoStartByKey((prev) => ({ ...(prev || {}), [mkLower]: norm }))
upsertPendingRow(show)
if (shouldQueueForRoomStatus(liveShow)) {
enqueuePending({
show: liveShow,
imageUrl: live?.imageUrl,
chatRoomUrl: live?.chatRoomUrl,
})
return true
}
// public -> Snapshot aktualisieren und normal starten
applyPendingRoomSnapshot(mkLower, {
show: liveShow,
imageUrl: live?.imageUrl,
chatRoomUrl: live?.chatRoomUrl,
})
}
} catch {
// Wenn Live-Check fehlschlägt: lieber in Warteschlange statt blind starten
try {
const parsed = await apiJSON<ParsedModel>('/api/models/parse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: norm }),
})
const mkLower = String(parsed?.modelKey ?? '').trim().toLowerCase()
if (mkLower) {
setPendingAutoStartByKey((prev) => {
const next = { ...(prev || {}), [mkLower]: norm }
pendingAutoStartByKeyRef.current = next
return next
})
setPendingWatchedRooms((prev) => {
const nextItem: PendingWatchedRoom = {
id: mkLower,
modelKey: mkLower,
url: norm,
currentShow: 'unknown',
}
const idx = prev.findIndex(
(x) => String(x.modelKey ?? '').trim().toLowerCase() === mkLower
)
if (idx >= 0) {
const copy = [...prev]
copy[idx] = { ...copy[idx], ...nextItem }
return copy
}
return [nextItem, ...prev]
})
return true
}
} catch {
// parse fail -> normal starten
}
} else {
// Nicht-Chaturbate-API: wenn busy, wenigstens "pendingStart" setzen
if (busyRef.current) {
pendingStartUrlRef.current = norm
return true
}
}
@ -1855,6 +1992,12 @@ export default function App() {
)
}, [])
const openSplitModal = useCallback((job: RecordJob) => {
setSplitJob(job)
setSplitModalKey((k) => k + 1)
setSplitModalOpen(true)
}, [])
// ✅ Anzahl Watched Models (aus Store), die online sind
const onlineWatchedModelsCount = useMemo(() => {
let c = 0
@ -2004,12 +2147,6 @@ export default function App() {
}
}, [selectedTab, loadDoneCount, requestFinishedReload])
useEffect(() => {
const maxPage = Math.max(1, Math.ceil(doneCount / DONE_PAGE_SIZE))
if (donePage > maxPage) setDonePage(maxPage)
}, [doneCount, donePage])
useEffect(() => {
if (!authed) return
@ -3103,6 +3240,7 @@ export default function App() {
onToggleLike={handleToggleLike}
onToggleWatch={handleToggleWatch}
onKeepJob={handleKeepJob}
onSplitJob={openSplitModal}
blurPreviews={Boolean(recSettings.blurPreviews)}
teaserPlayback={recSettings.teaserPlayback ?? 'hover'}
teaserAudio={Boolean(recSettings.teaserAudio)}
@ -3159,6 +3297,26 @@ export default function App() {
onStopJob={stopJob}
/>
<VideoSplitModal
key={splitModalKey}
open={splitModalOpen}
job={splitJob}
videoSrc={videoSrcFromJob(splitJob)}
timelinePreviewCount={8}
onClose={() => {
setSplitModalOpen(false)
setSplitJob(null)
}}
onApply={async ({ job, splits, segments }) => {
console.log('VIDEO_SPLIT_APPLY', {
jobId: job.id,
output: job.output,
splits,
segments,
})
}}
/>
{playerJob ? (
<Player
key={[

View File

@ -285,6 +285,9 @@ const phaseLabel = (p?: string) => {
case 'assets':
return 'Erstelle Vorschau/Thumbnails…'
case 'analyze':
return 'AI analysiert Segmente…'
case 'postwork':
return 'Nacharbeiten laufen…'
@ -464,15 +467,15 @@ function DownloadsCardRow({
return (
<div
className="
relative overflow-hidden rounded-2xl border border-white/40 bg-white/35 shadow-sm
backdrop-blur-xl supports-[backdrop-filter]:bg-white/25
relative overflow-hidden rounded-2xl
border border-gray-200/90 bg-white shadow-sm
ring-1 ring-black/5
transition-all hover:-translate-y-0.5 hover:shadow-md active:translate-y-0
dark:border-white/10 dark:bg-gray-950/35 dark:supports-[backdrop-filter]:bg-gray-950/25
"
>
{/* subtle gradient */}
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-white/70 via-white/20 to-white/60 dark:from-white/10 dark:via-transparent dark:to-white/5" />
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-gray-50/90 via-white to-gray-50/70 dark:from-white/10 dark:via-transparent dark:to-white/5" />
<div className="relative p-3">
<div className="flex items-start justify-between gap-2">
@ -613,8 +616,8 @@ function DownloadsCardRow({
return (
<div
className="
group relative overflow-hidden rounded-2xl border border-white/40 bg-white/35 shadow-sm
backdrop-blur-xl supports-[backdrop-filter]:bg-white/25
group relative overflow-hidden rounded-2xl
border border-gray-200/90 bg-white shadow-sm
ring-1 ring-black/5
transition-all hover:-translate-y-0.5 hover:shadow-md active:translate-y-0
dark:border-white/10 dark:bg-gray-950/35 dark:supports-[backdrop-filter]:bg-gray-950/25
@ -627,7 +630,7 @@ function DownloadsCardRow({
}}
>
{/* subtle gradient */}
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-white/70 via-white/20 to-white/60 dark:from-white/10 dark:via-transparent dark:to-white/5" />
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-gray-50/90 via-white to-gray-50/70 dark:from-white/10 dark:via-transparent dark:to-white/5" />
<div className="pointer-events-none absolute -inset-10 opacity-0 blur-2xl transition-opacity duration-300 group-hover:opacity-100 bg-white/40 dark:bg-white/10" />
<div className="relative p-3">
@ -752,7 +755,8 @@ function DownloadsCardRow({
<Button
size="sm"
variant={showRemoveQueuedButton ? 'secondary' : 'primary'}
variant="primary"
color={isQueuedPostwork ? 'red' : 'indigo'}
disabled={disableStopButton}
className="shrink-0"
onClick={async (e) => {
@ -1768,7 +1772,7 @@ export default function Downloads({
{postworkRows.length > 0 ? (
<>
<div className="mt-2 text-xs font-semibold text-gray-700 dark:text-gray-200">
<div className="mb-2 text-sm font-semibold text-gray-900 dark:text-white">
Nacharbeiten ({postworkRows.length})
</div>
{postworkRows.map((r) => (
@ -1826,8 +1830,8 @@ export default function Downloads({
/* Desktop: Tabellen (wirklich nur desktop gemountet) */
<div className="mt-3 space-y-4">
{downloadJobRows.length > 0 ? (
<div className="overflow-x-auto">
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
<div className="overflow-x-auto rounded-2xl border border-gray-200/80 bg-white/80 p-2 shadow-sm dark:border-white/10 dark:bg-transparent dark:p-0">
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-xl border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm dark:border-white/10 dark:bg-white/5 dark:text-white">
<span>Downloads ({downloadJobRows.length})</span>
{watchedPausedByDisk ? <DiskEmergencyBadge /> : null}
</div>
@ -1848,8 +1852,8 @@ export default function Downloads({
) : null}
{postworkRows.length > 0 ? (
<div className="overflow-x-auto">
<div className="mb-2 text-sm font-semibold text-gray-900 dark:text-white">
<div className="overflow-x-auto rounded-2xl border border-amber-200/80 bg-white/80 p-2 shadow-sm dark:border-white/10 dark:bg-transparent dark:p-0">
<div className="mb-2 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-sm font-semibold text-amber-900 shadow-sm dark:border-white/10 dark:bg-white/5 dark:text-white">
Nacharbeiten ({postworkRows.length})
</div>
<Table
@ -1869,8 +1873,8 @@ export default function Downloads({
) : null}
{pendingRows.length > 0 ? (
<div className="overflow-x-auto">
<div className="mb-2 text-sm font-semibold text-gray-900 dark:text-white">
<div className="overflow-x-auto rounded-2xl border border-slate-200/80 bg-white/80 p-2 shadow-sm dark:border-white/10 dark:bg-transparent dark:p-0">
<div className="mb-2 rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm font-semibold text-slate-800 shadow-sm dark:border-white/10 dark:bg-white/5 dark:text-white">
Wartend ({pendingRows.length})
</div>
<Table

View File

@ -58,6 +58,7 @@ type Props = {
onToggleLike?: (job: RecordJob) => void | Promise<void>
onToggleWatch?: (job: RecordJob) => void | Promise<void>
onKeepJob?: (job: RecordJob) => void | Promise<void>
onSplitJob?: (job: RecordJob) => void | Promise<void>
doneTotal: number
page: number
pageSize: number
@ -315,6 +316,7 @@ export default function FinishedDownloads({
onToggleLike,
onToggleWatch,
onKeepJob,
onSplitJob,
doneTotal,
page,
pageSize,
@ -712,26 +714,24 @@ export default function FinishedDownloads({
])
useEffect(() => {
if (effectiveAllMode) return
// Override-Daten nur dann zurücksetzen, wenn wir wirklich wieder auf
// die normalen /done-Props zurückfallen wollen.
if (effectiveAllMode || includeKeep) return
setOverrideDoneJobs(null)
setOverrideDoneTotal(null)
}, [page, pageSize, sortMode, includeKeep, effectiveAllMode])
useEffect(() => {
if (!includeKeep) {
// zurück auf "nur /done/" (Props)
if (!globalFilterActive) {
setOverrideDoneJobs(null)
setOverrideDoneTotal(null)
}
return
}
if (effectiveAllMode) return
// includeKeep = true:
// - wenn Filter aktiv -> fetchAllDoneJobs macht das bereits (mit includeKeep)
if (globalFilterActive) return
// Nur nötig, wenn wir NICHT auf die normalen Props zurückfallen.
if (!includeKeep) return
const ac = new AbortController()
let alive = true
setIsLoading(true)
;(async () => {
try {
@ -739,19 +739,27 @@ export default function FinishedDownloads({
`/api/record/done?page=${page}&pageSize=${pageSize}&sort=${encodeURIComponent(sortMode)}&withCount=1&includeKeep=1`,
{ cache: 'no-store' as any, signal: ac.signal }
)
if (!res.ok) return
if (!res.ok || !alive || ac.signal.aborted) return
const data = await res.json().catch(() => null)
if (!alive || ac.signal.aborted) return
const items = Array.isArray(data?.items) ? (data.items as RecordJob[]) : []
const count = Number(data?.count ?? data?.totalCount ?? items.length)
const countRaw = Number(data?.count ?? data?.totalCount ?? items.length)
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : items.length
setOverrideDoneJobs(items)
setOverrideDoneTotal(Number.isFinite(count) ? count : items.length)
} catch {}
setOverrideDoneTotal(count)
} finally {
if (alive) setIsLoading(false)
}
})()
return () => ac.abort()
}, [includeKeep, globalFilterActive, page, pageSize, sortMode])
return () => {
alive = false
ac.abort()
}
}, [effectiveAllMode, includeKeep, page, pageSize, sortMode])
useEffect(() => {
try {
@ -1749,7 +1757,28 @@ export default function FinishedDownloads({
// ✅ .trash niemals anzeigen
if (isTrashOutput(j.output)) return false
return j.status === 'finished' || j.status === 'failed' || j.status === 'stopped'
const anyJ = j as any
const phase = String(anyJ?.phase ?? '').trim().toLowerCase()
const pw = anyJ?.postWork
const pwState = String(pw?.state ?? '').trim().toLowerCase()
const isActiveFinishedPostwork =
phase === 'probe' ||
phase === 'remuxing' ||
phase === 'moving' ||
phase === 'assets' ||
phase === 'postwork' ||
pwState === 'queued' ||
pwState === 'running'
// ✅ Alles, was noch in Nacharbeiten steckt, NICHT im Finished-Tab zeigen
if (isActiveFinishedPostwork) return false
return (
j.status === 'finished' ||
j.status === 'failed' ||
j.status === 'stopped'
)
})
return list
@ -2053,8 +2082,11 @@ export default function FinishedDownloads({
}, [isSmall])
useEffect(() => {
// Nicht während eines Seitenwechsels / Refills zurückspringen.
if (isLoading) return
if (emptyFolder && page !== 1) onPageChange(1)
}, [emptyFolder, page, onPageChange])
}, [emptyFolder, isLoading, page, onPageChange])
return (
<>
@ -2457,6 +2489,7 @@ export default function FinishedDownloads({
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
onSplit={onSplitJob}
activeTagSet={activeTagSet}
onToggleTagFilter={toggleTagFilter}
onHoverPreviewKeyChange={setHoverTeaserKey}
@ -2507,6 +2540,7 @@ export default function FinishedDownloads({
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
onSplit={onSplitJob}
deleteVideo={deleteVideo}
keepVideo={keepVideo}
enqueueDeleteVideo={enqueueDeleteVideo}
@ -2547,6 +2581,7 @@ export default function FinishedDownloads({
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
onSplit={onSplitJob}
activeTagSet={activeTagSet}
onToggleTagFilter={toggleTagFilter}
onHoverPreviewKeyChange={setHoverTeaserKey}

View File

@ -6,11 +6,6 @@ import Card from './Card'
import type { RecordJob } from '../../types'
import FinishedVideoPreview from './FinishedVideoPreview'
import SwipeCard, { type SwipeCardHandle } from './SwipeCard'
import {
StarIcon as StarSolidIcon,
HeartIcon as HeartSolidIcon,
EyeIcon as EyeSolidIcon,
} from '@heroicons/react/24/solid'
import RecordJobActions from './RecordJobActions'
import TagOverflowRow from './TagOverflowRow'
import PreviewScrubber from './PreviewScrubber'
@ -84,6 +79,7 @@ type Props = {
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
onToggleLike?: (job: RecordJob) => void | Promise<void>
onToggleWatch?: (job: RecordJob) => void | Promise<void>
onSplit?: (job: RecordJob) => void | Promise<void>
enqueueDeleteVideo?: (job: RecordJob) => boolean
enqueueKeepVideo?: (job: RecordJob) => boolean
@ -161,6 +157,189 @@ function chooseSpriteGrid(count: number): [number, number] {
return [bestCols, bestRows]
}
function postworkBadgeClass(tone: 'idle' | 'running' | 'done' | 'error') {
if (tone === 'running') {
return 'bg-amber-50 text-amber-800 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-200 dark:ring-amber-400/20'
}
if (tone === 'done') {
return 'bg-emerald-50 text-emerald-800 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-200 dark:ring-emerald-400/20'
}
if (tone === 'error') {
return 'bg-red-50 text-red-800 ring-red-200 dark:bg-red-500/10 dark:text-red-200 dark:ring-red-400/20'
}
return 'bg-gray-100 text-gray-700 ring-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10'
}
function isPostworkJob(job: RecordJob): boolean {
const anyJ = job as any
const phase = String(anyJ.phase ?? '').trim().toLowerCase()
const pw = anyJ.postWork
const pwKey = String(anyJ.postWorkKey ?? '').trim()
if (pwKey) return true
if (pw && (pw.state === 'queued' || pw.state === 'running')) return true
if (job.endedAt && phase) return true
if (phase === 'postwork') return true
return false
}
function getEffectivePostworkState(job: RecordJob): 'running' | 'queued' | 'none' {
const anyJ = job as any
const phase = String(anyJ.phase ?? '').trim().toLowerCase()
const pw = anyJ.postWork
const pwState = String(pw?.state ?? '').trim().toLowerCase()
const hasPwKey = String(anyJ.postWorkKey ?? '').trim() !== ''
if (!isPostworkJob(job)) return 'none'
// ✅ Für Finished-Tab: echte Postwork-Phasen/Queue-Hinweise dürfen auch
// bei status=finished weiter sichtbar bleiben.
if (pwState === 'queued') return 'queued'
if (pwState === 'running') return 'running'
if (
phase === 'postwork' ||
phase === 'probe' ||
phase === 'remuxing' ||
phase === 'moving' ||
phase === 'assets'
) {
return 'running'
}
if (
typeof pw?.position === 'number' &&
Number.isFinite(pw.position) &&
pw.position > 0
) {
return 'queued'
}
if (
typeof pw?.running === 'number' &&
Number.isFinite(pw.running) &&
pw.running > 0 &&
(!Number.isFinite(pw?.position) || pw.position <= 0)
) {
return 'running'
}
if (hasPwKey) return 'queued'
if (anyJ.endedAt || job.endedAt) return 'queued'
return 'none'
}
function phaseLabel(phaseRaw?: string): string {
switch (String(phaseRaw ?? '').trim().toLowerCase()) {
case 'probe':
return 'Analysiere Datei (Dauer/Streams)…'
case 'remuxing':
return 'Konvertiere Container zu MP4…'
case 'moving':
return 'Verschiebe nach Done…'
case 'assets':
return 'Erstelle Vorschau/Thumbnails…'
case 'postwork':
return 'Nacharbeiten laufen…'
default:
return ''
}
}
function postWorkLabel(job: RecordJob): string {
const anyJ = job as any
const pw = anyJ.postWork
const effectiveState = getEffectivePostworkState(job)
if (effectiveState === 'running') {
const running = typeof pw?.running === 'number' && pw.running > 0 ? pw.running : 1
const maxP = typeof pw?.maxParallel === 'number' ? pw.maxParallel : 0
return maxP > 0
? `Nacharbeiten laufen… (${running}/${maxP} parallel)`
: 'Nacharbeiten laufen…'
}
if (effectiveState === 'queued') {
const posServer = typeof pw?.position === 'number' ? pw.position : 0
const waitingServer = typeof pw?.waiting === 'number' ? pw.waiting : 0
const totalServer = Math.max(waitingServer, posServer)
return posServer > 0 && totalServer > 0
? `Warte auf Nacharbeiten… ${posServer} / ${totalServer}`
: 'Warte auf Nacharbeiten…'
}
return ''
}
function finishedPostworkBadge(job: RecordJob): {
label: string
tone: 'idle' | 'running' | 'done' | 'error'
} | null {
const anyJ = job as any
const phaseRaw = String(anyJ?.phase ?? '').trim()
const phase = phaseRaw.toLowerCase()
const effectiveState = getEffectivePostworkState(job)
if (effectiveState === 'none') return null
if (
phase === 'probe' ||
phase === 'remuxing' ||
phase === 'moving' ||
phase === 'assets'
) {
return {
label: phaseLabel(phaseRaw) || 'Nacharbeiten laufen…',
tone: 'running',
}
}
if (effectiveState === 'queued') {
return {
label: postWorkLabel(job) || 'Warte auf Nacharbeiten…',
tone: 'idle',
}
}
if (effectiveState === 'running') {
return {
label: phaseLabel(phaseRaw) || postWorkLabel(job) || 'Nacharbeiten laufen…',
tone: 'running',
}
}
const rawStatus =
String(anyJ?.postworkStatus ?? anyJ?.postprocessingStatus ?? '')
.trim()
.toLowerCase()
if (rawStatus === 'failed' || rawStatus === 'error') {
return {
label: 'Nacharbeiten fehlgeschlagen',
tone: 'error',
}
}
if (
rawStatus === 'done' ||
rawStatus === 'finished' ||
rawStatus === 'completed' ||
rawStatus === 'success'
) {
return {
label: 'Nacharbeiten fertig',
tone: 'done',
}
}
return null
}
function CardBlurWrapper({
blurred,
animateUnblurOnMount,
@ -388,6 +567,7 @@ export default function FinishedDownloadsCardsView({
onToggleFavorite,
onToggleLike,
onToggleWatch,
onSplit,
enqueueDeleteVideo,
enqueueKeepVideo,
@ -680,6 +860,7 @@ export default function FinishedDownloadsCardsView({
const isFav = Boolean(flags?.favorite)
const isLiked = flags?.liked === true
const isWatching = Boolean(flags?.watching)
const postworkStatus = finishedPostworkBadge(j)
const tags = parseTags(flags?.tags)
@ -911,9 +1092,17 @@ export default function FinishedDownloadsCardsView({
</div>
<div className="shrink-0 flex items-center gap-1.5 pt-0.5">
{isWatching ? <EyeSolidIcon className="size-4 text-sky-600 dark:text-sky-300" /> : null}
{isLiked ? <HeartSolidIcon className="size-4 text-rose-600 dark:text-rose-300" /> : null}
{isFav ? <StarSolidIcon className="size-4 text-amber-600 dark:text-amber-300" /> : null}
{postworkStatus ? (
<span
className={[
'inline-flex max-w-[220px] items-center rounded-full px-2 py-1 text-[11px] font-semibold ring-1 whitespace-nowrap',
postworkBadgeClass(postworkStatus.tone),
].join(' ')}
title={postworkStatus.label}
>
<span className="truncate">{postworkStatus.label}</span>
</span>
) : null}
</div>
</div>
@ -942,7 +1131,8 @@ export default function FinishedDownloadsCardsView({
onToggleHot={onToggleHot}
onKeep={keepVideo}
onDelete={deleteVideo}
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'details', 'add']}
onSplit={onSplit}
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']}
className="w-full gap-1.5"
/>
</div>

View File

@ -72,6 +72,7 @@ type Props = {
onToggleLike?: (job: RecordJob) => void | Promise<void>
onToggleWatch?: (job: RecordJob) => void | Promise<void>
onToggleHot: (job: RecordJob) => void | Promise<void>
onSplit?: (job: RecordJob) => void | Promise<void>
// optional queued actions (bevorzugt verwenden, falls vorhanden)
enqueueDeleteVideo?: (job: RecordJob) => boolean
@ -182,6 +183,7 @@ export default function FinishedDownloadsGalleryView({
onToggleFavorite,
onToggleLike,
onToggleWatch,
onSplit,
enqueueDeleteVideo,
enqueueKeepVideo,
enqueueToggleHot,
@ -679,7 +681,8 @@ export default function FinishedDownloadsGalleryView({
}
return deleteVideo(job)
}}
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'details', 'add']}
onSplit={onSplit}
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']}
className="w-full gap-1.5"
/>
</div>

View File

@ -70,6 +70,7 @@ type Props = {
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
onToggleLike?: (job: RecordJob) => void | Promise<void>
onToggleWatch?: (job: RecordJob) => void | Promise<void>
onSplit?: (job: RecordJob) => void | Promise<void>
deleteVideo: (job: RecordJob) => Promise<boolean>
keepVideo: (job: RecordJob) => Promise<boolean>
@ -122,6 +123,7 @@ export default function FinishedDownloadsTableView({
onToggleFavorite,
onToggleLike,
onToggleWatch,
onSplit,
deleteVideo,
keepVideo,
@ -460,7 +462,8 @@ export default function FinishedDownloadsTableView({
}
return deleteVideo(job)
}}
order={['watch', 'favorite', 'like', 'hot', 'details', 'add', 'keep', 'delete']}
onSplit={onSplit}
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']}
className="flex items-center justify-end gap-1"
/>
)

View File

@ -14,6 +14,7 @@ import {
HeartIcon as HeartOutlineIcon,
EyeIcon as EyeOutlineIcon,
ArrowDownTrayIcon,
ScissorsIcon,
} from '@heroicons/react/24/outline'
import {
FireIcon as FireSolidIcon,
@ -27,7 +28,16 @@ import { createPortal } from 'react-dom'
type Variant = 'overlay' | 'table'
type ActionKey = 'details' | 'add' | 'hot' | 'favorite' | 'like' | 'watch' | 'keep' | 'delete'
type ActionKey =
| 'details'
| 'add'
| 'split'
| 'hot'
| 'favorite'
| 'like'
| 'watch'
| 'keep'
| 'delete'
type ActionResult = void | boolean
type ActionFn = (job: RecordJob) => ActionResult | Promise<ActionResult>
@ -53,6 +63,7 @@ type Props = {
onDelete?: ActionFn
onToggleWatch?: ActionFn
onAddToDownloads?: ActionFn
onSplit?: ActionFn
order?: ActionKey[]
@ -102,6 +113,7 @@ export default function RecordJobActions({
onDelete,
onToggleWatch,
onAddToDownloads,
onSplit,
order,
className,
}: Props) {
@ -144,7 +156,7 @@ export default function RecordJobActions({
// ✅ Reihenfolge strikt nach `order` (wenn gesetzt). Keys die nicht im order stehen: niemals anzeigen.
const actionOrder: ActionKey[] = order ?? ['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'details']
const actionOrder: ActionKey[] = order ?? ['watch', 'favorite', 'like', 'split', 'hot', 'keep', 'delete', 'details']
const inOrder = (k: ActionKey) => actionOrder.includes(k)
const addUrl = String((job as any)?.sourceUrl ?? '').trim()
@ -158,6 +170,7 @@ export default function RecordJobActions({
const wantWatch = inOrder('watch')
const wantKeep = inOrder('keep')
const wantDelete = inOrder('delete')
const wantSplit = inOrder('split') && Boolean(baseName(job.output || ''))
// Details: wenn du ihn auch ohne detailsKey zeigen willst, nimm nur inOrder('details').
// (Aktuell macht Details ohne detailsKey wenig Sinn, daher: nur anzeigen wenn key existiert.)
@ -394,6 +407,21 @@ export default function RecordJobActions({
</button>
) : null
const SplitBtn = wantSplit ? (
<button
type="button"
className={btnBase}
title="Video schneiden"
aria-label="Video schneiden"
disabled={busy || !onSplit}
onClick={run(onSplit)}
>
<span className={cn('inline-flex items-center justify-center', iconBox)}>
<ScissorsIcon className={cn(iconFill, colors.off)} />
</span>
</button>
) : null
const KeepBtn = wantKeep ? (
<button
type="button"
@ -427,6 +455,7 @@ export default function RecordJobActions({
const byKey: Record<ActionKey, React.ReactNode> = {
details: DetailsBtn,
add: AddBtn,
split: SplitBtn,
favorite: FavoriteBtn,
like: LikeBtn,
watch: WatchBtn,
@ -620,6 +649,26 @@ export default function RecordJobActions({
)
}
if (k === 'split') {
return (
<button
key="split"
type="button"
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-gray-100/70 dark:hover:bg-white/5 disabled:opacity-50"
disabled={busy || !onSplit}
onClick={async (e) => {
e.preventDefault()
e.stopPropagation()
setMenuOpen(false)
await onSplit?.(job)
}}
>
<ScissorsIcon className={cn('size-4', colors.off)} />
<span className="truncate">Video schneiden</span>
</button>
)
}
if (k === 'favorite') {
return (
<button

File diff suppressed because it is too large Load Diff