161 lines
3.9 KiB
Go
161 lines
3.9 KiB
Go
// 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
|
|
}
|