nsfwapp/backend/assets_sprite.go
2026-03-16 12:46:38 +01:00

169 lines
3.5 KiB
Go

// 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
}