// 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 }