// backend\assets_sprite.go package main import ( "context" "fmt" "os" "os/exec" "path/filepath" "strings" ) /* const ( previewSpriteCols = 10 previewSpriteRows = 8 previewSpriteFrameCount = previewSpriteCols * previewSpriteRows previewSpriteCellW = 160 previewSpriteCellH = 90 ) */ const ( previewSpriteCols = 6 previewSpriteRows = 5 previewSpriteFrameCount = previewSpriteCols * previewSpriteRows previewSpriteCellW = 120 previewSpriteCellH = 68 ) func fixedPreviewSpriteLayout() (cols, rows, count, cellW, cellH int) { return previewSpriteCols, previewSpriteRows, previewSpriteFrameCount, previewSpriteCellW, previewSpriteCellH } func previewSpriteStepSeconds(durationSec float64) float64 { if durationSec <= 0 { return 5 } step := durationSec / float64(previewSpriteFrameCount) if step < 0.5 { step = 0.5 } 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=1/%f,"+ "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, ) cmd := exec.CommandContext( ctx, ffmpegPath, "-hide_banner", "-y", "-i", videoPath, "-an", "-sn", "-threads", "0", "-vf", vf, "-frames:v", "1", "-c:v", "mjpeg", "-q:v", "4", "-f", "image2", tmpPath, ) out, err := cmd.CombinedOutput() if err != nil { msg := strings.TrimSpace(string(out)) if msg == "" { msg = "(keine ffmpeg-ausgabe)" } return fmt.Errorf( "ffmpeg sprite failed for %q -> %q: %w | args=%q | output=%s", videoPath, tmpPath, err, strings.Join(cmd.Args, " "), msg, ) } 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 }