706 lines
15 KiB
Go
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
|
|
}
|