// backend\assets_sprite.go package main import ( "context" "fmt" "math" "os" "os/exec" "path/filepath" "strings" ) // chooseSpriteGrid wählt ein sinnvolles cols/rows-Grid für count Frames. // Ziel: wenig leere Zellen + eher horizontales Layout (passt gut zu 16:9 Cells). func chooseSpriteGrid(count int) (cols, rows int) { if count <= 0 { return 1, 1 } if count == 1 { return 1, 1 } targetRatio := 16.0 / 9.0 // wir bevorzugen horizontale Spritesheets bestCols, bestRows := 1, count bestWaste := math.MaxInt bestRatioScore := math.MaxFloat64 for c := 1; c <= count; c++ { r := int(math.Ceil(float64(count) / float64(c))) if r <= 0 { r = 1 } waste := c*r - count ratio := float64(c) / float64(r) ratioScore := math.Abs(ratio - targetRatio) // Priorität: // 1) weniger leere Zellen // 2) näher an targetRatio // 3) bei Gleichstand weniger Rows (lieber breiter als hoch) if waste < bestWaste || (waste == bestWaste && ratioScore < bestRatioScore) || (waste == bestWaste && ratioScore == bestRatioScore && r < bestRows) { bestWaste = waste bestRatioScore = ratioScore bestCols = c bestRows = r } } return bestCols, bestRows } // generatePreviewSpriteWebP erzeugt ein statisches WebP-Spritesheet aus einem Video. // ffmpeg muss im PATH verfügbar sein. func generatePreviewSpriteWebP( 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("generatePreviewSpriteWebP: empty videoPath") } if outPath == "" { return fmt.Errorf("generatePreviewSpriteWebP: empty outPath") } if cols <= 0 || rows <= 0 { return fmt.Errorf("generatePreviewSpriteWebP: invalid grid %dx%d", cols, rows) } if stepSec <= 0 { return fmt.Errorf("generatePreviewSpriteWebP: invalid stepSec %.3f", stepSec) } if cellW <= 0 || cellH <= 0 { return fmt.Errorf("generatePreviewSpriteWebP: invalid cell size %dx%d", cellW, cellH) } // Zielordner sicherstellen if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil { return fmt.Errorf("mkdir sprite dir: %w", err) } // Temp-Datei im gleichen Verzeichnis für atomaren Replace. // Wichtig: ffmpeg erkennt das Output-Format über die Endung. // Deshalb muss .webp am Ende stehen (nicht "...webp.tmp"). ext := filepath.Ext(outPath) if ext == "" { ext = ".webp" } base := strings.TrimSuffix(outPath, ext) tmpPath := base + ".tmp" + ext // z.B. preview-sprite.tmp.webp // fps=1/stepSec nimmt alle stepSec Sekunden einen Frame // scale+pad erzwingt feste Zellgröße (wichtig für korrektes background-positioning im Frontend) vf := fmt.Sprintf( "fps=1/%g,scale=%d:%d:force_original_aspect_ratio=decrease:flags=lanczos,"+ "pad=%d:%d:(ow-iw)/2:(oh-ih)/2:black,tile=%dx%d:margin=0:padding=0", stepSec, cellW, cellH, cellW, cellH, cols, rows, ) // Statisches WebP-Spritesheet cmd := exec.CommandContext( ctx, "ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-i", videoPath, "-an", "-sn", "-vf", vf, "-frames:v", "1", "-c:v", "libwebp", "-lossless", "0", "-compression_level", "6", "-q:v", "80", "-f", "webp", tmpPath, ) out, err := cmd.CombinedOutput() if err != nil { msg := strings.TrimSpace(string(out)) if msg != "" { return fmt.Errorf("ffmpeg sprite failed: %w: %s", err, msg) } return fmt.Errorf("ffmpeg sprite failed: %w", err) } fi, err := os.Stat(tmpPath) if err != nil { return fmt.Errorf("sprite temp stat failed: %w", err) } if fi.IsDir() || fi.Size() <= 0 { _ = os.Remove(tmpPath) return fmt.Errorf("sprite temp file invalid/empty") } // Windows: Ziel vorher löschen, damit Rename klappt _ = os.Remove(outPath) if err := os.Rename(tmpPath, outPath); err != nil { _ = os.Remove(tmpPath) return fmt.Errorf("sprite rename failed: %w", err) } return nil }