nsfwapp/backend/tasks_check_video.go
2026-04-27 14:40:16 +02:00

666 lines
14 KiB
Go

// backend\tasks_check_video.go
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"image"
"image/jpeg"
"math"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
type tailBlackoutSample struct {
Role string `json:"role,omitempty"`
TimeSec float64 `json:"timeSec"`
AvgLuma float64 `json:"avgLuma"`
BlackRatio float64 `json:"blackRatio"`
MostlyBlack bool `json:"mostlyBlack"`
}
type tailBlackoutResult struct {
CheckedAt string `json:"checkedAt"`
IsCorrupt bool `json:"isCorrupt"`
Reason string `json:"reason,omitempty"`
ReferenceBlack bool `json:"referenceBlack,omitempty"`
BlackTailFrames int `json:"blackTailFrames"`
SampleCount int `json:"sampleCount"`
Samples []tailBlackoutSample `json:"samples,omitempty"`
}
type checkVideosTaskState struct {
mu sync.Mutex
queued bool
running bool
done int
total int
text string
currentFile string
err string
queuedAt time.Time
startedAt time.Time
finishedAt time.Time
cancel context.CancelFunc
}
func shortTaskFilename(name string, max int) string {
s := strings.TrimSpace(name)
if s == "" {
return ""
}
if max <= 1 || len(s) <= max {
return s
}
return "…" + s[len(s)-(max-1):]
}
func snapshotCheckVideosTaskState() CheckVideosTaskState {
checkVideosTask.mu.Lock()
defer checkVideosTask.mu.Unlock()
// Fertige/abgebrochene/fehlerhafte Tasks nach kurzer Zeit nicht mehr zurückgeben
if !checkVideosTask.queued &&
!checkVideosTask.running &&
!checkVideosTask.finishedAt.IsZero() &&
time.Since(checkVideosTask.finishedAt) > 4*time.Second {
return CheckVideosTaskState{}
}
out := CheckVideosTaskState{
Queued: checkVideosTask.queued,
Running: checkVideosTask.running,
QueuedAt: checkVideosTask.queuedAt,
StartedAt: checkVideosTask.startedAt,
Text: checkVideosTask.text,
Error: checkVideosTask.err,
CurrentFile: checkVideosTask.currentFile,
Done: checkVideosTask.done,
Total: checkVideosTask.total,
}
if !checkVideosTask.finishedAt.IsZero() {
finishedAt := checkVideosTask.finishedAt
out.FinishedAt = &finishedAt
}
return out
}
var checkVideosTask checkVideosTaskState
func clampFloat(v, lo, hi float64) float64 {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
func shouldCheckFinishedVideo(path string) bool {
n := strings.ToLower(strings.ReplaceAll(path, "\\", "/"))
base := filepath.Base(n)
ext := strings.ToLower(filepath.Ext(base))
if strings.Contains(n, "/.trash/") {
return false
}
switch base {
case "preview.jpg", "preview.mp4", "preview-sprite.jpg":
return false
}
switch ext {
case ".mp4", ".mkv", ".webm", ".ts", ".mov":
return true
default:
return false
}
}
func hasUsableSpriteForVideo(videoPath string) bool {
assetID := assetIDFromVideoPath(videoPath)
if assetID == "" {
return false
}
_, _, _, spritePath, _, err := assetPathsForID(assetID)
if err != nil {
return false
}
fi, err := os.Stat(spritePath)
return err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0
}
func collectFinishedVideosForCheck() ([]string, error) {
s := getSettings()
doneAbs, err := resolvePathRelativeToApp(strings.TrimSpace(s.DoneDir))
if err != nil {
return nil, err
}
files := make([]string, 0, 256)
err = filepath.Walk(doneAbs, func(path string, info os.FileInfo, walkErr error) error {
if walkErr != nil {
return nil
}
if info == nil || info.IsDir() {
return nil
}
if !info.Mode().IsRegular() {
return nil
}
if !shouldCheckFinishedVideo(path) {
return nil
}
if !hasUsableSpriteForVideo(path) {
return nil
}
files = append(files, path)
return nil
})
if err != nil {
return nil, err
}
sort.Strings(files)
return files, nil
}
func finishCheckVideosTask(err error) {
checkVideosTask.mu.Lock()
checkVideosTask.queued = false
checkVideosTask.running = false
checkVideosTask.cancel = nil
checkVideosTask.finishedAt = time.Now()
errText := strings.TrimSpace(func() string {
if err == nil {
return ""
}
return err.Error()
}())
cancelled :=
strings.EqualFold(errText, "abgebrochen") ||
strings.EqualFold(errText, "abbruch") ||
strings.EqualFold(errText, "cancelled") ||
strings.EqualFold(errText, "canceled")
if cancelled {
checkVideosTask.err = ""
checkVideosTask.text = "Abgebrochen."
} else if err != nil {
checkVideosTask.err = errText
checkVideosTask.text = "Fehler bei der Video-Prüfung."
} else {
checkVideosTask.err = ""
checkVideosTask.text = "Prüfung abgeschlossen."
}
checkVideosTask.mu.Unlock()
publishTaskState()
}
func runCheckVideosTask(ctx context.Context) {
files, err := collectFinishedVideosForCheck()
if err != nil {
finishCheckVideosTask(err)
return
}
checkVideosTask.mu.Lock()
checkVideosTask.queued = false
checkVideosTask.running = true
checkVideosTask.total = len(files)
checkVideosTask.done = 0
checkVideosTask.startedAt = time.Now()
checkVideosTask.text = "Prüfe Videos…"
checkVideosTask.mu.Unlock()
publishTaskState()
var firstErr error
for i, file := range files {
select {
case <-ctx.Done():
finishCheckVideosTask(fmt.Errorf("abgebrochen"))
return
default:
}
checkVideosTask.mu.Lock()
checkVideosTask.currentFile = file
checkVideosTask.text = shortTaskFilename(file, 52)
checkVideosTask.done = i
checkVideosTask.mu.Unlock()
if _, err := runTailBlackoutCheck(ctx, file); err != nil {
if firstErr == nil {
firstErr = err
}
}
checkVideosTask.mu.Lock()
checkVideosTask.done = i + 1
checkVideosTask.mu.Unlock()
}
finishCheckVideosTask(firstErr)
}
func startCheckVideosTask() error {
checkVideosTask.mu.Lock()
defer checkVideosTask.mu.Unlock()
if checkVideosTask.running || checkVideosTask.queued {
return fmt.Errorf("task läuft bereits")
}
ctx, cancel := context.WithCancel(context.Background())
checkVideosTask.queued = true
checkVideosTask.running = false
checkVideosTask.done = 0
checkVideosTask.total = 0
checkVideosTask.text = "Wartet…"
checkVideosTask.currentFile = ""
checkVideosTask.err = ""
checkVideosTask.queuedAt = time.Now()
checkVideosTask.startedAt = time.Time{}
checkVideosTask.finishedAt = time.Time{}
checkVideosTask.cancel = cancel
publishTaskState()
go runCheckVideosTask(ctx)
return nil
}
func cancelCheckVideosTask() {
checkVideosTask.mu.Lock()
cancel := checkVideosTask.cancel
checkVideosTask.mu.Unlock()
if cancel != nil {
cancel()
}
}
func checkVideosTaskHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
if err := startCheckVideosTask(); err != nil {
http.Error(w, err.Error(), http.StatusConflict)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
return
case http.MethodDelete:
cancelCheckVideosTask()
w.WriteHeader(http.StatusNoContent)
return
default:
http.Error(w, "Nur POST/DELETE erlaubt", http.StatusMethodNotAllowed)
return
}
}
func analyzeImageBoundsBlackness(img image.Image, bounds image.Rectangle) (avgLuma float64, blackRatio float64, err error) {
b := bounds.Intersect(img.Bounds())
w := b.Dx()
h := b.Dy()
if w <= 0 || h <= 0 {
return 0, 0, fmt.Errorf("invalid image bounds")
}
totalPixels := w * h
step := int(math.Sqrt(float64(totalPixels) / 12000.0))
if step < 1 {
step = 1
}
var (
sumLuma float64
count int
black int
)
for y := b.Min.Y; y < b.Max.Y; y += step {
for x := b.Min.X; x < b.Max.X; x += step {
r, g, bb, _ := img.At(x, y).RGBA()
r8 := float64(r >> 8)
g8 := float64(g >> 8)
b8 := float64(bb >> 8)
luma := 0.2126*r8 + 0.7152*g8 + 0.0722*b8
sumLuma += luma
count++
if luma <= 16 {
black++
}
}
}
if count == 0 {
return 0, 0, fmt.Errorf("no sampled pixels")
}
avgLuma = sumLuma / float64(count)
blackRatio = float64(black) / float64(count)
return avgLuma, blackRatio, nil
}
func analyzeJPGBlackness(jpg []byte) (avgLuma float64, blackRatio float64, err error) {
img, err := jpeg.Decode(bytes.NewReader(jpg))
if err != nil {
return 0, 0, err
}
return analyzeImageBoundsBlackness(img, img.Bounds())
}
func spriteCellBounds(img image.Image, cols, rows, index int) (image.Rectangle, error) {
if cols <= 0 || rows <= 0 {
return image.Rectangle{}, fmt.Errorf("invalid sprite layout")
}
if index < 0 || index >= cols*rows {
return image.Rectangle{}, fmt.Errorf("sprite index out of range")
}
b := img.Bounds()
col := index % cols
row := index / cols
x0 := b.Min.X + (col*b.Dx())/cols
x1 := b.Min.X + ((col+1)*b.Dx())/cols
y0 := b.Min.Y + (row*b.Dy())/rows
y1 := b.Min.Y + ((row+1)*b.Dy())/rows
rect := image.Rect(x0, y0, x1, y1)
if rect.Dx() <= 0 || rect.Dy() <= 0 {
return image.Rectangle{}, fmt.Errorf("invalid sprite cell bounds")
}
return rect, nil
}
func analyzeSpriteCellBlackness(img image.Image, cols, rows, index int) (avgLuma float64, blackRatio float64, err error) {
rect, err := spriteCellBounds(img, cols, rows, index)
if err != nil {
return 0, 0, err
}
return analyzeImageBoundsBlackness(img, rect)
}
func isMostlyBlackFrame(avgLuma, blackRatio float64) bool {
return avgLuma <= 18.0 && blackRatio >= 0.96
}
func makeTailSample(role string, t, avgLuma, blackRatio float64) tailBlackoutSample {
return tailBlackoutSample{
Role: role,
TimeSec: t,
AvgLuma: avgLuma,
BlackRatio: blackRatio,
MostlyBlack: isMostlyBlackFrame(avgLuma, blackRatio),
}
}
func uniqueTailIndexes(indexes []int, count int) []int {
if count <= 0 {
return nil
}
seen := map[int]struct{}{}
out := make([]int, 0, len(indexes))
for _, idx := range indexes {
if idx < 0 {
idx = 0
}
if idx >= count {
idx = count - 1
}
if _, ok := seen[idx]; ok {
continue
}
seen[idx] = struct{}{}
out = append(out, idx)
}
return out
}
func spriteSampleTimeSec(index int, stepSec float64) float64 {
if stepSec <= 0 || index < 0 {
return 0
}
return float64(index) * stepSec
}
func uniqueTailTimes(times []float64, dur float64) []float64 {
maxT := math.Max(0.15, dur-0.15)
seen := map[int]struct{}{}
out := make([]float64, 0, len(times))
for _, t := range times {
t = clampFloat(t, 0.10, maxT)
key := int(math.Round(t * 1000))
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, t)
}
return out
}
func inspectVideoTailBlackout(videoPath string) (*tailBlackoutResult, error) {
assetID := assetIDFromVideoPath(videoPath)
if assetID == "" {
return nil, fmt.Errorf("asset id fehlt")
}
_, _, _, spritePath, metaPath, err := assetPathsForID(assetID)
if err != nil {
return nil, err
}
f, err := os.Open(spritePath)
if err != nil {
return nil, fmt.Errorf("sprite open failed: %w", err)
}
defer f.Close()
img, err := jpeg.Decode(f)
if err != nil {
return nil, fmt.Errorf("sprite decode failed: %w", err)
}
cols, rows, count, _, _ := fixedPreviewSpriteLayout()
stepSec := 0.0
if ps, ok := readPreviewSpriteMetaFromMetaFile(metaPath); ok {
if ps.Cols > 0 {
cols = ps.Cols
}
if ps.Rows > 0 {
rows = ps.Rows
}
if ps.Count > 0 {
count = ps.Count
}
if ps.StepSeconds > 0 {
stepSec = ps.StepSeconds
}
}
maxCells := cols * rows
if maxCells <= 0 {
return nil, fmt.Errorf("invalid sprite layout")
}
if count <= 0 {
count = maxCells
}
if count > maxCells {
count = maxCells
}
if count < 2 {
return nil, fmt.Errorf("sprite enthält zu wenige frames")
}
res := &tailBlackoutResult{
CheckedAt: time.Now().Format(time.RFC3339Nano),
}
refIndex := int(math.Round(float64(count-1) * 0.55))
if avg, ratio, err := analyzeSpriteCellBlackness(img, cols, rows, refIndex); err == nil {
refSample := makeTailSample("reference", spriteSampleTimeSec(refIndex, stepSec), avg, ratio)
res.ReferenceBlack = refSample.MostlyBlack
res.Samples = append(res.Samples, refSample)
}
tailIndexes := uniqueTailIndexes([]int{
int(math.Round(float64(count-1) * 0.82)),
int(math.Round(float64(count-1) * 0.88)),
int(math.Round(float64(count-1) * 0.94)),
int(math.Round(float64(count-1) * 0.985)),
}, count)
validTail := 0
blackTail := 0
for _, idx := range tailIndexes {
avg, ratio, err := analyzeSpriteCellBlackness(img, cols, rows, idx)
if err != nil {
continue
}
sample := makeTailSample("tail", spriteSampleTimeSec(idx, stepSec), avg, ratio)
res.Samples = append(res.Samples, sample)
validTail++
if sample.MostlyBlack {
blackTail++
}
}
res.SampleCount = validTail
res.BlackTailFrames = blackTail
switch {
case validTail >= 4 && blackTail >= 3 && !res.ReferenceBlack:
res.IsCorrupt = true
res.Reason = fmt.Sprintf("%d/%d Sprite-Endzellen sind fast komplett schwarz", blackTail, validTail)
case validTail >= 3 && blackTail == validTail && !res.ReferenceBlack:
res.IsCorrupt = true
res.Reason = fmt.Sprintf("alle %d geprüften Sprite-Endzellen sind fast komplett schwarz", validTail)
case validTail >= 4 && blackTail == validTail:
res.IsCorrupt = true
res.Reason = "das komplette geprüfte Sprite-Ende ist fast komplett schwarz"
default:
res.IsCorrupt = false
if validTail == 0 {
res.Reason = "keine validen Sprite-Endzellen prüfbar"
} else {
res.Reason = fmt.Sprintf("%d/%d Sprite-Endzellen schwarz", blackTail, validTail)
}
}
return res, nil
}
func persistTailBlackoutResult(videoPath string, res *tailBlackoutResult) error {
if res == nil {
return nil
}
assetID := assetIDFromVideoPath(videoPath)
if assetID == "" {
return fmt.Errorf("asset id fehlt")
}
_, _, _, _, metaPath, err := assetPathsForID(assetID)
if err != nil {
return err
}
raw, err := os.ReadFile(metaPath)
if err != nil {
return err
}
root := map[string]any{}
if len(raw) > 0 {
dec := json.NewDecoder(bytes.NewReader(raw))
dec.UseNumber()
if err := dec.Decode(&root); err != nil {
return err
}
}
validation, _ := root["validation"].(map[string]any)
if validation == nil {
validation = map[string]any{}
}
b, err := json.Marshal(res)
if err != nil {
return err
}
node := map[string]any{}
if err := json.Unmarshal(b, &node); err != nil {
return err
}
validation["tailBlackout"] = node
root["validation"] = validation
out, err := json.Marshal(root)
if err != nil {
return err
}
return atomicWriteFile(metaPath, out)
}
func runTailBlackoutCheck(ctx context.Context, videoPath string) (*tailBlackoutResult, error) {
_ = ctx
res, err := inspectVideoTailBlackout(videoPath)
if err != nil {
return nil, err
}
if err := persistTailBlackoutResult(videoPath, res); err != nil {
return nil, err
}
return res, nil
}