nsfwapp/backend/assets_sprite.go
2026-04-27 14:40:16 +02:00

193 lines
4.0 KiB
Go

// backend\assets_sprite.go
package main
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
)
const (
previewSpriteCols = 10
previewSpriteRows = 8
previewSpriteFrameCount = previewSpriteCols * previewSpriteRows
previewSpriteCellW = 160
previewSpriteCellH = 90
)
func fixedPreviewSpriteLayout() (cols, rows, count, cellW, cellH int) {
return previewSpriteCols, previewSpriteRows, previewSpriteFrameCount, previewSpriteCellW, previewSpriteCellH
}
func previewSpriteStepSeconds(durationSec float64) float64 {
if durationSec <= 0 {
return 5
}
// 80 Zellen = 79 Abstände.
// Wir ziehen ein kleines Stück vom Ende ab, damit ffmpeg
// nicht "hinter" dem letzten gültigen Frame sampelt.
usableDur := durationSec - 0.10
if usableDur <= 0 {
usableDur = durationSec
}
step := usableDur / float64(previewSpriteFrameCount-1)
// Nicht künstlich auf 0.5s anheben:
// genau das erzeugt bei kürzeren Videos zu wenige Frames
// und damit schwarze Rest-Zellen im Sprite.
if step < 0.05 {
step = 0.05
}
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=fps=1/%f:start_time=0:round=up,"+
"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,
)
args := []string{
"-hide_banner",
"-y",
}
args = append(args, ffmpegInputTol...)
args = append(args,
"-i", videoPath,
"-an",
"-sn",
"-threads", "2",
"-vf", vf,
"-frames:v", "1",
"-update", "1",
"-c:v", "mjpeg",
"-q:v", "4",
tmpPath,
)
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := ctx.Err(); err != nil {
return err
}
err := cmd.Run()
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
msgErr := strings.TrimSpace(stderr.String())
if msgErr == "" {
msgErr = "(kein stderr)"
}
return fmt.Errorf(
"ffmpeg sprite failed for %q -> %q: %w | args=%q | stderr=%s",
videoPath,
tmpPath,
err,
strings.Join(cmd.Args, " "),
msgErr,
)
}
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
}