// 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, appErrorf("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, appErrorf("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, appErrorf("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, appErrorf("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{}, appErrorf("invalid sprite layout") } if index < 0 || index >= cols*rows { return image.Rectangle{}, appErrorf("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{}, appErrorf("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, appErrorf("asset id fehlt") } _, _, _, spritePath, metaPath, err := assetPathsForID(assetID) if err != nil { return nil, err } f, err := os.Open(spritePath) if err != nil { return nil, appErrorf("sprite open failed: %w", err) } defer f.Close() img, err := jpeg.Decode(f) if err != nil { return nil, appErrorf("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, appErrorf("invalid sprite layout") } if count <= 0 { count = maxCells } if count > maxCells { count = maxCells } if count < 2 { return nil, appErrorf("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 appErrorf("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 } var errAnalysisSkippedByBadSprite = errors.New("analysis skipped because preview-sprite.jpg is invalid") func analysisSkippedByBadSprite(err error) bool { return errors.Is(err, errAnalysisSkippedByBadSprite) } func skipAnalysisBecauseBadSpriteError(reason string) error { reason = strings.TrimSpace(reason) if reason == "" { reason = "preview-sprite.jpg ist fehlerhaft" } return fmt.Errorf("%w: %s", errAnalysisSkippedByBadSprite, reason) } func persistBadPreviewSpriteReason(videoPath string, reason string) { reason = strings.TrimSpace(reason) if reason == "" { reason = "preview-sprite.jpg ist fehlerhaft" } err := persistTailBlackoutResult(videoPath, &tailBlackoutResult{ CheckedAt: time.Now().Format(time.RFC3339Nano), IsCorrupt: true, Reason: reason, }) if err != nil { appLogln("⚠️ preview-sprite validation persist failed:", videoPath, err) } } func checkPreviewSpriteBeforeAnalysis( ctx context.Context, videoPath string, ) (skip bool, reason string, err error) { videoPath = strings.TrimSpace(videoPath) if videoPath == "" { return true, "videoPath ist leer", nil } if err := ctx.Err(); err != nil { return false, "", err } assetID := assetIDFromVideoPath(videoPath) if assetID == "" { return true, "asset id fehlt", nil } _, _, _, spritePath, _, perr := assetPathsForID(assetID) if perr != nil { return true, perr.Error(), nil } if !fileExistsNonEmpty(spritePath) { reason := "preview-sprite.jpg fehlt oder ist leer" persistBadPreviewSpriteReason(videoPath, reason) return true, reason, nil } res, checkErr := runTailBlackoutCheck(ctx, videoPath) if checkErr != nil { reason := "preview-sprite.jpg konnte nicht geprüft werden: " + checkErr.Error() persistBadPreviewSpriteReason(videoPath, reason) return true, reason, nil } if res != nil && res.IsCorrupt { reason := strings.TrimSpace(res.Reason) if reason == "" { reason = "preview-sprite.jpg deutet auf beschädigtes Videoende hin" } return true, reason, nil } return false, "", nil }