nsfwapp/backend/assets_generate.go
2026-02-20 18:18:59 +01:00

372 lines
8.9 KiB
Go

// backend\assets_generate.go
package main
import (
"context"
"errors"
"fmt"
"math"
"os"
"path/filepath"
"strings"
"time"
)
func formatBytesSI(b int64) string {
if b < 0 {
b = 0
}
const unit = 1024
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := int64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
suffix := []string{"KB", "MB", "GB", "TB", "PB"}
v := float64(b) / float64(div)
// 1 Nachkommastelle, außer sehr große ganze Zahlen
if v >= 10 {
return fmt.Sprintf("%.0f %s", v, suffix[exp])
}
return fmt.Sprintf("%.1f %s", v, suffix[exp])
}
func u64ToI64(x uint64) int64 {
if x > uint64(maxInt64) {
return maxInt64
}
return int64(x)
}
// -------------------------
// Asset layout helpers
// -------------------------
// Asset "ID" = Dateiname ohne Endung (immer OHNE "HOT " Prefix)
func assetIDFromVideoPath(videoPath string) string {
base := filepath.Base(strings.TrimSpace(videoPath))
if base == "" {
return ""
}
id := strings.TrimSuffix(base, filepath.Ext(base))
id = stripHotPrefix(id)
return strings.TrimSpace(id)
}
// Liefert die standardisierten Pfade (thumbs.webp / preview.mp4 / meta.json)
func assetPathsForID(id string) (assetDir, thumbPath, previewPath, metaPath string, err error) {
id = strings.TrimSpace(id)
if id == "" {
return "", "", "", "", fmt.Errorf("empty id")
}
assetDir, err = ensureGeneratedDir(id)
if err != nil || strings.TrimSpace(assetDir) == "" {
return "", "", "", "", fmt.Errorf("generated dir: %v", err)
}
thumbPath = filepath.Join(assetDir, "thumbs.webp")
previewPath = filepath.Join(assetDir, "preview.mp4")
metaPath, _ = metaJSONPathForAssetID(id) // bevorzugt generated/meta/<id>/meta.json
if strings.TrimSpace(metaPath) == "" {
metaPath = filepath.Join(assetDir, "meta.json")
}
return assetDir, thumbPath, previewPath, metaPath, nil
}
type ensuredMeta struct {
durSec float64
vw, vh int
fps float64
sourceURL string
ok bool // ok = duration + props vorhanden
}
// Stellt sicher:
// - Duration ist berechnet (cache ok)
// - Props (w/h/fps) sind drin (ffprobe wenn nötig)
// - meta.json wird (best-effort) geschrieben, inkl. sourceURL
func ensureVideoMeta(ctx context.Context, videoPath, metaPath, sourceURL string, vfi os.FileInfo) (ensuredMeta, error) {
out := ensuredMeta{sourceURL: strings.TrimSpace(sourceURL)}
videoPath = strings.TrimSpace(videoPath)
metaPath = strings.TrimSpace(metaPath)
if videoPath == "" || metaPath == "" {
return out, nil
}
if vfi == nil || vfi.IsDir() || vfi.Size() <= 0 {
return out, nil
}
// 1) Try cache/meta first (inkl. w/h/fps)
if d, mw, mh, mfps, ok := readVideoMeta(metaPath, vfi); ok {
out.durSec, out.vw, out.vh, out.fps = d, mw, mh, mfps
} else {
// 2) Duration berechnen
dctx, cancel := context.WithTimeout(ctx, 6*time.Second)
d, derr := durationSecondsCached(dctx, videoPath)
cancel()
if derr == nil && d > 0 {
out.durSec = d
}
}
// 3) Props ggf. nachziehen (ffprobe)
if out.durSec > 0 && (out.vw <= 0 || out.vh <= 0 || out.fps <= 0) {
pctx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
if durSem != nil {
if err := durSem.Acquire(pctx); err == nil {
out.vw, out.vh, out.fps, _ = probeVideoProps(pctx, videoPath)
durSem.Release()
}
} else {
out.vw, out.vh, out.fps, _ = probeVideoProps(pctx, videoPath)
}
}
// 4) meta.json schreiben/aktualisieren (best-effort)
if out.durSec > 0 {
_ = writeVideoMeta(metaPath, vfi, out.durSec, out.vw, out.vh, out.fps, out.sourceURL)
}
out.ok = out.durSec > 0 && out.vw > 0 && out.vh > 0
return out, nil
}
type EnsureAssetsResult struct {
Skipped bool
ThumbGenerated bool
PreviewGenerated bool
MetaOK bool
}
// Public wrappers (kompatibel zu deinem bisherigen API)
func ensureAssetsForVideo(videoPath string) error {
// Default: keine SourceURL (für Covers egal)
return ensureAssetsForVideoWithProgress(videoPath, "", nil)
}
func ensureAssetsForVideoWithSource(videoPath string, sourceURL string) error {
return ensureAssetsForVideoWithProgress(videoPath, sourceURL, nil)
}
// onRatio: 0..1 (Assets-Gesamtfortschritt)
func ensureAssetsForVideoWithProgress(videoPath string, sourceURL string, onRatio func(r float64)) error {
ctx := context.Background()
_, err := ensureAssetsForVideoWithProgressCtx(ctx, videoPath, sourceURL, onRatio)
return err
}
// Task/Bulk sollte diesen Context-aware Call nutzen.
func ensureAssetsForVideoWithProgressCtx(ctx context.Context, videoPath string, sourceURL string, onRatio func(r float64)) (EnsureAssetsResult, error) {
res, err := ensureAssetsForVideoDetailed(ctx, videoPath, sourceURL, onRatio)
return res, err
}
// Core: generiert thumbs/preview/meta und sagt zurück was passiert ist.
func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceURL string, onRatio func(r float64)) (EnsureAssetsResult, error) {
var out EnsureAssetsResult
videoPath = strings.TrimSpace(videoPath)
if videoPath == "" {
return out, nil
}
fi, statErr := os.Stat(videoPath)
if statErr != nil || fi.IsDir() || fi.Size() <= 0 {
return out, nil
}
id := assetIDFromVideoPath(videoPath)
if id == "" {
return out, nil
}
_, thumbPath, previewPath, metaPath, perr := assetPathsForID(id)
if perr != nil {
return out, perr
}
progress := func(r float64) {
if onRatio == nil {
return
}
if r < 0 {
r = 0
}
if r > 1 {
r = 1
}
onRatio(r)
}
// Vorher-Checks (für Result)
thumbBefore := func() bool {
if tfi, err := os.Stat(thumbPath); err == nil && !tfi.IsDir() && tfi.Size() > 0 {
return true
}
return false
}()
previewBefore := func() bool {
if pfi, err := os.Stat(previewPath); err == nil && !pfi.IsDir() && pfi.Size() > 0 {
return true
}
return false
}()
// Meta sicherstellen (dedupliziert)
meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi)
out.MetaOK = meta.ok
// Wenn alles da ist: skipped
if thumbBefore && previewBefore && meta.ok {
out.Skipped = true
progress(1)
return out, nil
}
// Gewichte: thumbs klein, preview groß
const (
thumbsW = 0.25
previewW = 0.75
)
progress(0)
// ----------------
// Thumbs (WebP-only)
// ----------------
if thumbBefore {
progress(thumbsW)
} else {
progress(0.05)
func() {
genCtx, cancel := context.WithTimeout(ctx, 45*time.Second)
defer cancel()
// Acquire; wenn Context cancelled → Fehler zurück
if err := thumbSem.Acquire(genCtx); err != nil {
// wenn ctx cancelled -> hart zurück, sonst best-effort weiter
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return
}
return
}
defer thumbSem.Release()
progress(0.10)
t := 0.0
if meta.durSec > 0 {
t = meta.durSec * 0.5
}
progress(0.15)
img, e1 := extractFrameAtTimeWebP(videoPath, t)
if e1 != nil || len(img) == 0 {
img, e1 = extractLastFrameWebP(videoPath)
if e1 != nil || len(img) == 0 {
img, e1 = extractFirstFrameWebPScaled(videoPath, 720, 75)
}
}
progress(0.20)
if e1 == nil && len(img) > 0 {
if err := atomicWriteFile(thumbPath, img); err == nil {
out.ThumbGenerated = true
} else {
fmt.Println("⚠️ thumb write:", err)
}
}
}()
progress(thumbsW)
}
// ----------------
// Preview
// ----------------
if previewBefore {
progress(1)
return out, nil
}
func() {
genCtx, cancel := context.WithTimeout(ctx, 3*time.Minute)
defer cancel()
progress(thumbsW + 0.02)
if err := genSem.Acquire(genCtx); err != nil {
return
}
defer genSem.Release()
progress(thumbsW + 0.05)
if err := generateTeaserClipsMP4WithProgress(genCtx, videoPath, previewPath, 1.0, 18, func(r float64) {
if r < 0 {
r = 0
}
if r > 1 {
r = 1
}
progress(thumbsW + r*previewW)
}); err != nil {
fmt.Println("⚠️ preview clips:", err)
return
}
out.PreviewGenerated = true
// ✅ Preview-Clips (Starts + Dur) in meta.json schreiben (best-effort)
func() {
// nur wenn wir die Original-Dauer kennen
if !(meta.durSec > 0) {
return
}
// muss identisch zu generateTeaserClipsMP4WithProgress Defaults sein
opts := TeaserPreviewOptions{
Segments: 18,
SegmentDuration: 1.0,
Width: 640,
Preset: "veryfast",
CRF: 21,
Audio: true,
AudioBitrate: "128k",
UseVsync2: false,
}
starts, segDur, _ := computeTeaserStarts(meta.durSec, opts)
clips := make([]previewClip, 0, len(starts))
for _, s := range starts {
clips = append(clips, previewClip{
StartSeconds: math.Round(s*1000) / 1000, // 3 decimals wie ffmpeg arg
DurationSeconds: math.Round(segDur*1000) / 1000, // 3 decimals
})
}
// Originalvideo-fi (nicht preview-fi!), damit Validierung konsistent bleibt
_ = writeVideoMetaWithPreviewClips(metaPath, fi, meta.durSec, meta.vw, meta.vh, meta.fps, meta.sourceURL, clips)
}()
}()
progress(1)
return out, nil
}