// backend\assets_sprite.go package main import ( "bytes" "context" "fmt" "os" "os/exec" "path/filepath" "strings" "syscall" ) const ( previewSpriteCols = 20 previewSpriteRows = 12 previewSpriteFrameCount = previewSpriteCols * previewSpriteRows previewSpriteCellW = 320 previewSpriteCellH = 180 ) func fixedPreviewSpriteLayout() (cols, rows, count, cellW, cellH int) { return previewSpriteCols, previewSpriteRows, previewSpriteFrameCount, previewSpriteCellW, previewSpriteCellH } func previewSpriteStepSeconds(durationSec float64) float64 { if durationSec <= 0 { return 5 } // 120 Zellen = 119 Abstände. // Wir ziehen ein kleines Stück vom Ende ab, damit ffmpeg // nicht "hinter" dem letzten gültigen Frame sampelt. usableDur := durationSec - 0.10 if usableDur <= 0 { usableDur = durationSec } step := usableDur / float64(previewSpriteFrameCount-1) // Nicht künstlich auf 0.5s anheben: // genau das erzeugt bei kürzeren Videos zu wenige Frames // und damit schwarze Rest-Zellen im Sprite. if step < 0.05 { step = 0.05 } return step } // generatePreviewSpriteJPG erzeugt ein statisches JPG-Spritesheet aus einem Video. // ffmpeg muss im PATH verfügbar sein. func generatePreviewSpriteJPG( ctx context.Context, videoPath string, outPath string, cols int, rows int, stepSec float64, cellW int, cellH int, ) error { videoPath = strings.TrimSpace(videoPath) outPath = strings.TrimSpace(outPath) if videoPath == "" { return fmt.Errorf("generatePreviewSpriteJPG: empty videoPath") } if outPath == "" { return fmt.Errorf("generatePreviewSpriteJPG: empty outPath") } if cols <= 0 || rows <= 0 { return fmt.Errorf("generatePreviewSpriteJPG: invalid grid %dx%d", cols, rows) } if stepSec <= 0 { return fmt.Errorf("generatePreviewSpriteJPG: invalid stepSec %.3f", stepSec) } if cellW <= 0 || cellH <= 0 { return fmt.Errorf("generatePreviewSpriteJPG: invalid cell size %dx%d", cellW, cellH) } if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil { return fmt.Errorf("mkdir sprite dir: %w", err) } ext := filepath.Ext(outPath) if ext == "" { ext = ".jpg" } base := strings.TrimSuffix(outPath, ext) tmpPath := base + ".tmp" + ext // alte temp-Datei vorsichtshalber vorher entfernen _ = os.Remove(tmpPath) // bei JEDEM Fehler temp wieder aufräumen renameOK := false defer func() { if !renameOK { _ = os.Remove(tmpPath) } }() ffmpegPath := strings.TrimSpace(getSettings().FFmpegPath) if ffmpegPath == "" { ffmpegPath = "ffmpeg" } // robustere Filterkette vf := fmt.Sprintf( "fps=fps=1/%f:start_time=0:round=up,"+ "scale=%d:%d:force_original_aspect_ratio=decrease:flags=bilinear,"+ "pad=%d:%d:(ow-iw)/2:(oh-ih)/2:black,"+ "setsar=1,"+ "tile=%dx%d:margin=0:padding=0", stepSec, cellW, cellH, cellW, cellH, cols, rows, ) args := []string{ "-hide_banner", "-y", } args = append(args, ffmpegInputTol...) args = append(args, "-i", videoPath, "-an", "-sn", "-threads", "2", "-vf", vf, "-frames:v", "1", "-update", "1", "-c:v", "mjpeg", "-q:v", "3", tmpPath, ) cmd := exec.CommandContext(ctx, ffmpegPath, args...) cmd.SysProcAttr = &syscall.SysProcAttr{ HideWindow: true, CreationFlags: 0x08000000, // CREATE_NO_WINDOW } var stderr bytes.Buffer cmd.Stderr = &stderr if err := ctx.Err(); err != nil { return err } err := cmd.Run() if err != nil { if ctx.Err() != nil { return ctx.Err() } msgErr := strings.TrimSpace(stderr.String()) if msgErr == "" { msgErr = "(kein stderr)" } return fmt.Errorf( "ffmpeg sprite failed for %q -> %q: %w | args=%q | stderr=%s", videoPath, tmpPath, err, strings.Join(cmd.Args, " "), msgErr, ) } fi, err := os.Stat(tmpPath) if err != nil { return fmt.Errorf("sprite temp stat failed: %w", err) } if fi.IsDir() || fi.Size() <= 0 { return fmt.Errorf("sprite temp file invalid/empty") } _ = os.Remove(outPath) if err := os.Rename(tmpPath, outPath); err != nil { return fmt.Errorf("sprite rename failed: %w", err) } renameOK = true return nil }