nsfwapp/backend/tasks_check_video.go
2026-04-28 11:02:08 +02:00

706 lines
15 KiB
Go

// backend\tasks_check_video.go
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"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"`
}
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 snapshotCheckVideosJobs() []CheckVideosTaskState {
checkVideosJobsMu.Lock()
defer checkVideosJobsMu.Unlock()
now := time.Now()
out := make([]CheckVideosTaskState, 0, len(checkVideosJobs))
for id, job := range checkVideosJobs {
if job == nil {
delete(checkVideosJobs, id)
continue
}
st := job.State
if !st.Queued && !st.Running && st.FinishedAt != nil && now.Sub(*st.FinishedAt) > 4*time.Second {
delete(checkVideosJobs, id)
continue
}
out = append(out, st)
}
sort.Slice(out, func(i, j int) bool {
ai := out[i].QueuedAt
aj := out[j].QueuedAt
if ai.IsZero() {
ai = out[i].StartedAt
}
if aj.IsZero() {
aj = out[j].StartedAt
}
return ai.Before(aj)
})
return out
}
func updateCheckVideosJobState(jobID string, fn func(st *CheckVideosTaskState)) (CheckVideosTaskState, bool) {
checkVideosJobsMu.Lock()
job := checkVideosJobs[jobID]
if job == nil {
checkVideosJobsMu.Unlock()
return CheckVideosTaskState{}, false
}
fn(&job.State)
st := job.State
checkVideosJobsMu.Unlock()
publishTaskState()
return st, true
}
func removeCheckVideosJob(jobID string) {
checkVideosJobsMu.Lock()
delete(checkVideosJobs, jobID)
checkVideosJobsMu.Unlock()
publishTaskState()
}
type checkVideosTaskJob struct {
State CheckVideosTaskState
Cancel context.CancelFunc
}
var checkVideosJobsMu sync.Mutex
var checkVideosJobs = map[string]*checkVideosTaskJob{}
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(jobID string, err error) {
now := time.Now()
updateCheckVideosJobState(jobID, func(st *CheckVideosTaskState) {
st.Queued = false
st.Running = false
st.FinishedAt = &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 {
st.Error = "Abgebrochen"
st.Text = "Abgebrochen."
} else if err != nil {
st.Error = errText
st.Text = "Fehler bei der Video-Prüfung."
} else {
st.Error = ""
st.Text = "Prüfung abgeschlossen."
}
})
time.AfterFunc(4*time.Second, func() {
removeCheckVideosJob(jobID)
})
}
func runCheckVideosTask(jobID string, ctx context.Context) {
if err := acquireExclusiveTask(ctx); err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
finishCheckVideosTask(jobID, fmt.Errorf("abgebrochen"))
} else {
finishCheckVideosTask(jobID, err)
}
return
}
defer releaseExclusiveTask()
files, err := collectFinishedVideosForCheck()
if err != nil {
finishCheckVideosTask(jobID, err)
return
}
updateCheckVideosJobState(jobID, func(st *CheckVideosTaskState) {
st.Queued = false
st.Running = true
st.Total = len(files)
st.Done = 0
st.StartedAt = time.Now()
st.Text = "Prüfe Videos…"
})
var firstErr error
for i, file := range files {
select {
case <-ctx.Done():
finishCheckVideosTask(jobID, fmt.Errorf("abgebrochen"))
return
default:
}
updateCheckVideosJobState(jobID, func(st *CheckVideosTaskState) {
st.CurrentFile = file
st.Text = shortTaskFilename(file, 52)
st.Done = i
})
if _, err := runTailBlackoutCheck(ctx, file); err != nil {
if firstErr == nil {
firstErr = err
}
}
updateCheckVideosJobState(jobID, func(st *CheckVideosTaskState) {
st.Done = i + 1
})
}
finishCheckVideosTask(jobID, firstErr)
}
func startCheckVideosTask() (CheckVideosTaskState, error) {
jobID := newTaskID("check-videos")
ctx, cancel := context.WithCancel(context.Background())
st := CheckVideosTaskState{
ID: jobID,
Queued: true,
Running: false,
Done: 0,
Total: 0,
Text: "Wartet…",
CurrentFile: "",
Error: "",
QueuedAt: time.Now(),
StartedAt: time.Time{},
FinishedAt: nil,
}
checkVideosJobsMu.Lock()
checkVideosJobs[jobID] = &checkVideosTaskJob{
State: st,
Cancel: cancel,
}
checkVideosJobsMu.Unlock()
publishTaskState()
go runCheckVideosTask(jobID, ctx)
return st, nil
}
func cancelCheckVideosTask(jobID string) {
checkVideosJobsMu.Lock()
job := checkVideosJobs[jobID]
checkVideosJobsMu.Unlock()
if job != nil && job.Cancel != nil {
job.Cancel()
}
}
func checkVideosTaskHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
st, err := startCheckVideosTask()
if err != nil {
http.Error(w, err.Error(), http.StatusConflict)
return
}
writeJSON(w, http.StatusOK, st)
return
case http.MethodDelete:
id := strings.TrimSpace(r.URL.Query().Get("id"))
if id == "" {
http.Error(w, "id fehlt", http.StatusBadRequest)
return
}
cancelCheckVideosTask(id)
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
}