911 lines
21 KiB
Go
911 lines
21 KiB
Go
// backend\meta.go
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// --------------------------
|
|
// generated/meta/<id>/meta.json
|
|
// --------------------------
|
|
|
|
type previewClip struct {
|
|
StartSeconds float64 `json:"startSeconds"`
|
|
DurationSeconds float64 `json:"durationSeconds"`
|
|
}
|
|
|
|
type previewSpriteMeta struct {
|
|
Path string `json:"path"` // z.B. /api/preview-sprite/<id>
|
|
Count int `json:"count"` // Anzahl Frames im Sprite
|
|
Cols int `json:"cols"` // Spalten im Tile
|
|
Rows int `json:"rows"` // Zeilen im Tile
|
|
StepSeconds float64 `json:"stepSeconds"` // Zeitabstand zwischen Frames
|
|
CellWidth int `json:"cellWidth,omitempty"`
|
|
CellHeight int `json:"cellHeight,omitempty"`
|
|
}
|
|
|
|
type videoFileMeta struct {
|
|
Size int64 `json:"size"`
|
|
ModUnix int64 `json:"modUnix"`
|
|
SourceURL string `json:"sourceUrl,omitempty"`
|
|
}
|
|
|
|
type videoStreamMeta struct {
|
|
Width int `json:"width,omitempty"`
|
|
Height int `json:"height,omitempty"`
|
|
FPS float64 `json:"fps,omitempty"`
|
|
Resolution string `json:"resolution,omitempty"`
|
|
}
|
|
|
|
type videoMediaMeta struct {
|
|
DurationSeconds float64 `json:"durationSeconds"`
|
|
Video videoStreamMeta `json:"video"`
|
|
}
|
|
|
|
type previewThumbMeta struct {
|
|
Path string `json:"path,omitempty"`
|
|
Exists bool `json:"exists,omitempty"`
|
|
}
|
|
|
|
type previewMeta struct {
|
|
Clips []previewClip `json:"clips,omitempty"`
|
|
Thumb *previewThumbMeta `json:"thumb,omitempty"`
|
|
Sprite *previewSpriteMeta `json:"sprite,omitempty"`
|
|
}
|
|
|
|
type analysisMeta struct {
|
|
Highlights *aiAnalysisMeta `json:"highlights,omitempty"`
|
|
}
|
|
|
|
type postworkHistoryEntry struct {
|
|
Queue string `json:"queue"`
|
|
Phase string `json:"phase"`
|
|
State string `json:"state"`
|
|
Label string `json:"label,omitempty"`
|
|
Position int `json:"position,omitempty"`
|
|
Waiting int `json:"waiting,omitempty"`
|
|
Running int `json:"running,omitempty"`
|
|
MaxParallel int `json:"maxParallel,omitempty"`
|
|
TS int64 `json:"ts"`
|
|
}
|
|
|
|
type postworkCompletedMeta struct {
|
|
Meta bool `json:"meta,omitempty"`
|
|
Teaser bool `json:"teaser,omitempty"`
|
|
Thumb bool `json:"thumb,omitempty"`
|
|
Sprites bool `json:"sprites,omitempty"`
|
|
Analyze bool `json:"analyze,omitempty"`
|
|
}
|
|
|
|
type videoMeta struct {
|
|
Version int `json:"version"`
|
|
UpdatedAtUnix int64 `json:"updatedAtUnix"`
|
|
|
|
File videoFileMeta `json:"file"`
|
|
Media videoMediaMeta `json:"media"`
|
|
Preview previewMeta `json:"preview,omitempty"`
|
|
Analysis analysisMeta `json:"analysis,omitempty"`
|
|
Validation map[string]any `json:"validation,omitempty"`
|
|
}
|
|
|
|
type aiSegmentMeta struct {
|
|
Label string `json:"label"`
|
|
StartSeconds float64 `json:"startSeconds"`
|
|
EndSeconds float64 `json:"endSeconds"`
|
|
DurationSeconds float64 `json:"durationSeconds"`
|
|
Score float64 `json:"score,omitempty"`
|
|
AutoSelected bool `json:"autoSelected,omitempty"`
|
|
Position string `json:"position,omitempty"`
|
|
Tags []string `json:"tags,omitempty"`
|
|
MarkerSeconds float64 `json:"markerSeconds,omitempty"`
|
|
PreviewSeconds float64 `json:"previewSeconds,omitempty"`
|
|
}
|
|
|
|
type aiAnalysisMeta struct {
|
|
Goal string `json:"goal,omitempty"`
|
|
Mode string `json:"mode,omitempty"`
|
|
Hits []analyzeHit `json:"hits,omitempty"`
|
|
Segments []aiSegmentMeta `json:"segments,omitempty"`
|
|
Rating *aiRatingMeta `json:"rating,omitempty"`
|
|
AnalyzedAtUnix int64 `json:"analyzedAtUnix,omitempty"`
|
|
}
|
|
|
|
func readVideoMeta(metaPath string, fi os.FileInfo) (dur float64, w int, h int, fps float64, ok bool) {
|
|
m, ok := readVideoMetaIfValid(metaPath, fi)
|
|
if !ok || m == nil {
|
|
return 0, 0, 0, 0, false
|
|
}
|
|
return m.Media.DurationSeconds, m.Media.Video.Width, m.Media.Video.Height, m.Media.Video.FPS, true
|
|
}
|
|
|
|
func readVideoMetaDuration(metaPath string, fi os.FileInfo) (float64, bool) {
|
|
m, ok := readVideoMetaIfValid(metaPath, fi)
|
|
if !ok || m == nil || m.Media.DurationSeconds <= 0 {
|
|
return 0, false
|
|
}
|
|
return m.Media.DurationSeconds, true
|
|
}
|
|
|
|
func metaJSONPathForAssetID(assetID string) (string, error) {
|
|
root, err := generatedMetaRoot()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if strings.TrimSpace(root) == "" {
|
|
return "", appErrorf("generated/meta root leer")
|
|
}
|
|
return filepath.Join(root, assetID, "meta.json"), nil
|
|
}
|
|
|
|
func ensureVideoMetaForFile(ctx context.Context, fullPath string, fi os.FileInfo, sourceURL string) (*videoMeta, bool) {
|
|
stem := strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath))
|
|
assetID := stripHotPrefix(strings.TrimSpace(stem))
|
|
if assetID == "" {
|
|
return nil, false
|
|
}
|
|
|
|
var err error
|
|
assetID, err = sanitizeID(assetID)
|
|
if err != nil || assetID == "" {
|
|
return nil, false
|
|
}
|
|
|
|
metaPath, err := metaJSONPathForAssetID(assetID)
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
|
|
if m, ok := readVideoMetaIfValid(metaPath, fi); ok {
|
|
return m, true
|
|
}
|
|
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
cctx, cancel := context.WithTimeout(ctx, 8*time.Second)
|
|
defer cancel()
|
|
|
|
if durSem != nil {
|
|
if err := durSem.Acquire(cctx); err != nil {
|
|
return nil, false
|
|
}
|
|
defer durSem.Release()
|
|
}
|
|
|
|
dur, derr := durationSecondsCached(cctx, fullPath)
|
|
if derr != nil || dur <= 0 {
|
|
return nil, false
|
|
}
|
|
|
|
w, h, fps, perr := probeVideoProps(cctx, fullPath)
|
|
if perr != nil {
|
|
w, h, fps = 0, 0, 0
|
|
}
|
|
|
|
_ = os.MkdirAll(filepath.Dir(metaPath), 0o755)
|
|
|
|
if err := writeVideoMeta(metaPath, fi, dur, w, h, fps, sourceURL); err != nil {
|
|
return nil, false
|
|
}
|
|
|
|
m, ok := readVideoMetaLoose(metaPath)
|
|
if !ok || m == nil {
|
|
return nil, false
|
|
}
|
|
|
|
return m, true
|
|
}
|
|
|
|
func attachMetaToJobBestEffort(ctx context.Context, job *RecordJob, fullPath string) {
|
|
if job == nil {
|
|
return
|
|
}
|
|
fullPath = strings.TrimSpace(fullPath)
|
|
if fullPath == "" {
|
|
return
|
|
}
|
|
|
|
fi, err := os.Stat(fullPath)
|
|
if err != nil || fi == nil || fi.IsDir() {
|
|
return
|
|
}
|
|
|
|
if job.SizeBytes <= 0 {
|
|
job.SizeBytes = fi.Size()
|
|
}
|
|
|
|
m, ok := ensureVideoMetaForFileBestEffort(ctx, fullPath, job.SourceURL)
|
|
if !ok || m == nil {
|
|
return
|
|
}
|
|
|
|
job.Meta = m
|
|
|
|
if job.DurationSeconds <= 0 && m.Media.DurationSeconds > 0 {
|
|
job.DurationSeconds = m.Media.DurationSeconds
|
|
}
|
|
if job.VideoWidth <= 0 && m.Media.Video.Width > 0 {
|
|
job.VideoWidth = m.Media.Video.Width
|
|
}
|
|
if job.VideoHeight <= 0 && m.Media.Video.Height > 0 {
|
|
job.VideoHeight = m.Media.Video.Height
|
|
}
|
|
if job.FPS <= 0 && m.Media.Video.FPS > 0 {
|
|
job.FPS = m.Media.Video.FPS
|
|
}
|
|
}
|
|
|
|
func ensureVideoMetaForFileBestEffort(ctx context.Context, fullPath string, sourceURL string) (*videoMeta, bool) {
|
|
fullPath = strings.TrimSpace(fullPath)
|
|
if fullPath == "" {
|
|
return nil, false
|
|
}
|
|
|
|
fi, err := os.Stat(fullPath)
|
|
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
|
|
return nil, false
|
|
}
|
|
|
|
if m, ok := ensureVideoMetaForFile(ctx, fullPath, fi, sourceURL); ok && m != nil {
|
|
return m, true
|
|
}
|
|
|
|
dur := durationSecondsCacheOnly(fullPath, fi)
|
|
if dur <= 0 {
|
|
return nil, false
|
|
}
|
|
|
|
stem := strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath))
|
|
assetID := stripHotPrefix(strings.TrimSpace(stem))
|
|
if assetID == "" {
|
|
return nil, false
|
|
}
|
|
|
|
assetID, err = sanitizeID(assetID)
|
|
if err != nil || assetID == "" {
|
|
return nil, false
|
|
}
|
|
|
|
metaPath, err := metaJSONPathForAssetID(assetID)
|
|
if err != nil || strings.TrimSpace(metaPath) == "" {
|
|
return nil, false
|
|
}
|
|
|
|
_ = os.MkdirAll(filepath.Dir(metaPath), 0o755)
|
|
_ = writeVideoMetaDuration(metaPath, fi, dur, sourceURL)
|
|
|
|
if m, ok := readVideoMetaIfValid(metaPath, fi); ok && m != nil {
|
|
return m, true
|
|
}
|
|
|
|
return nil, false
|
|
}
|
|
|
|
func readVideoMetaIfValid(metaPath string, fi os.FileInfo) (*videoMeta, bool) {
|
|
b, err := os.ReadFile(metaPath)
|
|
if err != nil || len(b) == 0 {
|
|
return nil, false
|
|
}
|
|
|
|
var m videoMeta
|
|
if err := json.Unmarshal(b, &m); err != nil {
|
|
return nil, false
|
|
}
|
|
if m.Version != 3 {
|
|
return nil, false
|
|
}
|
|
if m.File.Size != fi.Size() || m.File.ModUnix != fi.ModTime().Unix() {
|
|
return nil, false
|
|
}
|
|
if m.Media.DurationSeconds <= 0 {
|
|
return nil, false
|
|
}
|
|
|
|
return &m, true
|
|
}
|
|
|
|
func readVideoMetaLoose(metaPath string) (*videoMeta, bool) {
|
|
b, err := os.ReadFile(metaPath)
|
|
if err != nil || len(b) == 0 {
|
|
return nil, false
|
|
}
|
|
|
|
var m videoMeta
|
|
if err := json.Unmarshal(b, &m); err != nil {
|
|
return nil, false
|
|
}
|
|
|
|
if m.Version == 0 {
|
|
m.Version = 3
|
|
}
|
|
|
|
return &m, true
|
|
}
|
|
|
|
func mergePreviewSpriteMeta(existing, incoming *previewSpriteMeta) *previewSpriteMeta {
|
|
if existing == nil && incoming == nil {
|
|
return nil
|
|
}
|
|
if existing == nil {
|
|
cp := *incoming
|
|
return &cp
|
|
}
|
|
if incoming == nil {
|
|
cp := *existing
|
|
return &cp
|
|
}
|
|
|
|
out := *incoming
|
|
|
|
if strings.TrimSpace(out.Path) == "" {
|
|
out.Path = existing.Path
|
|
}
|
|
if out.Count <= 0 {
|
|
out.Count = existing.Count
|
|
}
|
|
if out.Cols <= 0 {
|
|
out.Cols = existing.Cols
|
|
}
|
|
if out.Rows <= 0 {
|
|
out.Rows = existing.Rows
|
|
}
|
|
if out.StepSeconds <= 0 {
|
|
out.StepSeconds = existing.StepSeconds
|
|
}
|
|
if out.CellWidth <= 0 {
|
|
out.CellWidth = existing.CellWidth
|
|
}
|
|
if out.CellHeight <= 0 {
|
|
out.CellHeight = existing.CellHeight
|
|
}
|
|
|
|
return &out
|
|
}
|
|
|
|
func hasAIAnalysisMeta(ai *aiAnalysisMeta) bool {
|
|
if ai == nil {
|
|
return false
|
|
}
|
|
if strings.TrimSpace(ai.Goal) != "" {
|
|
return true
|
|
}
|
|
if strings.TrimSpace(ai.Mode) != "" {
|
|
return true
|
|
}
|
|
if len(ai.Hits) > 0 {
|
|
return true
|
|
}
|
|
if len(ai.Segments) > 0 {
|
|
return true
|
|
}
|
|
if ai.Rating != nil {
|
|
return true
|
|
}
|
|
if ai.AnalyzedAtUnix > 0 {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func pickAnalysisAIForGoal(a analysisMeta, goal string) *aiAnalysisMeta {
|
|
if hasAIAnalysisMeta(a.Highlights) {
|
|
return a.Highlights
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func readVideoMetaSourceURL(metaPath string, fi os.FileInfo) (string, bool) {
|
|
m, ok := readVideoMetaIfValid(metaPath, fi)
|
|
if !ok || m == nil {
|
|
return "", false
|
|
}
|
|
u := strings.TrimSpace(m.File.SourceURL)
|
|
if u == "" {
|
|
return "", false
|
|
}
|
|
return u, true
|
|
}
|
|
|
|
func writeVideoMeta(
|
|
metaPath string,
|
|
fi os.FileInfo,
|
|
dur float64,
|
|
w int,
|
|
h int,
|
|
fps float64,
|
|
sourceURL string,
|
|
) error {
|
|
if strings.TrimSpace(metaPath) == "" || dur <= 0 {
|
|
return nil
|
|
}
|
|
|
|
var existing *videoMeta
|
|
if old, ok := readVideoMetaLoose(metaPath); ok && old != nil {
|
|
existing = old
|
|
}
|
|
|
|
m := videoMeta{
|
|
Version: 3,
|
|
UpdatedAtUnix: time.Now().Unix(),
|
|
File: videoFileMeta{
|
|
Size: fi.Size(),
|
|
ModUnix: fi.ModTime().Unix(),
|
|
SourceURL: strings.TrimSpace(sourceURL),
|
|
},
|
|
Media: videoMediaMeta{
|
|
DurationSeconds: dur,
|
|
Video: videoStreamMeta{
|
|
Width: w,
|
|
Height: h,
|
|
FPS: fps,
|
|
Resolution: formatResolution(w, h),
|
|
},
|
|
},
|
|
}
|
|
|
|
if existing != nil {
|
|
m.Preview = existing.Preview
|
|
m.Analysis = existing.Analysis
|
|
m.Validation = existing.Validation
|
|
|
|
if m.File.SourceURL == "" {
|
|
m.File.SourceURL = existing.File.SourceURL
|
|
}
|
|
if m.Media.Video.Width <= 0 {
|
|
m.Media.Video.Width = existing.Media.Video.Width
|
|
}
|
|
if m.Media.Video.Height <= 0 {
|
|
m.Media.Video.Height = existing.Media.Video.Height
|
|
}
|
|
if m.Media.Video.FPS <= 0 {
|
|
m.Media.Video.FPS = existing.Media.Video.FPS
|
|
}
|
|
if m.Media.Video.Resolution == "" {
|
|
m.Media.Video.Resolution = existing.Media.Video.Resolution
|
|
}
|
|
}
|
|
|
|
buf, err := json.MarshalIndent(m, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
buf = append(buf, '\n')
|
|
return atomicWriteFile(metaPath, buf)
|
|
}
|
|
|
|
func writeVideoMetaAI(
|
|
metaPath string,
|
|
fi os.FileInfo,
|
|
dur float64,
|
|
w int,
|
|
h int,
|
|
fps float64,
|
|
sourceURL string,
|
|
ai *aiAnalysisMeta,
|
|
) error {
|
|
if strings.TrimSpace(metaPath) == "" || dur <= 0 {
|
|
return nil
|
|
}
|
|
|
|
var existing *videoMeta
|
|
if old, ok := readVideoMetaLoose(metaPath); ok && old != nil {
|
|
existing = old
|
|
}
|
|
|
|
m := videoMeta{
|
|
Version: 3,
|
|
UpdatedAtUnix: time.Now().Unix(),
|
|
File: videoFileMeta{
|
|
Size: fi.Size(),
|
|
ModUnix: fi.ModTime().Unix(),
|
|
SourceURL: strings.TrimSpace(sourceURL),
|
|
},
|
|
Media: videoMediaMeta{
|
|
DurationSeconds: dur,
|
|
Video: videoStreamMeta{
|
|
Width: w,
|
|
Height: h,
|
|
FPS: fps,
|
|
Resolution: formatResolution(w, h),
|
|
},
|
|
},
|
|
}
|
|
|
|
if existing != nil {
|
|
m.Preview = existing.Preview
|
|
m.Validation = existing.Validation
|
|
|
|
if m.Media.Video.Width <= 0 {
|
|
m.Media.Video.Width = existing.Media.Video.Width
|
|
}
|
|
if m.Media.Video.Height <= 0 {
|
|
m.Media.Video.Height = existing.Media.Video.Height
|
|
}
|
|
if m.Media.Video.FPS <= 0 {
|
|
m.Media.Video.FPS = existing.Media.Video.FPS
|
|
}
|
|
if m.Media.Video.Resolution == "" {
|
|
m.Media.Video.Resolution = existing.Media.Video.Resolution
|
|
}
|
|
if m.File.SourceURL == "" {
|
|
m.File.SourceURL = existing.File.SourceURL
|
|
}
|
|
if m.Media.DurationSeconds <= 0 {
|
|
m.Media.DurationSeconds = existing.Media.DurationSeconds
|
|
}
|
|
|
|
// Wichtig:
|
|
// analysis wird NICHT aus existing übernommen.
|
|
// Dadurch verschwinden alte analysis.ai / analysis.nsfw beim nächsten Schreiben.
|
|
}
|
|
|
|
if hasAIAnalysisMeta(ai) {
|
|
ai.Goal = "highlights"
|
|
m.Analysis.Highlights = ai
|
|
}
|
|
|
|
buf, err := json.MarshalIndent(m, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
buf = append(buf, '\n')
|
|
return atomicWriteFile(metaPath, buf)
|
|
}
|
|
|
|
func writeVideoMetaWithPreviewClips(metaPath string, fi os.FileInfo, dur float64, w int, h int, fps float64, sourceURL string, clips []previewClip) error {
|
|
if strings.TrimSpace(metaPath) == "" || dur <= 0 {
|
|
return nil
|
|
}
|
|
assetID := strings.TrimSpace(filepath.Base(filepath.Dir(metaPath)))
|
|
|
|
var existing *videoMeta
|
|
if old, ok := readVideoMetaLoose(metaPath); ok && old != nil {
|
|
existing = old
|
|
}
|
|
|
|
m := videoMeta{
|
|
Version: 3,
|
|
UpdatedAtUnix: time.Now().Unix(),
|
|
File: videoFileMeta{
|
|
Size: fi.Size(),
|
|
ModUnix: fi.ModTime().Unix(),
|
|
SourceURL: strings.TrimSpace(sourceURL),
|
|
},
|
|
Media: videoMediaMeta{
|
|
DurationSeconds: dur,
|
|
Video: videoStreamMeta{
|
|
Width: w,
|
|
Height: h,
|
|
FPS: fps,
|
|
Resolution: formatResolution(w, h),
|
|
},
|
|
},
|
|
Preview: previewMeta{
|
|
Clips: clips,
|
|
Thumb: buildPreviewThumbMeta(assetID),
|
|
},
|
|
}
|
|
|
|
if existing != nil {
|
|
m.Analysis = existing.Analysis
|
|
|
|
if existing.Preview.Sprite != nil && m.Preview.Sprite == nil {
|
|
m.Preview.Sprite = existing.Preview.Sprite
|
|
}
|
|
if m.Preview.Thumb == nil && existing.Preview.Thumb != nil {
|
|
m.Preview.Thumb = existing.Preview.Thumb
|
|
}
|
|
if m.File.SourceURL == "" {
|
|
m.File.SourceURL = existing.File.SourceURL
|
|
}
|
|
if m.Media.Video.Width <= 0 {
|
|
m.Media.Video.Width = existing.Media.Video.Width
|
|
}
|
|
if m.Media.Video.Height <= 0 {
|
|
m.Media.Video.Height = existing.Media.Video.Height
|
|
}
|
|
if m.Media.Video.FPS <= 0 {
|
|
m.Media.Video.FPS = existing.Media.Video.FPS
|
|
}
|
|
if m.Media.Video.Resolution == "" {
|
|
m.Media.Video.Resolution = existing.Media.Video.Resolution
|
|
}
|
|
}
|
|
|
|
buf, err := json.Marshal(m)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
buf = append(buf, '\n')
|
|
return atomicWriteFile(metaPath, buf)
|
|
}
|
|
|
|
func writeVideoMetaWithPreviewClipsAndSprite(
|
|
metaPath string,
|
|
fi os.FileInfo,
|
|
dur float64,
|
|
w int,
|
|
h int,
|
|
fps float64,
|
|
sourceURL string,
|
|
clips []previewClip,
|
|
sprite *previewSpriteMeta,
|
|
) error {
|
|
if strings.TrimSpace(metaPath) == "" || dur <= 0 {
|
|
return nil
|
|
}
|
|
assetID := strings.TrimSpace(filepath.Base(filepath.Dir(metaPath)))
|
|
|
|
var existing *videoMeta
|
|
if old, ok := readVideoMetaLoose(metaPath); ok && old != nil {
|
|
existing = old
|
|
}
|
|
|
|
m := videoMeta{
|
|
Version: 3,
|
|
UpdatedAtUnix: time.Now().Unix(),
|
|
File: videoFileMeta{
|
|
Size: fi.Size(),
|
|
ModUnix: fi.ModTime().Unix(),
|
|
SourceURL: strings.TrimSpace(sourceURL),
|
|
},
|
|
Media: videoMediaMeta{
|
|
DurationSeconds: dur,
|
|
Video: videoStreamMeta{
|
|
Width: w,
|
|
Height: h,
|
|
FPS: fps,
|
|
Resolution: formatResolution(w, h),
|
|
},
|
|
},
|
|
Preview: previewMeta{
|
|
Clips: clips,
|
|
Thumb: buildPreviewThumbMeta(assetID),
|
|
Sprite: sprite,
|
|
},
|
|
}
|
|
|
|
if existing != nil {
|
|
m.Analysis = existing.Analysis
|
|
|
|
m.Preview.Sprite = mergePreviewSpriteMeta(existing.Preview.Sprite, m.Preview.Sprite)
|
|
|
|
if m.Preview.Thumb == nil && existing.Preview.Thumb != nil {
|
|
m.Preview.Thumb = existing.Preview.Thumb
|
|
}
|
|
if len(m.Preview.Clips) == 0 && len(existing.Preview.Clips) > 0 {
|
|
m.Preview.Clips = existing.Preview.Clips
|
|
}
|
|
if m.File.SourceURL == "" {
|
|
m.File.SourceURL = existing.File.SourceURL
|
|
}
|
|
if m.Media.Video.Width <= 0 {
|
|
m.Media.Video.Width = existing.Media.Video.Width
|
|
}
|
|
if m.Media.Video.Height <= 0 {
|
|
m.Media.Video.Height = existing.Media.Video.Height
|
|
}
|
|
if m.Media.Video.FPS <= 0 {
|
|
m.Media.Video.FPS = existing.Media.Video.FPS
|
|
}
|
|
if m.Media.Video.Resolution == "" {
|
|
m.Media.Video.Resolution = existing.Media.Video.Resolution
|
|
}
|
|
if m.Media.DurationSeconds <= 0 {
|
|
m.Media.DurationSeconds = existing.Media.DurationSeconds
|
|
}
|
|
}
|
|
|
|
buf, err := json.Marshal(m)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
buf = append(buf, '\n')
|
|
return atomicWriteFile(metaPath, buf)
|
|
}
|
|
|
|
func writeVideoMetaDuration(metaPath string, fi os.FileInfo, dur float64, sourceURL string) error {
|
|
return writeVideoMeta(metaPath, fi, dur, 0, 0, 0, sourceURL)
|
|
}
|
|
|
|
func generatedMetaFile(assetID string) (string, error) {
|
|
assetID = stripHotPrefix(strings.TrimSpace(assetID))
|
|
if assetID == "" {
|
|
return "", appErrorf("empty assetID")
|
|
}
|
|
|
|
id, err := sanitizeID(assetID)
|
|
if err != nil || id == "" {
|
|
return "", appErrorf("invalid assetID: %w", err)
|
|
}
|
|
|
|
return metaJSONPathForAssetID(id)
|
|
}
|
|
|
|
func generatedDirForID(id string) (string, error) {
|
|
id, err := sanitizeID(id)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
root, err := generatedMetaRoot()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if strings.TrimSpace(root) == "" {
|
|
return "", appErrorf("generated meta root ist leer")
|
|
}
|
|
return filepath.Join(root, id), nil
|
|
}
|
|
|
|
func ensureGeneratedDir(id string) (string, error) {
|
|
dir, err := generatedDirForID(id)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return "", err
|
|
}
|
|
return dir, nil
|
|
}
|
|
|
|
func formatResolution(w, h int) string {
|
|
if w <= 0 || h <= 0 {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("%dx%d", w, h)
|
|
}
|
|
|
|
func generatedThumbFile(id string) (string, error) {
|
|
dir, err := generatedDirForID(id)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.Join(dir, "preview.jpg"), nil
|
|
}
|
|
|
|
func generatedPreviewFile(id string) (string, error) {
|
|
dir, err := generatedDirForID(id)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.Join(dir, "preview.mp4"), nil
|
|
}
|
|
|
|
func generatedPreviewSpriteFile(id string) (string, error) {
|
|
dir, err := generatedDirForID(id)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.Join(dir, "preview-sprite.jpg"), nil
|
|
}
|
|
|
|
func buildPreviewThumbMeta(assetID string) *previewThumbMeta {
|
|
assetID = stripHotPrefix(strings.TrimSpace(assetID))
|
|
if assetID == "" {
|
|
return nil
|
|
}
|
|
|
|
thumbPath, err := generatedThumbFile(assetID)
|
|
if err != nil || strings.TrimSpace(thumbPath) == "" {
|
|
return nil
|
|
}
|
|
|
|
if _, err := os.Stat(thumbPath); err != nil {
|
|
return nil
|
|
}
|
|
|
|
return &previewThumbMeta{
|
|
Path: "/api/generated/cover?id=" + assetID,
|
|
Exists: true,
|
|
}
|
|
}
|
|
|
|
func ensureGeneratedDirs() error {
|
|
root, err := generatedMetaRoot()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(root) == "" {
|
|
return appErrorf("generated meta root ist leer")
|
|
}
|
|
return os.MkdirAll(root, 0o755)
|
|
}
|
|
|
|
func sanitizeID(id string) (string, error) {
|
|
id = strings.TrimSpace(id)
|
|
if id == "" {
|
|
return "", appErrorf("id fehlt")
|
|
}
|
|
if strings.ContainsAny(id, `/\`) {
|
|
return "", appErrorf("ungültige id")
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
func writeVideoAIForFile(
|
|
ctx context.Context,
|
|
fullPath string,
|
|
sourceURL string,
|
|
ai *aiAnalysisMeta,
|
|
) error {
|
|
fullPath = strings.TrimSpace(fullPath)
|
|
if fullPath == "" || ai == nil {
|
|
return nil
|
|
}
|
|
|
|
fi, err := os.Stat(fullPath)
|
|
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
|
|
return appErrorf("datei nicht gefunden")
|
|
}
|
|
|
|
m, ok := ensureVideoMetaForFileBestEffort(ctx, fullPath, sourceURL)
|
|
if !ok || m == nil {
|
|
return appErrorf("meta konnte nicht erzeugt werden")
|
|
}
|
|
|
|
stem := strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath))
|
|
assetID := stripHotPrefix(strings.TrimSpace(stem))
|
|
if assetID == "" {
|
|
return appErrorf("asset id fehlt")
|
|
}
|
|
|
|
assetID, err = sanitizeID(assetID)
|
|
if err != nil || assetID == "" {
|
|
return appErrorf("asset id ungültig: %w", err)
|
|
}
|
|
|
|
metaPath, err := metaJSONPathForAssetID(assetID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if strings.TrimSpace(sourceURL) == "" {
|
|
sourceURL = m.File.SourceURL
|
|
}
|
|
|
|
return writeVideoMetaAI(
|
|
metaPath,
|
|
fi,
|
|
m.Media.DurationSeconds,
|
|
m.Media.Video.Width,
|
|
m.Media.Video.Height,
|
|
m.Media.Video.FPS,
|
|
sourceURL,
|
|
ai,
|
|
)
|
|
}
|
|
|
|
func readVideoMetaAIForGoal(metaPath string, goal string) (map[string]any, bool) {
|
|
b, err := os.ReadFile(metaPath)
|
|
if err != nil || len(b) == 0 {
|
|
return nil, false
|
|
}
|
|
|
|
var m map[string]any
|
|
dec := json.NewDecoder(strings.NewReader(string(b)))
|
|
dec.UseNumber()
|
|
if err := dec.Decode(&m); err != nil {
|
|
return nil, false
|
|
}
|
|
|
|
rawAnalysis, ok := m["analysis"].(map[string]any)
|
|
if !ok || rawAnalysis == nil {
|
|
return nil, false
|
|
}
|
|
|
|
// Neuer einziger Zweig.
|
|
if highlights, ok := rawAnalysis["highlights"].(map[string]any); ok && highlights != nil {
|
|
return highlights, true
|
|
}
|
|
|
|
return nil, false
|
|
}
|