nsfwapp/backend/assets_sprite.go
2026-03-03 21:14:39 +01:00

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
}