updated ai detection
This commit is contained in:
parent
9f2dd0ff3b
commit
e7a13652f5
819
backend/analyze.go
Normal file
819
backend/analyze.go
Normal 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)
|
||||||
|
}
|
||||||
BIN
backend/assets/nsfw/320n.onnx
Normal file
BIN
backend/assets/nsfw/320n.onnx
Normal file
Binary file not shown.
BIN
backend/assets/nsfw/onnxruntime.dll
Normal file
BIN
backend/assets/nsfw/onnxruntime.dll
Normal file
Binary file not shown.
@ -1,8 +1,9 @@
|
|||||||
// backend/generate.go
|
// backend\assets_generate.go
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
@ -267,8 +268,18 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
|
|||||||
meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi)
|
meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi)
|
||||||
out.MetaOK = meta.ok
|
out.MetaOK = meta.ok
|
||||||
|
|
||||||
// Wenn alles da ist: skipped
|
// Wenn alles da ist: als skipped markieren,
|
||||||
if thumbBefore && previewBefore && spriteBefore && meta.ok {
|
// 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
|
out.Skipped = true
|
||||||
progress(1)
|
progress(1)
|
||||||
return out, nil
|
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
|
var spriteMeta *previewSpriteMeta
|
||||||
|
|
||||||
if spriteBefore {
|
|
||||||
// Meta trotzdem vorbereiten (für JSON)
|
|
||||||
if meta.durSec > 0 {
|
if meta.durSec > 0 {
|
||||||
stepSec := 5.0
|
cols, rows, count, cellW, cellH := fixedPreviewSpriteLayout()
|
||||||
count := int(math.Floor(meta.durSec/stepSec)) + 1
|
stepSec := previewSpriteStepSeconds(meta.durSec)
|
||||||
if count < 1 {
|
|
||||||
count = 1
|
|
||||||
}
|
|
||||||
if count > 200 {
|
|
||||||
count = 200 // Schutz
|
|
||||||
}
|
|
||||||
cols, rows := chooseSpriteGrid(count)
|
|
||||||
|
|
||||||
spriteMeta = &previewSpriteMeta{
|
spriteMeta = &previewSpriteMeta{
|
||||||
Path: fmt.Sprintf("/api/preview-sprite/%s", id),
|
Path: fmt.Sprintf("/api/preview-sprite/%s", id),
|
||||||
@ -435,15 +437,17 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
|
|||||||
Cols: cols,
|
Cols: cols,
|
||||||
Rows: rows,
|
Rows: rows,
|
||||||
StepSeconds: stepSec,
|
StepSeconds: stepSec,
|
||||||
|
CellWidth: cellW,
|
||||||
|
CellHeight: cellH,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
|
if !spriteBefore {
|
||||||
func() {
|
func() {
|
||||||
if sourceInputInvalid {
|
if sourceInputInvalid {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// nur sinnvoll wenn wir Dauer kennen
|
|
||||||
if !(meta.durSec > 0) {
|
if !(meta.durSec > 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -456,37 +460,24 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
|
|||||||
}
|
}
|
||||||
defer genSem.Release()
|
defer genSem.Release()
|
||||||
|
|
||||||
stepSec := 5.0
|
cols, rows, _, cellW, cellH := fixedPreviewSpriteLayout()
|
||||||
count := int(math.Floor(meta.durSec/stepSec)) + 1
|
stepSec := previewSpriteStepSeconds(meta.durSec)
|
||||||
if count < 1 {
|
|
||||||
count = 1
|
|
||||||
}
|
|
||||||
if count > 200 {
|
|
||||||
count = 200 // Schutz gegen riesige Sprites
|
|
||||||
}
|
|
||||||
|
|
||||||
cols, rows := chooseSpriteGrid(count)
|
if err := generatePreviewSpriteWebP(
|
||||||
|
genCtx,
|
||||||
// Zellgröße (16:9) für Gallery-Thumbs
|
videoPath,
|
||||||
cellW := 160
|
spritePath,
|
||||||
cellH := 90
|
cols,
|
||||||
|
rows,
|
||||||
if err := generatePreviewSpriteWebP(genCtx, videoPath, spritePath, cols, rows, stepSec, cellW, cellH); err != nil {
|
stepSec,
|
||||||
|
cellW,
|
||||||
|
cellH,
|
||||||
|
); err != nil {
|
||||||
fmt.Println("⚠️ preview sprite:", err)
|
fmt.Println("⚠️ preview sprite:", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
out.SpriteGenerated = true
|
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)
|
progress(1)
|
||||||
return out, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -5,53 +5,35 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// chooseSpriteGrid wählt ein sinnvolles cols/rows-Grid für count Frames.
|
const (
|
||||||
// Ziel: wenig leere Zellen + eher horizontales Layout (passt gut zu 16:9 Cells).
|
previewSpriteCols = 10
|
||||||
func chooseSpriteGrid(count int) (cols, rows int) {
|
previewSpriteRows = 8
|
||||||
if count <= 0 {
|
previewSpriteFrameCount = previewSpriteCols * previewSpriteRows
|
||||||
return 1, 1
|
previewSpriteCellW = 160
|
||||||
}
|
previewSpriteCellH = 90
|
||||||
if count == 1 {
|
)
|
||||||
return 1, 1
|
|
||||||
|
func fixedPreviewSpriteLayout() (cols, rows, count, cellW, cellH int) {
|
||||||
|
return previewSpriteCols, previewSpriteRows, previewSpriteFrameCount, previewSpriteCellW, previewSpriteCellH
|
||||||
|
}
|
||||||
|
|
||||||
|
func previewSpriteStepSeconds(durationSec float64) float64 {
|
||||||
|
if durationSec <= 0 {
|
||||||
|
return 5
|
||||||
}
|
}
|
||||||
|
|
||||||
targetRatio := 16.0 / 9.0 // wir bevorzugen horizontale Spritesheets
|
step := durationSec / float64(previewSpriteFrameCount)
|
||||||
bestCols, bestRows := 1, count
|
if step < 0.5 {
|
||||||
bestWaste := math.MaxInt
|
step = 0.5
|
||||||
bestRatioScore := math.MaxFloat64
|
|
||||||
|
|
||||||
for c := 1; c <= count; c++ {
|
|
||||||
r := int(math.Ceil(float64(count) / float64(c)))
|
|
||||||
if r <= 0 {
|
|
||||||
r = 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
waste := c*r - count
|
return step
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return bestCols, bestRows
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// generatePreviewSpriteWebP erzeugt ein statisches WebP-Spritesheet aus einem Video.
|
// 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)
|
return fmt.Errorf("generatePreviewSpriteWebP: invalid cell size %dx%d", cellW, cellH)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zielordner sicherstellen
|
|
||||||
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
|
||||||
return fmt.Errorf("mkdir sprite dir: %w", err)
|
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)
|
ext := filepath.Ext(outPath)
|
||||||
if ext == "" {
|
if ext == "" {
|
||||||
ext = ".webp"
|
ext = ".webp"
|
||||||
}
|
}
|
||||||
base := strings.TrimSuffix(outPath, ext)
|
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(
|
vf := fmt.Sprintf(
|
||||||
"fps=1/%g,scale=%d:%d:force_original_aspect_ratio=decrease:flags=lanczos,"+
|
"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",
|
"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,
|
cols, rows,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Statisches WebP-Spritesheet
|
|
||||||
cmd := exec.CommandContext(
|
cmd := exec.CommandContext(
|
||||||
ctx,
|
ctx,
|
||||||
"ffmpeg",
|
ffmpegPath,
|
||||||
"-hide_banner",
|
"-hide_banner",
|
||||||
"-loglevel", "error",
|
"-loglevel", "error",
|
||||||
"-y",
|
"-y",
|
||||||
@ -122,6 +102,7 @@ func generatePreviewSpriteWebP(
|
|||||||
"-an",
|
"-an",
|
||||||
"-sn",
|
"-sn",
|
||||||
"-vf", vf,
|
"-vf", vf,
|
||||||
|
"-vsync", "vfr",
|
||||||
"-frames:v", "1",
|
"-frames:v", "1",
|
||||||
"-c:v", "libwebp",
|
"-c:v", "libwebp",
|
||||||
"-lossless", "0",
|
"-lossless", "0",
|
||||||
@ -149,7 +130,6 @@ func generatePreviewSpriteWebP(
|
|||||||
return fmt.Errorf("sprite temp file invalid/empty")
|
return fmt.Errorf("sprite temp file invalid/empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Windows: Ziel vorher löschen, damit Rename klappt
|
|
||||||
_ = os.Remove(outPath)
|
_ = os.Remove(outPath)
|
||||||
if err := os.Rename(tmpPath, outPath); err != nil {
|
if err := os.Rename(tmpPath, outPath); err != nil {
|
||||||
_ = os.Remove(tmpPath)
|
_ = os.Remove(tmpPath)
|
||||||
|
|||||||
@ -182,39 +182,41 @@ func inFlightBytesForJob(j *RecordJob) uint64 {
|
|||||||
return sizeOfPathBestEffort(j.Output)
|
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)
|
const giB = uint64(1024 * 1024 * 1024)
|
||||||
|
|
||||||
// computeDiskThresholds:
|
// computeDiskThresholds:
|
||||||
// Pause = ceil( (2 * inFlightBytes) / GiB )
|
// Pause = max(lowDiskPauseBelowGB, ceil(relevantInFlightBytes / GiB))
|
||||||
// Resume = Pause + 3 GB (Hysterese)
|
// Resume = Pause + 3 GB (Hysterese)
|
||||||
// Wenn inFlight==0 => Pause/Resume = 0
|
//
|
||||||
func computeDiskThresholds() (pauseGB int, resumeGB int, inFlight uint64, pauseNeed uint64, resumeNeed uint64) {
|
// relevantInFlightBytes = Summe aller laufenden Downloads,
|
||||||
inFlight = sumInFlightBytes()
|
// deren aktuelle Dateigröße über AutoDeleteSmallDownloadsBelowMB liegt.
|
||||||
if inFlight == 0 {
|
//
|
||||||
return 0, 0, 0, 0, 0
|
// 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
|
dynamicPauseGB := 0
|
||||||
pauseGB = int((need + giB - 1) / giB) // ceil
|
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 {
|
if pauseGB > 10_000 {
|
||||||
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.
|
// Idee: Für TS->MP4 Peak brauchst du grob nochmal die Größe der aktuellen Datei als Reserve.
|
||||||
func sumInFlightBytes() uint64 {
|
func sumInFlightBytes() uint64 {
|
||||||
var sum uint64
|
var sum uint64
|
||||||
minKeepBytes := minRelevantInFlightBytes()
|
|
||||||
|
|
||||||
jobsMu.Lock()
|
jobsMu.Lock()
|
||||||
defer jobsMu.Unlock()
|
defer jobsMu.Unlock()
|
||||||
@ -246,19 +247,47 @@ func sumInFlightBytes() uint64 {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
b := inFlightBytesForJob(j)
|
sum += inFlightBytesForJob(j)
|
||||||
|
}
|
||||||
|
|
||||||
// ✅ Nur "relevante" Dateien berücksichtigen:
|
return sum
|
||||||
// Wenn Auto-Delete kleine Downloads aktiv ist, zählen wir nur Jobs,
|
}
|
||||||
// deren aktuelle Dateigröße bereits über der Schwelle liegt.
|
|
||||||
//
|
func sumInFlightBytesAboveAutoDeleteThreshold() uint64 {
|
||||||
// Hinweis: Ein Job kann später noch über die Schwelle wachsen.
|
s := getSettings()
|
||||||
// Diese Logik ist bewusst "weniger konservativ", so wie gewünscht.
|
|
||||||
if minKeepBytes > 0 && b > 0 && b < minKeepBytes {
|
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
|
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
|
return sum
|
||||||
@ -294,11 +323,11 @@ func startDiskSpaceGuard() {
|
|||||||
}
|
}
|
||||||
free := u.Free
|
free := u.Free
|
||||||
|
|
||||||
// ✅ Dynamische Schwellen:
|
// ✅ Schwellen:
|
||||||
// Pause = ceil((2 * inFlight) / GiB)
|
// Pause = max(config lowDiskPauseBelowGB, ceil((2 * inFlight) / GiB))
|
||||||
// Resume = Pause + 3 GB
|
// Resume = Pause + 3 GB
|
||||||
// pauseNeed/resumeNeed sind die benötigten freien Bytes
|
// pauseNeed/resumeNeed sind die benötigten freien Bytes
|
||||||
pauseGB, resumeGB, inFlight, pauseNeed, resumeNeed := computeDiskThresholds()
|
pauseGB, resumeGB, relevantInFlight, pauseNeed, resumeNeed := computeDiskThresholds()
|
||||||
|
|
||||||
// ✅ diskEmergency NICHT sticky behalten.
|
// ✅ diskEmergency NICHT sticky behalten.
|
||||||
// Stattdessen dynamisch mit Hysterese setzen/löschen:
|
// Stattdessen dynamisch mit Hysterese setzen/löschen:
|
||||||
@ -310,20 +339,6 @@ func startDiskSpaceGuard() {
|
|||||||
|
|
||||||
wasEmergency := atomic.LoadInt32(&diskEmergency) == 1
|
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
|
isLowForPause := free < pauseNeed
|
||||||
isHighEnoughForResume := free >= resumeNeed
|
isHighEnoughForResume := free >= resumeNeed
|
||||||
|
|
||||||
@ -337,11 +352,11 @@ func startDiskSpaceGuard() {
|
|||||||
broadcastAutostartPaused()
|
broadcastAutostartPaused()
|
||||||
|
|
||||||
fmt.Printf(
|
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(free)), free,
|
||||||
formatBytesSI(u64ToI64(pauseNeed)), pauseNeed,
|
formatBytesSI(u64ToI64(pauseNeed)), pauseNeed,
|
||||||
pauseGB, resumeGB,
|
pauseGB, resumeGB,
|
||||||
formatBytesSI(u64ToI64(inFlight)), inFlight,
|
formatBytesSI(u64ToI64(relevantInFlight)), relevantInFlight,
|
||||||
dir,
|
dir,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -358,11 +373,11 @@ func startDiskSpaceGuard() {
|
|||||||
broadcastAutostartPaused()
|
broadcastAutostartPaused()
|
||||||
|
|
||||||
fmt.Printf(
|
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(free)), free,
|
||||||
formatBytesSI(u64ToI64(resumeNeed)), resumeNeed,
|
formatBytesSI(u64ToI64(resumeNeed)), resumeNeed,
|
||||||
resumeGB,
|
resumeGB,
|
||||||
formatBytesSI(u64ToI64(inFlight)), inFlight,
|
formatBytesSI(u64ToI64(relevantInFlight)), relevantInFlight,
|
||||||
dir,
|
dir,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,118 +1,38 @@
|
|||||||
|
// backend\frontend.go
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Frontend (Vite build) als SPA ausliefern: Dateien aus dist, sonst index.html
|
// Vite-Build einbetten.
|
||||||
func registerFrontend(mux *http.ServeMux) {
|
// Beim Go-Build muss backend/web/dist bereits existieren.
|
||||||
// Kandidaten: zuerst ENV, dann typische Ordner
|
//
|
||||||
candidates := []string{
|
//go:embed web/dist web/dist/*
|
||||||
strings.TrimSpace(os.Getenv("FRONTEND_DIST")),
|
var embeddedFrontend embed.FS
|
||||||
"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"))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeFrontendHandler() (http.Handler, bool) {
|
func makeFrontendHandler() (http.Handler, bool) {
|
||||||
// Kandidaten: zuerst ENV, dann typische Ordner
|
distFS, err := fs.Sub(embeddedFrontend, "web/dist")
|
||||||
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 {
|
if err != nil {
|
||||||
continue
|
fmt.Println("⚠️ Frontend dist nicht im Binary gefunden – API läuft trotzdem:", err)
|
||||||
}
|
|
||||||
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.")
|
|
||||||
return nil, false
|
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) {
|
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// /api bleibt API
|
// /api bleibt API
|
||||||
@ -124,17 +44,23 @@ func makeFrontendHandler() (http.Handler, bool) {
|
|||||||
reqPath := r.URL.Path
|
reqPath := r.URL.Path
|
||||||
if reqPath == "" || reqPath == "/" {
|
if reqPath == "" || reqPath == "/" {
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// URL-Pfad in Dateisystem-Pfad umwandeln (ohne Traversal)
|
// URL-Pfad bereinigen
|
||||||
clean := path.Clean("/" + reqPath)
|
clean := path.Clean("/" + reqPath)
|
||||||
rel := strings.TrimPrefix(clean, "/")
|
rel := strings.TrimPrefix(clean, "/")
|
||||||
onDisk := filepath.Join(distAbs, filepath.FromSlash(rel))
|
|
||||||
|
|
||||||
if fi, err := os.Stat(onDisk); err == nil && !fi.IsDir() {
|
// Wenn echte Datei im embedded FS existiert -> ausliefern
|
||||||
ext := strings.ToLower(filepath.Ext(onDisk))
|
if fi, err := fs.Stat(distFS, rel); err == nil && !fi.IsDir() {
|
||||||
|
ext := strings.ToLower(path.Ext(rel))
|
||||||
if ext != "" && ext != ".html" {
|
if ext != "" && ext != ".html" {
|
||||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
} else {
|
} else {
|
||||||
@ -146,8 +72,22 @@ func makeFrontendHandler() (http.Handler, bool) {
|
|||||||
|
|
||||||
// SPA-Fallback
|
// SPA-Fallback
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
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
|
return h, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func registerFrontend(mux *http.ServeMux) {
|
||||||
|
h, ok := makeFrontendHandler()
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mux.Handle("/", h)
|
||||||
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ require (
|
|||||||
github.com/jackc/pgx/v5 v5.8.0
|
github.com/jackc/pgx/v5 v5.8.0
|
||||||
github.com/pquerna/otp v1.5.0
|
github.com/pquerna/otp v1.5.0
|
||||||
github.com/r3labs/sse/v2 v2.10.0
|
github.com/r3labs/sse/v2 v2.10.0
|
||||||
|
github.com/yalue/onnxruntime_go v1.27.0
|
||||||
golang.org/x/crypto v0.47.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/go-sysconf v0.3.12 // indirect
|
||||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/text v0.33.0 // indirect
|
golang.org/x/text v0.35.0 // indirect
|
||||||
gopkg.in/cenkalti/backoff.v1 v1.1.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/andybalholm/cascadia v1.3.3 // indirect
|
||||||
github.com/shirou/gopsutil/v3 v3.24.5
|
github.com/shirou/gopsutil/v3 v3.24.5
|
||||||
github.com/sqweek/dialog v0.0.0-20240226140203-065105509627
|
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/net v0.48.0 // indirect
|
||||||
golang.org/x/sys v0.40.0 // indirect
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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/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 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||||
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
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/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 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
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.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 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
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.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
|
||||||
golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
|
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.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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.12.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.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.7.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.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
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-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-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.15.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.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
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-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.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
|||||||
@ -77,14 +77,6 @@ func maybeBlockHLSOnPreview(w http.ResponseWriter, r *http.Request, basePath, fi
|
|||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
w.Header().Set("X-Preview-HLS-Disabled", "1")
|
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
|
http.Error(w, "HLS disabled on /api/preview; use /api/preview/live", http.StatusGone) // 410
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@ -543,9 +543,6 @@ func startAdaptiveSemController(ctx context.Context) {
|
|||||||
genSem.SetMax(genSem.Max() + 1)
|
genSem.SetMax(genSem.Max() + 1)
|
||||||
thumbSem.SetMax(thumbSem.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())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|||||||
165
backend/meta.go
165
backend/meta.go
@ -39,15 +39,33 @@ type videoMeta struct {
|
|||||||
VideoWidth int `json:"videoWidth,omitempty"`
|
VideoWidth int `json:"videoWidth,omitempty"`
|
||||||
VideoHeight int `json:"videoHeight,omitempty"`
|
VideoHeight int `json:"videoHeight,omitempty"`
|
||||||
FPS float64 `json:"fps,omitempty"`
|
FPS float64 `json:"fps,omitempty"`
|
||||||
Resolution string `json:"resolution,omitempty"` // z.B. "1920x1080"
|
Resolution string `json:"resolution,omitempty"`
|
||||||
|
|
||||||
SourceURL string `json:"sourceUrl,omitempty"`
|
SourceURL string `json:"sourceUrl,omitempty"`
|
||||||
PreviewClips []previewClip `json:"previewClips,omitempty"`
|
PreviewClips []previewClip `json:"previewClips,omitempty"`
|
||||||
PreviewSprite *previewSpriteMeta `json:"previewSprite,omitempty"`
|
PreviewSprite *previewSpriteMeta `json:"previewSprite,omitempty"`
|
||||||
|
AI *aiAnalysisMeta `json:"ai,omitempty"`
|
||||||
|
|
||||||
UpdatedAtUnix int64 `json:"updatedAtUnix"`
|
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)
|
// 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) {
|
func readVideoMeta(metaPath string, fi os.FileInfo) (dur float64, w int, h int, fps float64, ok bool) {
|
||||||
b, err := os.ReadFile(metaPath)
|
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),
|
SourceURL: strings.TrimSpace(sourceURL),
|
||||||
UpdatedAtUnix: time.Now().Unix(),
|
UpdatedAtUnix: time.Now().Unix(),
|
||||||
|
|
||||||
// ✅ bestehende Preview-Daten behalten
|
|
||||||
PreviewClips: nil,
|
PreviewClips: nil,
|
||||||
PreviewSprite: nil,
|
PreviewSprite: nil,
|
||||||
|
AI: nil,
|
||||||
}
|
}
|
||||||
|
|
||||||
if existing != nil {
|
if existing != nil {
|
||||||
m.PreviewClips = existing.PreviewClips
|
m.PreviewClips = existing.PreviewClips
|
||||||
m.PreviewSprite = existing.PreviewSprite
|
m.PreviewSprite = existing.PreviewSprite
|
||||||
|
m.AI = existing.AI
|
||||||
}
|
}
|
||||||
buf, err := json.Marshal(m)
|
buf, err := json.Marshal(m)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -378,6 +397,10 @@ func writeVideoMetaWithPreviewClips(metaPath string, fi os.FileInfo, dur float64
|
|||||||
UpdatedAtUnix: time.Now().Unix(),
|
UpdatedAtUnix: time.Now().Unix(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if existing != nil {
|
||||||
|
m.AI = existing.AI
|
||||||
|
}
|
||||||
|
|
||||||
// ✅ vorhandenes Sprite (inkl. stepSeconds) nicht wegwerfen
|
// ✅ vorhandenes Sprite (inkl. stepSeconds) nicht wegwerfen
|
||||||
if existing != nil && existing.PreviewSprite != nil {
|
if existing != nil && existing.PreviewSprite != nil {
|
||||||
m.PreviewSprite = existing.PreviewSprite
|
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)
|
buf, err := json.Marshal(m)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -538,3 +565,137 @@ func sanitizeID(id string) (string, error) {
|
|||||||
}
|
}
|
||||||
return id, nil
|
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
117
backend/nsfw_assets.go
Normal 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
409
backend/nsfw_detector.go
Normal 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
14
backend/nsfw_types.go
Normal 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"`
|
||||||
|
}
|
||||||
@ -144,10 +144,7 @@ func (pq *PostWorkQueue) workerLoop(id int) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1) Heavy-Gate: erst wenn ein Slot frei ist, gilt der Task als "running"
|
// Task startet jetzt wirklich → waiting -> running
|
||||||
pq.ffmpegSem <- struct{}{}
|
|
||||||
|
|
||||||
// 2) Ab hier startet er wirklich → waiting -> running
|
|
||||||
pq.mu.Lock()
|
pq.mu.Lock()
|
||||||
pq.removeWaitingKeyLocked(task.Key)
|
pq.removeWaitingKeyLocked(task.Key)
|
||||||
pq.runningKeys[task.Key] = struct{}{}
|
pq.runningKeys[task.Key] = struct{}{}
|
||||||
@ -170,9 +167,6 @@ func (pq *PostWorkQueue) workerLoop(id int) {
|
|||||||
pq.queued--
|
pq.queued--
|
||||||
}
|
}
|
||||||
pq.mu.Unlock()
|
pq.mu.Unlock()
|
||||||
|
|
||||||
// Slot freigeben
|
|
||||||
<-pq.ffmpegSem
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// 3) Optional: Task timeout (gegen hängende ffmpeg)
|
// 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)
|
// 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) ---
|
// --- Status Refresher (ehemals postwork_refresh.go) ---
|
||||||
|
|
||||||
|
|||||||
@ -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 tStr := strings.TrimSpace(r.URL.Query().Get("t")); tStr != "" {
|
||||||
if sec, err := strconv.ParseFloat(tStr, 64); err == nil && sec >= 0 {
|
if sec, err := strconv.ParseFloat(tStr, 64); err == nil && sec >= 0 {
|
||||||
secI := int64(sec + 0.5)
|
if sec < 0 {
|
||||||
if secI < 0 {
|
sec = 0
|
||||||
secI = 0
|
|
||||||
}
|
}
|
||||||
framePath := filepath.Join(assetDir, fmt.Sprintf("t_%d.webp", secI))
|
|
||||||
if fi, err := os.Stat(framePath); err == nil && !fi.IsDir() && fi.Size() > 0 {
|
img, err := extractFrameAtTimeWebP(outPath, sec)
|
||||||
servePreviewWebPFile(w, r, framePath)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
img, err := extractFrameAtTimeWebP(outPath, float64(secI))
|
|
||||||
if err == nil && len(img) > 0 {
|
if err == nil && len(img) > 0 {
|
||||||
_ = atomicWriteFile(framePath, img)
|
|
||||||
servePreviewWebPBytes(w, img)
|
servePreviewWebPBytes(w, img)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,6 +22,18 @@ import (
|
|||||||
|
|
||||||
// ---------------- Types ----------------
|
// ---------------- 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 {
|
type RecordRequest struct {
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Cookie string `json:"cookie,omitempty"`
|
Cookie string `json:"cookie,omitempty"`
|
||||||
@ -54,6 +66,7 @@ type doneMetaFileResp struct {
|
|||||||
FPS float64 `json:"fps,omitempty"`
|
FPS float64 `json:"fps,omitempty"`
|
||||||
SourceURL string `json:"sourceUrl,omitempty"`
|
SourceURL string `json:"sourceUrl,omitempty"`
|
||||||
PreviewSprite previewSpriteMetaResp `json:"previewSprite"`
|
PreviewSprite previewSpriteMetaResp `json:"previewSprite"`
|
||||||
|
AI any `json:"ai,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -115,6 +128,38 @@ func mustMethod(w http.ResponseWriter, r *http.Request, methods ...string) bool
|
|||||||
return false
|
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) ----------------
|
// ---------------- Preview sprite truth (shared) ----------------
|
||||||
|
|
||||||
type previewSpriteMetaFileInfo struct {
|
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 mp, merr := generatedMetaFile(id); merr == nil && strings.TrimSpace(mp) != "" {
|
||||||
if mfi, serr := os.Stat(mp); serr == nil && mfi != nil && !mfi.IsDir() && mfi.Size() > 0 {
|
if mfi, serr := os.Stat(mp); serr == nil && mfi != nil && !mfi.IsDir() && mfi.Size() > 0 {
|
||||||
resp.MetaExists = true
|
resp.MetaExists = true
|
||||||
|
|
||||||
if dur, w2, h2, fps2, ok := readVideoMeta(mp, fi); ok {
|
if dur, w2, h2, fps2, ok := readVideoMeta(mp, fi); ok {
|
||||||
resp.DurationSeconds = dur
|
resp.DurationSeconds = dur
|
||||||
resp.Width = w2
|
resp.Width = w2
|
||||||
@ -1231,6 +1277,10 @@ func recordDoneMeta(w http.ResponseWriter, r *http.Request) {
|
|||||||
if u, ok := readVideoMetaSourceURL(mp, fi); ok {
|
if u, ok := readVideoMetaSourceURL(mp, fi); ok {
|
||||||
resp.SourceURL = u
|
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
|
sortedAll := doneCache.sortedIdx
|
||||||
doneCache.mu.Unlock()
|
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
|
count := 0
|
||||||
if qModel == "" {
|
if qModel == "" {
|
||||||
incKey := "0"
|
incKey := "0"
|
||||||
if includeKeep {
|
if includeKeep {
|
||||||
incKey = "1"
|
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 {
|
} else {
|
||||||
for _, it := range items {
|
for _, it := range items {
|
||||||
if !includeKeep && it.fromKeep {
|
if !includeKeep && it.fromKeep {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if it.modelKey == qModel {
|
if it.modelKey != qModel {
|
||||||
count++
|
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
|
start := 0
|
||||||
end := totalCount
|
end := totalCount
|
||||||
@ -1554,7 +1685,7 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
out := make([]*RecordJob, 0, max(0, end-start))
|
out := make([]*RecordJob, 0, max(0, end-start))
|
||||||
|
|
||||||
for _, ii := range idx[start:end] {
|
for _, ii := range filteredIdx[start:end] {
|
||||||
base := items[ii].job
|
base := items[ii].job
|
||||||
if base == nil {
|
if base == nil {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@ -85,6 +85,20 @@ func setNoStoreHeaders(w http.ResponseWriter) {
|
|||||||
|
|
||||||
// ---------- Resolve dirs ----------
|
// ---------- 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) {
|
func resolvePathRelativeToApp(p string) (string, error) {
|
||||||
p = strings.TrimSpace(p)
|
p = strings.TrimSpace(p)
|
||||||
if p == "" {
|
if p == "" {
|
||||||
@ -96,10 +110,9 @@ func resolvePathRelativeToApp(p string) (string, error) {
|
|||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
exe, err := os.Executable()
|
baseDir, err := exeDir()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
exeDir := filepath.Dir(exe)
|
low := strings.ToLower(baseDir)
|
||||||
low := strings.ToLower(exeDir)
|
|
||||||
|
|
||||||
// Heuristik: go run / tests -> exe liegt in Temp/go-build
|
// Heuristik: go run / tests -> exe liegt in Temp/go-build
|
||||||
isTemp := strings.Contains(low, `\appdata\local\temp`) ||
|
isTemp := strings.Contains(low, `\appdata\local\temp`) ||
|
||||||
@ -110,7 +123,7 @@ func resolvePathRelativeToApp(p string) (string, error) {
|
|||||||
strings.Contains(low, `/go-build`)
|
strings.Contains(low, `/go-build`)
|
||||||
|
|
||||||
if !isTemp {
|
if !isTemp {
|
||||||
return filepath.Join(exeDir, p), nil
|
return filepath.Join(baseDir, p), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -37,13 +37,15 @@ func setJobProgress(job *RecordJob, phase string, pct int) {
|
|||||||
case "postwork":
|
case "postwork":
|
||||||
return rng{0, 8}
|
return rng{0, 8}
|
||||||
case "remuxing":
|
case "remuxing":
|
||||||
return rng{8, 42}
|
return rng{8, 38}
|
||||||
case "moving":
|
case "moving":
|
||||||
return rng{42, 58}
|
return rng{38, 54}
|
||||||
case "probe":
|
case "probe":
|
||||||
return rng{58, 72}
|
return rng{54, 70}
|
||||||
case "assets":
|
case "assets":
|
||||||
return rng{72, 99}
|
return rng{70, 88}
|
||||||
|
case "analyze":
|
||||||
|
return rng{88, 99}
|
||||||
default:
|
default:
|
||||||
return rng{0, 100}
|
return rng{0, 100}
|
||||||
}
|
}
|
||||||
@ -448,7 +450,8 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|||||||
job.PostWorkKey = ""
|
job.PostWorkKey = ""
|
||||||
job.PostWork = nil
|
job.PostWork = nil
|
||||||
jobsMu.Unlock()
|
jobsMu.Unlock()
|
||||||
publishJobUpsert(job)
|
|
||||||
|
publishJobRemove(job)
|
||||||
notifyDoneChanged()
|
notifyDoneChanged()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -696,6 +699,37 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|||||||
}
|
}
|
||||||
setPhase("assets", 100)
|
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
|
// Finalize
|
||||||
jobsMu.Lock()
|
jobsMu.Lock()
|
||||||
job.Status = postTarget
|
job.Status = postTarget
|
||||||
@ -704,7 +738,8 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|||||||
job.PostWorkKey = ""
|
job.PostWorkKey = ""
|
||||||
job.PostWork = nil
|
job.PostWork = nil
|
||||||
jobsMu.Unlock()
|
jobsMu.Unlock()
|
||||||
publishJobUpsert(job)
|
|
||||||
|
publishJobRemove(job)
|
||||||
notifyDoneChanged()
|
notifyDoneChanged()
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
@ -724,7 +759,8 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|||||||
job.PostWorkKey = ""
|
job.PostWorkKey = ""
|
||||||
job.PostWork = nil
|
job.PostWork = nil
|
||||||
jobsMu.Unlock()
|
jobsMu.Unlock()
|
||||||
publishJobUpsert(job)
|
|
||||||
|
publishJobRemove(job)
|
||||||
notifyDoneChanged()
|
notifyDoneChanged()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
"useMyFreeCamsWatcher": true,
|
"useMyFreeCamsWatcher": true,
|
||||||
"autoDeleteSmallDownloads": false,
|
"autoDeleteSmallDownloads": false,
|
||||||
"autoDeleteSmallDownloadsBelowMB": 50,
|
"autoDeleteSmallDownloadsBelowMB": 50,
|
||||||
|
"lowDiskPauseBelowGB": 5,
|
||||||
"blurPreviews": false,
|
"blurPreviews": false,
|
||||||
"teaserPlayback": "hover",
|
"teaserPlayback": "hover",
|
||||||
"teaserAudio": false,
|
"teaserAudio": false,
|
||||||
|
|||||||
@ -54,12 +54,15 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
|
|||||||
api.HandleFunc("/api/record/list", recordList)
|
api.HandleFunc("/api/record/list", recordList)
|
||||||
api.HandleFunc("/api/record/done/meta", recordDoneMeta)
|
api.HandleFunc("/api/record/done/meta", recordDoneMeta)
|
||||||
api.HandleFunc("/api/record/video", recordVideo)
|
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/done", recordDoneList)
|
||||||
api.HandleFunc("/api/record/delete", recordDeleteVideo)
|
api.HandleFunc("/api/record/delete", recordDeleteVideo)
|
||||||
api.HandleFunc("/api/record/toggle-hot", recordToggleHot)
|
api.HandleFunc("/api/record/toggle-hot", recordToggleHot)
|
||||||
api.HandleFunc("/api/record/keep", recordKeepVideo)
|
api.HandleFunc("/api/record/keep", recordKeepVideo)
|
||||||
api.HandleFunc("/api/record/unkeep", recordUnkeepVideo)
|
api.HandleFunc("/api/record/unkeep", recordUnkeepVideo)
|
||||||
api.HandleFunc("/api/record/restore", recordRestoreVideo)
|
api.HandleFunc("/api/record/restore", recordRestoreVideo)
|
||||||
|
api.HandleFunc("/api/record/prepare-split", recordPrepareSplit)
|
||||||
|
|
||||||
api.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler)
|
api.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler)
|
||||||
api.HandleFunc("/api/chaturbate/biocontext", chaturbateBioContextHandler)
|
api.HandleFunc("/api/chaturbate/biocontext", chaturbateBioContextHandler)
|
||||||
|
|||||||
@ -3,9 +3,13 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- main ---
|
// --- main ---
|
||||||
@ -18,16 +22,21 @@ func main() {
|
|||||||
|
|
||||||
go startGeneratedGarbageCollector()
|
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()
|
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()
|
auth, err := NewAuthManager()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("❌ auth init:", err)
|
fmt.Println("❌ auth init:", err)
|
||||||
|
_ = closeNSFWDetector()
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,8 +54,31 @@ func main() {
|
|||||||
fmt.Println("🌐 HTTP-API aktiv: http://localhost:9999")
|
fmt.Println("🌐 HTTP-API aktiv: http://localhost:9999")
|
||||||
|
|
||||||
handler := withCORS(mux)
|
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)
|
fmt.Println("❌ HTTP-Server Fehler:", err)
|
||||||
|
_ = closeNSFWDetector()
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_ = closeNSFWDetector()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,6 +30,7 @@ type RecorderSettings struct {
|
|||||||
// Wenn aktiv, werden fertige Downloads automatisch gelöscht, wenn sie kleiner als der Grenzwert sind.
|
// Wenn aktiv, werden fertige Downloads automatisch gelöscht, wenn sie kleiner als der Grenzwert sind.
|
||||||
AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"`
|
AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"`
|
||||||
AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"`
|
AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"`
|
||||||
|
LowDiskPauseBelowGB int `json:"lowDiskPauseBelowGB"`
|
||||||
|
|
||||||
BlurPreviews bool `json:"blurPreviews"`
|
BlurPreviews bool `json:"blurPreviews"`
|
||||||
TeaserPlayback string `json:"teaserPlayback"` // still | hover | all
|
TeaserPlayback string `json:"teaserPlayback"` // still | hover | all
|
||||||
@ -58,6 +59,7 @@ var (
|
|||||||
UseMyFreeCamsWatcher: false,
|
UseMyFreeCamsWatcher: false,
|
||||||
AutoDeleteSmallDownloads: false,
|
AutoDeleteSmallDownloads: false,
|
||||||
AutoDeleteSmallDownloadsBelowMB: 50,
|
AutoDeleteSmallDownloadsBelowMB: 50,
|
||||||
|
LowDiskPauseBelowGB: 5,
|
||||||
|
|
||||||
BlurPreviews: false,
|
BlurPreviews: false,
|
||||||
TeaserPlayback: "hover",
|
TeaserPlayback: "hover",
|
||||||
@ -119,6 +121,12 @@ func loadSettings() {
|
|||||||
if s.AutoDeleteSmallDownloadsBelowMB > 100_000 {
|
if s.AutoDeleteSmallDownloadsBelowMB > 100_000 {
|
||||||
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()
|
settingsMu.Lock()
|
||||||
settings = s
|
settings = s
|
||||||
@ -205,6 +213,7 @@ type RecorderSettingsPublic struct {
|
|||||||
|
|
||||||
AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"`
|
AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"`
|
||||||
AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"`
|
AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"`
|
||||||
|
LowDiskPauseBelowGB int `json:"lowDiskPauseBelowGB"`
|
||||||
|
|
||||||
BlurPreviews bool `json:"blurPreviews"`
|
BlurPreviews bool `json:"blurPreviews"`
|
||||||
TeaserPlayback string `json:"teaserPlayback"`
|
TeaserPlayback string `json:"teaserPlayback"`
|
||||||
@ -230,6 +239,7 @@ func toPublicSettings(s RecorderSettings) RecorderSettingsPublic {
|
|||||||
|
|
||||||
AutoDeleteSmallDownloads: s.AutoDeleteSmallDownloads,
|
AutoDeleteSmallDownloads: s.AutoDeleteSmallDownloads,
|
||||||
AutoDeleteSmallDownloadsBelowMB: s.AutoDeleteSmallDownloadsBelowMB,
|
AutoDeleteSmallDownloadsBelowMB: s.AutoDeleteSmallDownloadsBelowMB,
|
||||||
|
LowDiskPauseBelowGB: s.LowDiskPauseBelowGB,
|
||||||
|
|
||||||
BlurPreviews: s.BlurPreviews,
|
BlurPreviews: s.BlurPreviews,
|
||||||
TeaserPlayback: s.TeaserPlayback,
|
TeaserPlayback: s.TeaserPlayback,
|
||||||
@ -294,6 +304,12 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
if in.AutoDeleteSmallDownloadsBelowMB > 100_000 {
|
if in.AutoDeleteSmallDownloadsBelowMB > 100_000 {
|
||||||
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) ---
|
// --- ensure folders (Fehler zurückgeben, falls z.B. keine Rechte) ---
|
||||||
recAbs, err := resolvePathRelativeToApp(in.RecordDir)
|
recAbs, err := resolvePathRelativeToApp(in.RecordDir)
|
||||||
|
|||||||
297
backend/split.go
Normal file
297
backend/split.go
Normal 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
|
||||||
|
}
|
||||||
@ -301,7 +301,13 @@ func runGenerateMissingAssets(ctx context.Context) {
|
|||||||
return
|
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) {
|
updateAssetsState(func(st *AssetsTaskState) {
|
||||||
if res.Skipped {
|
if res.Skipped {
|
||||||
st.Skipped++
|
st.Skipped++
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import { useNotify } from './components/ui/notify'
|
|||||||
//import { startChaturbateOnlinePolling } from './lib/chaturbateOnlinePoller'
|
//import { startChaturbateOnlinePolling } from './lib/chaturbateOnlinePoller'
|
||||||
import CategoriesTab from './components/ui/CategoriesTab'
|
import CategoriesTab from './components/ui/CategoriesTab'
|
||||||
import LoginPage from './components/ui/LoginPage'
|
import LoginPage from './components/ui/LoginPage'
|
||||||
|
import VideoSplitModal from './components/ui/VideoSplitModal'
|
||||||
|
|
||||||
const COOKIE_STORAGE_KEY = 'record_cookies'
|
const COOKIE_STORAGE_KEY = 'record_cookies'
|
||||||
|
|
||||||
@ -101,7 +102,7 @@ const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = {
|
|||||||
blurPreviews: false,
|
blurPreviews: false,
|
||||||
teaserPlayback: 'hover',
|
teaserPlayback: 'hover',
|
||||||
teaserAudio: false,
|
teaserAudio: false,
|
||||||
lowDiskPauseBelowGB: 3000,
|
lowDiskPauseBelowGB: 5,
|
||||||
}
|
}
|
||||||
|
|
||||||
type StoredModel = {
|
type StoredModel = {
|
||||||
@ -295,6 +296,13 @@ function mfcUserFromUrl(normUrl: string): string {
|
|||||||
|
|
||||||
const baseName = (p: string) => (p || '').replaceAll('\\', '/').split('/').pop() || ''
|
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) {
|
function replaceBasename(fullPath: string, newBase: string) {
|
||||||
const norm = (fullPath || '').replaceAll('\\', '/')
|
const norm = (fullPath || '').replaceAll('\\', '/')
|
||||||
const parts = norm.split('/')
|
const parts = norm.split('/')
|
||||||
@ -443,6 +451,9 @@ export default function App() {
|
|||||||
const [playerJob, setPlayerJob] = useState<RecordJob | null>(null)
|
const [playerJob, setPlayerJob] = useState<RecordJob | null>(null)
|
||||||
const [playerExpanded, setPlayerExpanded] = useState(false)
|
const [playerExpanded, setPlayerExpanded] = useState(false)
|
||||||
const [playerStartAtSec, setPlayerStartAtSec] = useState<number | null>(null)
|
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 [assetNonce, setAssetNonce] = useState(0)
|
||||||
const bumpAssets = useCallback(() => setAssetNonce((n) => n + 1), [])
|
const bumpAssets = useCallback(() => setAssetNonce((n) => n + 1), [])
|
||||||
@ -516,6 +527,9 @@ export default function App() {
|
|||||||
setDoneCount(0)
|
setDoneCount(0)
|
||||||
setDonePage(1)
|
setDonePage(1)
|
||||||
|
|
||||||
|
setSplitJob(null)
|
||||||
|
setSplitModalOpen(false)
|
||||||
|
|
||||||
setModelsByKey({})
|
setModelsByKey({})
|
||||||
setModelsCount(0)
|
setModelsCount(0)
|
||||||
|
|
||||||
@ -1154,6 +1168,40 @@ export default function App() {
|
|||||||
return () => window.removeEventListener('open-model-details', onOpen as any)
|
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 upsertModelCache = useCallback((m: StoredModel) => {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const cur = modelsCacheRef.current
|
const cur = modelsCacheRef.current
|
||||||
@ -1240,7 +1288,6 @@ export default function App() {
|
|||||||
}, [jobs])
|
}, [jobs])
|
||||||
|
|
||||||
// pending start falls gerade busy
|
// pending start falls gerade busy
|
||||||
const pendingStartUrlRef = useRef<string | null>(null)
|
|
||||||
const lastClipboardUrlRef = useRef<string>('')
|
const lastClipboardUrlRef = useRef<string>('')
|
||||||
|
|
||||||
// --- START QUEUE (parallel) ---
|
// --- START QUEUE (parallel) ---
|
||||||
@ -1649,6 +1696,19 @@ export default function App() {
|
|||||||
setRoomStatusByModelKey(next)
|
setRoomStatusByModelKey(next)
|
||||||
}, [jobs, modelsByKey, recSettings.useChaturbateApi])
|
}, [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)
|
// ✅ 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 startUrl = useCallback(async (rawUrl: string, opts?: { silent?: boolean }): Promise<boolean> => {
|
||||||
const norm0 = normalizeHttpUrl(rawUrl)
|
const norm0 = normalizeHttpUrl(rawUrl)
|
||||||
@ -1694,10 +1754,20 @@ export default function App() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const mkLower = String(parsed?.modelKey ?? '').trim().toLowerCase()
|
const mkLower = String(parsed?.modelKey ?? '').trim().toLowerCase()
|
||||||
|
|
||||||
if (mkLower) {
|
if (mkLower) {
|
||||||
const upsertPendingRow = (showRaw?: unknown) => {
|
const upsertPendingRow = (opts?: {
|
||||||
|
show?: unknown
|
||||||
|
imageUrl?: string
|
||||||
|
chatRoomUrl?: string
|
||||||
|
}) => {
|
||||||
const model = modelsByKeyRef.current[mkLower] as any
|
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) => {
|
setPendingWatchedRooms((prev) => {
|
||||||
const nextItem: PendingWatchedRoom = {
|
const nextItem: PendingWatchedRoom = {
|
||||||
@ -1705,10 +1775,13 @@ export default function App() {
|
|||||||
modelKey: mkLower,
|
modelKey: mkLower,
|
||||||
url: norm,
|
url: norm,
|
||||||
currentShow: show,
|
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) {
|
if (idx >= 0) {
|
||||||
const copy = [...prev]
|
const copy = [...prev]
|
||||||
copy[idx] = { ...copy[idx], ...nextItem }
|
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) {
|
if (busyRef.current) {
|
||||||
setPendingAutoStartByKey((prev) => ({ ...(prev || {}), [mkLower]: norm }))
|
enqueuePending({ show: 'unknown' })
|
||||||
upsertPendingRow('public')
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) aktuellen room_status aus dem Store prüfen
|
// Live current_show prüfen
|
||||||
const model = modelsByKeyRef.current[mkLower] as any
|
const live = await fetchChaturbateCurrentShow(mkLower)
|
||||||
const show = normalizePendingShow(model?.roomStatus)
|
const liveShow = normalizePendingShow(live?.show)
|
||||||
|
|
||||||
if (show === 'private' || show === 'hidden' || show === 'away') {
|
if (shouldQueueForRoomStatus(liveShow)) {
|
||||||
setPendingAutoStartByKey((prev) => ({ ...(prev || {}), [mkLower]: norm }))
|
enqueuePending({
|
||||||
upsertPendingRow(show)
|
show: liveShow,
|
||||||
|
imageUrl: live?.imageUrl,
|
||||||
|
chatRoomUrl: live?.chatRoomUrl,
|
||||||
|
})
|
||||||
return true
|
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 {
|
} catch {
|
||||||
// parse fail -> normal starten
|
// 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
|
// ✅ Anzahl Watched Models (aus Store), die online sind
|
||||||
const onlineWatchedModelsCount = useMemo(() => {
|
const onlineWatchedModelsCount = useMemo(() => {
|
||||||
let c = 0
|
let c = 0
|
||||||
@ -2004,12 +2147,6 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [selectedTab, loadDoneCount, requestFinishedReload])
|
}, [selectedTab, loadDoneCount, requestFinishedReload])
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const maxPage = Math.max(1, Math.ceil(doneCount / DONE_PAGE_SIZE))
|
|
||||||
if (donePage > maxPage) setDonePage(maxPage)
|
|
||||||
}, [doneCount, donePage])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authed) return
|
if (!authed) return
|
||||||
|
|
||||||
@ -3103,6 +3240,7 @@ export default function App() {
|
|||||||
onToggleLike={handleToggleLike}
|
onToggleLike={handleToggleLike}
|
||||||
onToggleWatch={handleToggleWatch}
|
onToggleWatch={handleToggleWatch}
|
||||||
onKeepJob={handleKeepJob}
|
onKeepJob={handleKeepJob}
|
||||||
|
onSplitJob={openSplitModal}
|
||||||
blurPreviews={Boolean(recSettings.blurPreviews)}
|
blurPreviews={Boolean(recSettings.blurPreviews)}
|
||||||
teaserPlayback={recSettings.teaserPlayback ?? 'hover'}
|
teaserPlayback={recSettings.teaserPlayback ?? 'hover'}
|
||||||
teaserAudio={Boolean(recSettings.teaserAudio)}
|
teaserAudio={Boolean(recSettings.teaserAudio)}
|
||||||
@ -3159,6 +3297,26 @@ export default function App() {
|
|||||||
onStopJob={stopJob}
|
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 ? (
|
{playerJob ? (
|
||||||
<Player
|
<Player
|
||||||
key={[
|
key={[
|
||||||
|
|||||||
@ -285,6 +285,9 @@ const phaseLabel = (p?: string) => {
|
|||||||
case 'assets':
|
case 'assets':
|
||||||
return 'Erstelle Vorschau/Thumbnails…'
|
return 'Erstelle Vorschau/Thumbnails…'
|
||||||
|
|
||||||
|
case 'analyze':
|
||||||
|
return 'AI analysiert Segmente…'
|
||||||
|
|
||||||
case 'postwork':
|
case 'postwork':
|
||||||
return 'Nacharbeiten laufen…'
|
return 'Nacharbeiten laufen…'
|
||||||
|
|
||||||
@ -464,15 +467,15 @@ function DownloadsCardRow({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="
|
className="
|
||||||
relative overflow-hidden rounded-2xl border border-white/40 bg-white/35 shadow-sm
|
relative overflow-hidden rounded-2xl
|
||||||
backdrop-blur-xl supports-[backdrop-filter]:bg-white/25
|
border border-gray-200/90 bg-white shadow-sm
|
||||||
ring-1 ring-black/5
|
ring-1 ring-black/5
|
||||||
transition-all hover:-translate-y-0.5 hover:shadow-md active:translate-y-0
|
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
|
dark:border-white/10 dark:bg-gray-950/35 dark:supports-[backdrop-filter]:bg-gray-950/25
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{/* subtle gradient */}
|
{/* 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="relative p-3">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
@ -613,8 +616,8 @@ function DownloadsCardRow({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="
|
className="
|
||||||
group relative overflow-hidden rounded-2xl border border-white/40 bg-white/35 shadow-sm
|
group relative overflow-hidden rounded-2xl
|
||||||
backdrop-blur-xl supports-[backdrop-filter]:bg-white/25
|
border border-gray-200/90 bg-white shadow-sm
|
||||||
ring-1 ring-black/5
|
ring-1 ring-black/5
|
||||||
transition-all hover:-translate-y-0.5 hover:shadow-md active:translate-y-0
|
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
|
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 */}
|
{/* 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="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">
|
<div className="relative p-3">
|
||||||
@ -752,7 +755,8 @@ function DownloadsCardRow({
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={showRemoveQueuedButton ? 'secondary' : 'primary'}
|
variant="primary"
|
||||||
|
color={isQueuedPostwork ? 'red' : 'indigo'}
|
||||||
disabled={disableStopButton}
|
disabled={disableStopButton}
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
onClick={async (e) => {
|
onClick={async (e) => {
|
||||||
@ -1768,7 +1772,7 @@ export default function Downloads({
|
|||||||
|
|
||||||
{postworkRows.length > 0 ? (
|
{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})
|
Nacharbeiten ({postworkRows.length})
|
||||||
</div>
|
</div>
|
||||||
{postworkRows.map((r) => (
|
{postworkRows.map((r) => (
|
||||||
@ -1826,8 +1830,8 @@ export default function Downloads({
|
|||||||
/* Desktop: Tabellen (wirklich nur desktop gemountet) */
|
/* Desktop: Tabellen (wirklich nur desktop gemountet) */
|
||||||
<div className="mt-3 space-y-4">
|
<div className="mt-3 space-y-4">
|
||||||
{downloadJobRows.length > 0 ? (
|
{downloadJobRows.length > 0 ? (
|
||||||
<div className="overflow-x-auto">
|
<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 text-sm font-semibold text-gray-900 dark:text-white">
|
<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>
|
<span>Downloads ({downloadJobRows.length})</span>
|
||||||
{watchedPausedByDisk ? <DiskEmergencyBadge /> : null}
|
{watchedPausedByDisk ? <DiskEmergencyBadge /> : null}
|
||||||
</div>
|
</div>
|
||||||
@ -1848,8 +1852,8 @@ export default function Downloads({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{postworkRows.length > 0 ? (
|
{postworkRows.length > 0 ? (
|
||||||
<div className="overflow-x-auto">
|
<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 text-sm font-semibold text-gray-900 dark:text-white">
|
<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})
|
Nacharbeiten ({postworkRows.length})
|
||||||
</div>
|
</div>
|
||||||
<Table
|
<Table
|
||||||
@ -1869,8 +1873,8 @@ export default function Downloads({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{pendingRows.length > 0 ? (
|
{pendingRows.length > 0 ? (
|
||||||
<div className="overflow-x-auto">
|
<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 text-sm font-semibold text-gray-900 dark:text-white">
|
<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})
|
Wartend ({pendingRows.length})
|
||||||
</div>
|
</div>
|
||||||
<Table
|
<Table
|
||||||
|
|||||||
@ -58,6 +58,7 @@ type Props = {
|
|||||||
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
||||||
onToggleWatch?: (job: RecordJob) => void | Promise<void>
|
onToggleWatch?: (job: RecordJob) => void | Promise<void>
|
||||||
onKeepJob?: (job: RecordJob) => void | Promise<void>
|
onKeepJob?: (job: RecordJob) => void | Promise<void>
|
||||||
|
onSplitJob?: (job: RecordJob) => void | Promise<void>
|
||||||
doneTotal: number
|
doneTotal: number
|
||||||
page: number
|
page: number
|
||||||
pageSize: number
|
pageSize: number
|
||||||
@ -315,6 +316,7 @@ export default function FinishedDownloads({
|
|||||||
onToggleLike,
|
onToggleLike,
|
||||||
onToggleWatch,
|
onToggleWatch,
|
||||||
onKeepJob,
|
onKeepJob,
|
||||||
|
onSplitJob,
|
||||||
doneTotal,
|
doneTotal,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@ -712,26 +714,24 @@ export default function FinishedDownloads({
|
|||||||
])
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
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)
|
setOverrideDoneJobs(null)
|
||||||
setOverrideDoneTotal(null)
|
setOverrideDoneTotal(null)
|
||||||
}, [page, pageSize, sortMode, includeKeep, effectiveAllMode])
|
}, [page, pageSize, sortMode, includeKeep, effectiveAllMode])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!includeKeep) {
|
if (effectiveAllMode) return
|
||||||
// zurück auf "nur /done/" (Props)
|
|
||||||
if (!globalFilterActive) {
|
|
||||||
setOverrideDoneJobs(null)
|
|
||||||
setOverrideDoneTotal(null)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// includeKeep = true:
|
// Nur nötig, wenn wir NICHT auf die normalen Props zurückfallen.
|
||||||
// - wenn Filter aktiv -> fetchAllDoneJobs macht das bereits (mit includeKeep)
|
if (!includeKeep) return
|
||||||
if (globalFilterActive) return
|
|
||||||
|
|
||||||
const ac = new AbortController()
|
const ac = new AbortController()
|
||||||
|
let alive = true
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
|
||||||
;(async () => {
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
@ -739,19 +739,27 @@ export default function FinishedDownloads({
|
|||||||
`/api/record/done?page=${page}&pageSize=${pageSize}&sort=${encodeURIComponent(sortMode)}&withCount=1&includeKeep=1`,
|
`/api/record/done?page=${page}&pageSize=${pageSize}&sort=${encodeURIComponent(sortMode)}&withCount=1&includeKeep=1`,
|
||||||
{ cache: 'no-store' as any, signal: ac.signal }
|
{ 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)
|
const data = await res.json().catch(() => null)
|
||||||
|
if (!alive || ac.signal.aborted) return
|
||||||
|
|
||||||
const items = Array.isArray(data?.items) ? (data.items as RecordJob[]) : []
|
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)
|
setOverrideDoneJobs(items)
|
||||||
setOverrideDoneTotal(Number.isFinite(count) ? count : items.length)
|
setOverrideDoneTotal(count)
|
||||||
} catch {}
|
} finally {
|
||||||
|
if (alive) setIsLoading(false)
|
||||||
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
return () => ac.abort()
|
return () => {
|
||||||
}, [includeKeep, globalFilterActive, page, pageSize, sortMode])
|
alive = false
|
||||||
|
ac.abort()
|
||||||
|
}
|
||||||
|
}, [effectiveAllMode, includeKeep, page, pageSize, sortMode])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
@ -1749,7 +1757,28 @@ export default function FinishedDownloads({
|
|||||||
// ✅ .trash niemals anzeigen
|
// ✅ .trash niemals anzeigen
|
||||||
if (isTrashOutput(j.output)) return false
|
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
|
return list
|
||||||
@ -2053,8 +2082,11 @@ export default function FinishedDownloads({
|
|||||||
}, [isSmall])
|
}, [isSmall])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Nicht während eines Seitenwechsels / Refills zurückspringen.
|
||||||
|
if (isLoading) return
|
||||||
|
|
||||||
if (emptyFolder && page !== 1) onPageChange(1)
|
if (emptyFolder && page !== 1) onPageChange(1)
|
||||||
}, [emptyFolder, page, onPageChange])
|
}, [emptyFolder, isLoading, page, onPageChange])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -2457,6 +2489,7 @@ export default function FinishedDownloads({
|
|||||||
onToggleFavorite={onToggleFavorite}
|
onToggleFavorite={onToggleFavorite}
|
||||||
onToggleLike={onToggleLike}
|
onToggleLike={onToggleLike}
|
||||||
onToggleWatch={onToggleWatch}
|
onToggleWatch={onToggleWatch}
|
||||||
|
onSplit={onSplitJob}
|
||||||
activeTagSet={activeTagSet}
|
activeTagSet={activeTagSet}
|
||||||
onToggleTagFilter={toggleTagFilter}
|
onToggleTagFilter={toggleTagFilter}
|
||||||
onHoverPreviewKeyChange={setHoverTeaserKey}
|
onHoverPreviewKeyChange={setHoverTeaserKey}
|
||||||
@ -2507,6 +2540,7 @@ export default function FinishedDownloads({
|
|||||||
onToggleFavorite={onToggleFavorite}
|
onToggleFavorite={onToggleFavorite}
|
||||||
onToggleLike={onToggleLike}
|
onToggleLike={onToggleLike}
|
||||||
onToggleWatch={onToggleWatch}
|
onToggleWatch={onToggleWatch}
|
||||||
|
onSplit={onSplitJob}
|
||||||
deleteVideo={deleteVideo}
|
deleteVideo={deleteVideo}
|
||||||
keepVideo={keepVideo}
|
keepVideo={keepVideo}
|
||||||
enqueueDeleteVideo={enqueueDeleteVideo}
|
enqueueDeleteVideo={enqueueDeleteVideo}
|
||||||
@ -2547,6 +2581,7 @@ export default function FinishedDownloads({
|
|||||||
onToggleFavorite={onToggleFavorite}
|
onToggleFavorite={onToggleFavorite}
|
||||||
onToggleLike={onToggleLike}
|
onToggleLike={onToggleLike}
|
||||||
onToggleWatch={onToggleWatch}
|
onToggleWatch={onToggleWatch}
|
||||||
|
onSplit={onSplitJob}
|
||||||
activeTagSet={activeTagSet}
|
activeTagSet={activeTagSet}
|
||||||
onToggleTagFilter={toggleTagFilter}
|
onToggleTagFilter={toggleTagFilter}
|
||||||
onHoverPreviewKeyChange={setHoverTeaserKey}
|
onHoverPreviewKeyChange={setHoverTeaserKey}
|
||||||
|
|||||||
@ -6,11 +6,6 @@ import Card from './Card'
|
|||||||
import type { RecordJob } from '../../types'
|
import type { RecordJob } from '../../types'
|
||||||
import FinishedVideoPreview from './FinishedVideoPreview'
|
import FinishedVideoPreview from './FinishedVideoPreview'
|
||||||
import SwipeCard, { type SwipeCardHandle } from './SwipeCard'
|
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 RecordJobActions from './RecordJobActions'
|
||||||
import TagOverflowRow from './TagOverflowRow'
|
import TagOverflowRow from './TagOverflowRow'
|
||||||
import PreviewScrubber from './PreviewScrubber'
|
import PreviewScrubber from './PreviewScrubber'
|
||||||
@ -84,6 +79,7 @@ type Props = {
|
|||||||
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
|
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
|
||||||
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
||||||
onToggleWatch?: (job: RecordJob) => void | Promise<void>
|
onToggleWatch?: (job: RecordJob) => void | Promise<void>
|
||||||
|
onSplit?: (job: RecordJob) => void | Promise<void>
|
||||||
|
|
||||||
enqueueDeleteVideo?: (job: RecordJob) => boolean
|
enqueueDeleteVideo?: (job: RecordJob) => boolean
|
||||||
enqueueKeepVideo?: (job: RecordJob) => boolean
|
enqueueKeepVideo?: (job: RecordJob) => boolean
|
||||||
@ -161,6 +157,189 @@ function chooseSpriteGrid(count: number): [number, number] {
|
|||||||
return [bestCols, bestRows]
|
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({
|
function CardBlurWrapper({
|
||||||
blurred,
|
blurred,
|
||||||
animateUnblurOnMount,
|
animateUnblurOnMount,
|
||||||
@ -388,6 +567,7 @@ export default function FinishedDownloadsCardsView({
|
|||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
onToggleLike,
|
onToggleLike,
|
||||||
onToggleWatch,
|
onToggleWatch,
|
||||||
|
onSplit,
|
||||||
|
|
||||||
enqueueDeleteVideo,
|
enqueueDeleteVideo,
|
||||||
enqueueKeepVideo,
|
enqueueKeepVideo,
|
||||||
@ -680,6 +860,7 @@ export default function FinishedDownloadsCardsView({
|
|||||||
const isFav = Boolean(flags?.favorite)
|
const isFav = Boolean(flags?.favorite)
|
||||||
const isLiked = flags?.liked === true
|
const isLiked = flags?.liked === true
|
||||||
const isWatching = Boolean(flags?.watching)
|
const isWatching = Boolean(flags?.watching)
|
||||||
|
const postworkStatus = finishedPostworkBadge(j)
|
||||||
|
|
||||||
const tags = parseTags(flags?.tags)
|
const tags = parseTags(flags?.tags)
|
||||||
|
|
||||||
@ -911,9 +1092,17 @@ export default function FinishedDownloadsCardsView({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shrink-0 flex items-center gap-1.5 pt-0.5">
|
<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}
|
{postworkStatus ? (
|
||||||
{isLiked ? <HeartSolidIcon className="size-4 text-rose-600 dark:text-rose-300" /> : null}
|
<span
|
||||||
{isFav ? <StarSolidIcon className="size-4 text-amber-600 dark:text-amber-300" /> : null}
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -942,7 +1131,8 @@ export default function FinishedDownloadsCardsView({
|
|||||||
onToggleHot={onToggleHot}
|
onToggleHot={onToggleHot}
|
||||||
onKeep={keepVideo}
|
onKeep={keepVideo}
|
||||||
onDelete={deleteVideo}
|
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"
|
className="w-full gap-1.5"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -72,6 +72,7 @@ type Props = {
|
|||||||
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
||||||
onToggleWatch?: (job: RecordJob) => void | Promise<void>
|
onToggleWatch?: (job: RecordJob) => void | Promise<void>
|
||||||
onToggleHot: (job: RecordJob) => void | Promise<void>
|
onToggleHot: (job: RecordJob) => void | Promise<void>
|
||||||
|
onSplit?: (job: RecordJob) => void | Promise<void>
|
||||||
|
|
||||||
// optional queued actions (bevorzugt verwenden, falls vorhanden)
|
// optional queued actions (bevorzugt verwenden, falls vorhanden)
|
||||||
enqueueDeleteVideo?: (job: RecordJob) => boolean
|
enqueueDeleteVideo?: (job: RecordJob) => boolean
|
||||||
@ -182,6 +183,7 @@ export default function FinishedDownloadsGalleryView({
|
|||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
onToggleLike,
|
onToggleLike,
|
||||||
onToggleWatch,
|
onToggleWatch,
|
||||||
|
onSplit,
|
||||||
enqueueDeleteVideo,
|
enqueueDeleteVideo,
|
||||||
enqueueKeepVideo,
|
enqueueKeepVideo,
|
||||||
enqueueToggleHot,
|
enqueueToggleHot,
|
||||||
@ -679,7 +681,8 @@ export default function FinishedDownloadsGalleryView({
|
|||||||
}
|
}
|
||||||
return deleteVideo(job)
|
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"
|
className="w-full gap-1.5"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -70,6 +70,7 @@ type Props = {
|
|||||||
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
|
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
|
||||||
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
||||||
onToggleWatch?: (job: RecordJob) => void | Promise<void>
|
onToggleWatch?: (job: RecordJob) => void | Promise<void>
|
||||||
|
onSplit?: (job: RecordJob) => void | Promise<void>
|
||||||
|
|
||||||
deleteVideo: (job: RecordJob) => Promise<boolean>
|
deleteVideo: (job: RecordJob) => Promise<boolean>
|
||||||
keepVideo: (job: RecordJob) => Promise<boolean>
|
keepVideo: (job: RecordJob) => Promise<boolean>
|
||||||
@ -122,6 +123,7 @@ export default function FinishedDownloadsTableView({
|
|||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
onToggleLike,
|
onToggleLike,
|
||||||
onToggleWatch,
|
onToggleWatch,
|
||||||
|
onSplit,
|
||||||
|
|
||||||
deleteVideo,
|
deleteVideo,
|
||||||
keepVideo,
|
keepVideo,
|
||||||
@ -460,7 +462,8 @@ export default function FinishedDownloadsTableView({
|
|||||||
}
|
}
|
||||||
return deleteVideo(job)
|
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"
|
className="flex items-center justify-end gap-1"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import {
|
|||||||
HeartIcon as HeartOutlineIcon,
|
HeartIcon as HeartOutlineIcon,
|
||||||
EyeIcon as EyeOutlineIcon,
|
EyeIcon as EyeOutlineIcon,
|
||||||
ArrowDownTrayIcon,
|
ArrowDownTrayIcon,
|
||||||
|
ScissorsIcon,
|
||||||
} from '@heroicons/react/24/outline'
|
} from '@heroicons/react/24/outline'
|
||||||
import {
|
import {
|
||||||
FireIcon as FireSolidIcon,
|
FireIcon as FireSolidIcon,
|
||||||
@ -27,7 +28,16 @@ import { createPortal } from 'react-dom'
|
|||||||
|
|
||||||
type Variant = 'overlay' | 'table'
|
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 ActionResult = void | boolean
|
||||||
type ActionFn = (job: RecordJob) => ActionResult | Promise<ActionResult>
|
type ActionFn = (job: RecordJob) => ActionResult | Promise<ActionResult>
|
||||||
@ -53,6 +63,7 @@ type Props = {
|
|||||||
onDelete?: ActionFn
|
onDelete?: ActionFn
|
||||||
onToggleWatch?: ActionFn
|
onToggleWatch?: ActionFn
|
||||||
onAddToDownloads?: ActionFn
|
onAddToDownloads?: ActionFn
|
||||||
|
onSplit?: ActionFn
|
||||||
|
|
||||||
order?: ActionKey[]
|
order?: ActionKey[]
|
||||||
|
|
||||||
@ -102,6 +113,7 @@ export default function RecordJobActions({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onToggleWatch,
|
onToggleWatch,
|
||||||
onAddToDownloads,
|
onAddToDownloads,
|
||||||
|
onSplit,
|
||||||
order,
|
order,
|
||||||
className,
|
className,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
@ -144,7 +156,7 @@ export default function RecordJobActions({
|
|||||||
|
|
||||||
|
|
||||||
// ✅ Reihenfolge strikt nach `order` (wenn gesetzt). Keys die nicht im order stehen: niemals anzeigen.
|
// ✅ 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 inOrder = (k: ActionKey) => actionOrder.includes(k)
|
||||||
|
|
||||||
const addUrl = String((job as any)?.sourceUrl ?? '').trim()
|
const addUrl = String((job as any)?.sourceUrl ?? '').trim()
|
||||||
@ -158,6 +170,7 @@ export default function RecordJobActions({
|
|||||||
const wantWatch = inOrder('watch')
|
const wantWatch = inOrder('watch')
|
||||||
const wantKeep = inOrder('keep')
|
const wantKeep = inOrder('keep')
|
||||||
const wantDelete = inOrder('delete')
|
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').
|
// 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.)
|
// (Aktuell macht Details ohne detailsKey wenig Sinn, daher: nur anzeigen wenn key existiert.)
|
||||||
@ -394,6 +407,21 @@ export default function RecordJobActions({
|
|||||||
</button>
|
</button>
|
||||||
) : null
|
) : 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 ? (
|
const KeepBtn = wantKeep ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -427,6 +455,7 @@ export default function RecordJobActions({
|
|||||||
const byKey: Record<ActionKey, React.ReactNode> = {
|
const byKey: Record<ActionKey, React.ReactNode> = {
|
||||||
details: DetailsBtn,
|
details: DetailsBtn,
|
||||||
add: AddBtn,
|
add: AddBtn,
|
||||||
|
split: SplitBtn,
|
||||||
favorite: FavoriteBtn,
|
favorite: FavoriteBtn,
|
||||||
like: LikeBtn,
|
like: LikeBtn,
|
||||||
watch: WatchBtn,
|
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') {
|
if (k === 'favorite') {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
1375
frontend/src/components/ui/VideoSplitModal.tsx
Normal file
1375
frontend/src/components/ui/VideoSplitModal.tsx
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user