666 lines
14 KiB
Go
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
|
|
}
|