bugfixes
This commit is contained in:
parent
15b5c59a53
commit
1da3c11f04
@ -1,2 +1,2 @@
|
|||||||
HTTPS_ENABLED=1
|
HTTPS_ENABLED=0
|
||||||
AUTH_RP_ORIGINS=https://l14pbbk95100006.tegdssd.de:9999,https://l14pbbk95100006.tegdssd.de:5173,https://localhost:9999,https://127.0.0.1:9999,https://10.0.1.25:9999,http://localhost:5173,http://127.0.0.1:5173,http://10.0.1.25:5173
|
AUTH_RP_ORIGINS=https://l14pbbk95100006.tegdssd.de:9999,https://l14pbbk95100006.tegdssd.de:5173,https://localhost:9999,https://127.0.0.1:9999,https://10.0.1.25:9999,http://localhost:5173,http://127.0.0.1:5173,http://10.0.1.25:5173
|
||||||
@ -980,7 +980,7 @@ func recordAnalyzeVideo(w http.ResponseWriter, r *http.Request) {
|
|||||||
segments = prepareAIRatingSegments(segments)
|
segments = prepareAIRatingSegments(segments)
|
||||||
appLogf("🧪 [analyze] rating segments file=%q count=%d", file, len(segments))
|
appLogf("🧪 [analyze] rating segments file=%q count=%d", file, len(segments))
|
||||||
|
|
||||||
rating := computeHighlightRating(segments, durationSec)
|
rating := computeHighlightRatingForVideo(segments, durationSec, outPath)
|
||||||
|
|
||||||
ai := &aiAnalysisMeta{
|
ai := &aiAnalysisMeta{
|
||||||
Goal: "highlights",
|
Goal: "highlights",
|
||||||
|
|||||||
@ -174,7 +174,7 @@ func ensureAnalyzeAllGoalsForVideoCtxForce(
|
|||||||
segments := buildAnalyzeSegmentsForGoal(hits, durationSec)
|
segments := buildAnalyzeSegmentsForGoal(hits, durationSec)
|
||||||
segments = prepareAIRatingSegments(segments)
|
segments = prepareAIRatingSegments(segments)
|
||||||
|
|
||||||
rating := computeHighlightRating(segments, durationSec)
|
rating := computeHighlightRatingForVideo(segments, durationSec, videoPath)
|
||||||
|
|
||||||
ai := &aiAnalysisMeta{
|
ai := &aiAnalysisMeta{
|
||||||
Goal: "highlights",
|
Goal: "highlights",
|
||||||
@ -1375,24 +1375,35 @@ func hasAIAnalysisForOutputGoal(outPath string, goal string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wichtig:
|
// Eine Analyse ist erst dann fertig, wenn wirklich ein Rating-Objekt
|
||||||
// Eine Analyse mit 0 Hits / 0 Segments ist trotzdem eine fertige Analyse.
|
// mit typischen Rating-Feldern gespeichert wurde.
|
||||||
// Sonst zeigt das Frontend fälschlich "Analyse fehlt".
|
rating, ok := aiMap["rating"].(map[string]any)
|
||||||
if jsonNumberPositive(aiMap["analyzedAtUnix"]) {
|
if !ok || rating == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonFieldExists(rating, "score") ||
|
||||||
|
jsonFieldExists(rating, "stars") ||
|
||||||
|
jsonFieldExists(rating, "segments") {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if rating, ok := aiMap["rating"].(map[string]any); ok && rating != nil {
|
// Legacy-Fallback: alte Dateien ohne Rating, aber mit Hits/Segments.
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Legacy-Fallback für alte meta.json-Dateien.
|
|
||||||
rawHits, hasHits := aiMap["hits"].([]any)
|
rawHits, hasHits := aiMap["hits"].([]any)
|
||||||
rawSegs, hasSegs := aiMap["segments"].([]any)
|
rawSegs, hasSegs := aiMap["segments"].([]any)
|
||||||
|
|
||||||
return (hasHits && len(rawHits) > 0) || (hasSegs && len(rawSegs) > 0)
|
return (hasHits && len(rawHits) > 0) || (hasSegs && len(rawSegs) > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func jsonFieldExists(m map[string]any, key string) bool {
|
||||||
|
if m == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
_, ok := m[key]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
func jsonNumberPositive(v any) bool {
|
func jsonNumberPositive(v any) bool {
|
||||||
switch x := v.(type) {
|
switch x := v.(type) {
|
||||||
case json.Number:
|
case json.Number:
|
||||||
@ -1522,7 +1533,7 @@ func prepareVideoForSplit(ctx context.Context, videoPath, sourceURL, goal string
|
|||||||
segments := buildAnalyzeSegmentsForGoal(hits, durationSec)
|
segments := buildAnalyzeSegmentsForGoal(hits, durationSec)
|
||||||
segments = prepareAIRatingSegments(segments)
|
segments = prepareAIRatingSegments(segments)
|
||||||
|
|
||||||
rating := computeHighlightRating(segments, durationSec)
|
rating := computeHighlightRatingForVideo(segments, durationSec, videoPath)
|
||||||
|
|
||||||
ai := &aiAnalysisMeta{
|
ai := &aiAnalysisMeta{
|
||||||
Goal: "highlights",
|
Goal: "highlights",
|
||||||
|
|||||||
@ -81,9 +81,6 @@ func (am *AuthManager) initWebAuthn() error {
|
|||||||
return errors.New("no WebAuthn RP origins configured")
|
return errors.New("no WebAuthn RP origins configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
appLogln("🔐 WebAuthn allowed origins:", strings.Join(origins, ", "))
|
|
||||||
appLogln("🔐 AUTH_RP_ID:", strings.TrimSpace(os.Getenv("AUTH_RP_ID")))
|
|
||||||
|
|
||||||
rpID, err := rpIDForOrigin(origins[0])
|
rpID, err := rpIDForOrigin(origins[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@ -1162,6 +1162,67 @@ func servePreviewStatusSVG(w http.ResponseWriter, label string, status int) {
|
|||||||
_, _ = w.Write([]byte(svg))
|
_, _ = w.Write([]byte(svg))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func shortFFmpegStderr(raw string) string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(raw, "\n")
|
||||||
|
clean := make([]string, 0, len(lines))
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
clean = append(clean, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(clean) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{
|
||||||
|
"Error while opening encoder",
|
||||||
|
"Could not open encoder",
|
||||||
|
"Invalid argument",
|
||||||
|
"Nothing was written into output file",
|
||||||
|
"Non full-range YUV",
|
||||||
|
"moov atom not found",
|
||||||
|
"Invalid data found",
|
||||||
|
"Error opening input",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, needle := range want {
|
||||||
|
needleLower := strings.ToLower(needle)
|
||||||
|
|
||||||
|
for _, line := range clean {
|
||||||
|
if strings.Contains(strings.ToLower(line), needleLower) {
|
||||||
|
if len(clean) > 1 {
|
||||||
|
return fmt.Sprintf("%s … (+%d Zeilen)", line, len(clean)-1)
|
||||||
|
}
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(clean) == 1 {
|
||||||
|
return clean[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s … (+%d Zeilen)", clean[len(clean)-1], len(clean)-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ffmpegPreviewError(label string, err error, stderr string) error {
|
||||||
|
msg := shortFFmpegStderr(stderr)
|
||||||
|
if msg == "" {
|
||||||
|
return appErrorf("%s: %w", label, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return appErrorf("%s: %w: %s", label, err, msg)
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// JPG extraction + preview endpoint
|
// JPG extraction + preview endpoint
|
||||||
// Route:
|
// Route:
|
||||||
@ -1189,6 +1250,8 @@ func extractLastFrameJPG(path string) ([]byte, error) {
|
|||||||
"-frames:v", "1",
|
"-frames:v", "1",
|
||||||
"-vf", "scale=720:-2:flags=fast_bilinear",
|
"-vf", "scale=720:-2:flags=fast_bilinear",
|
||||||
"-vcodec", "mjpeg",
|
"-vcodec", "mjpeg",
|
||||||
|
"-pix_fmt", "yuvj420p",
|
||||||
|
"-threads", "1",
|
||||||
"-q:v", "4",
|
"-q:v", "4",
|
||||||
"-f", "image2pipe",
|
"-f", "image2pipe",
|
||||||
"pipe:1",
|
"pipe:1",
|
||||||
@ -1207,7 +1270,7 @@ func extractLastFrameJPG(path string) ([]byte, error) {
|
|||||||
if ctx.Err() == context.DeadlineExceeded {
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
return nil, appErrorf("ffmpeg last-frame jpg: timeout")
|
return nil, appErrorf("ffmpeg last-frame jpg: timeout")
|
||||||
}
|
}
|
||||||
return nil, appErrorf("ffmpeg last-frame jpg: %w (%s)", err, strings.TrimSpace(stderr.String()))
|
return nil, ffmpegPreviewError("ffmpeg last-frame jpg", err, stderr.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
b := out.Bytes()
|
b := out.Bytes()
|
||||||
@ -1232,6 +1295,8 @@ func extractFrameAtTimeJPG(path string, seconds float64) ([]byte, error) {
|
|||||||
"-frames:v", "1",
|
"-frames:v", "1",
|
||||||
"-vf", "scale=720:-2",
|
"-vf", "scale=720:-2",
|
||||||
"-vcodec", "mjpeg",
|
"-vcodec", "mjpeg",
|
||||||
|
"-pix_fmt", "yuvj420p",
|
||||||
|
"-threads", "1",
|
||||||
"-q:v", "4",
|
"-q:v", "4",
|
||||||
"-f", "image2pipe",
|
"-f", "image2pipe",
|
||||||
"pipe:1",
|
"pipe:1",
|
||||||
@ -1246,7 +1311,7 @@ func extractFrameAtTimeJPG(path string, seconds float64) ([]byte, error) {
|
|||||||
cmd.Stdout = &out
|
cmd.Stdout = &out
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
return nil, appErrorf("ffmpeg frame-at-time jpg: %w (%s)", err, strings.TrimSpace(stderr.String()))
|
return nil, ffmpegPreviewError("ffmpeg frame-at-time jpg", err, stderr.String())
|
||||||
}
|
}
|
||||||
b := out.Bytes()
|
b := out.Bytes()
|
||||||
if len(b) == 0 {
|
if len(b) == 0 {
|
||||||
@ -1280,6 +1345,8 @@ func extractLastFrameJPGScaled(path string, width int, quality int) ([]byte, err
|
|||||||
"-frames:v", "1",
|
"-frames:v", "1",
|
||||||
"-vf", fmt.Sprintf("scale=%d:-2", width),
|
"-vf", fmt.Sprintf("scale=%d:-2", width),
|
||||||
"-vcodec", "mjpeg",
|
"-vcodec", "mjpeg",
|
||||||
|
"-pix_fmt", "yuvj420p",
|
||||||
|
"-threads", "1",
|
||||||
"-q:v", qv,
|
"-q:v", qv,
|
||||||
"-f", "image2pipe",
|
"-f", "image2pipe",
|
||||||
"pipe:1",
|
"pipe:1",
|
||||||
@ -1294,7 +1361,7 @@ func extractLastFrameJPGScaled(path string, width int, quality int) ([]byte, err
|
|||||||
cmd.Stdout = &out
|
cmd.Stdout = &out
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
return nil, appErrorf("ffmpeg last-frame scaled jpg: %w (%s)", err, strings.TrimSpace(stderr.String()))
|
return nil, ffmpegPreviewError("ffmpeg last-frame scaled jpg", err, stderr.String())
|
||||||
}
|
}
|
||||||
b := out.Bytes()
|
b := out.Bytes()
|
||||||
if len(b) == 0 {
|
if len(b) == 0 {
|
||||||
@ -1319,6 +1386,8 @@ func extractFirstFrameJPGScaled(path string, width int, quality int) ([]byte, er
|
|||||||
"-frames:v", "1",
|
"-frames:v", "1",
|
||||||
"-vf", fmt.Sprintf("scale=%d:-2", width),
|
"-vf", fmt.Sprintf("scale=%d:-2", width),
|
||||||
"-vcodec", "mjpeg",
|
"-vcodec", "mjpeg",
|
||||||
|
"-pix_fmt", "yuvj420p",
|
||||||
|
"-threads", "1",
|
||||||
"-q:v", "5",
|
"-q:v", "5",
|
||||||
"-f", "image2pipe",
|
"-f", "image2pipe",
|
||||||
"pipe:1",
|
"pipe:1",
|
||||||
@ -1333,7 +1402,7 @@ func extractFirstFrameJPGScaled(path string, width int, quality int) ([]byte, er
|
|||||||
cmd.Stdout = &out
|
cmd.Stdout = &out
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
return nil, appErrorf("ffmpeg first-frame scaled jpg: %w (%s)", err, strings.TrimSpace(stderr.String()))
|
return nil, ffmpegPreviewError("ffmpeg first-frame scaled jpg", err, stderr.String())
|
||||||
}
|
}
|
||||||
b := out.Bytes()
|
b := out.Bytes()
|
||||||
if len(b) == 0 {
|
if len(b) == 0 {
|
||||||
@ -1370,6 +1439,8 @@ func extractLiveFrameFromM3U8ThumbJPG(ctx context.Context, m3u8URL, cookie, user
|
|||||||
"-frames:v", "1",
|
"-frames:v", "1",
|
||||||
"-vf", "scale=320:-2",
|
"-vf", "scale=320:-2",
|
||||||
"-vcodec", "mjpeg",
|
"-vcodec", "mjpeg",
|
||||||
|
"-pix_fmt", "yuvj420p",
|
||||||
|
"-threads", "1",
|
||||||
"-q:v", "6",
|
"-q:v", "6",
|
||||||
"-f", "image2pipe",
|
"-f", "image2pipe",
|
||||||
"pipe:1",
|
"pipe:1",
|
||||||
@ -1387,7 +1458,7 @@ func extractLiveFrameFromM3U8ThumbJPG(ctx context.Context, m3u8URL, cookie, user
|
|||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
return nil, appErrorf("ffmpeg live m3u8 jpg failed: %w (%s)", err, strings.TrimSpace(stderr.String()))
|
return nil, ffmpegPreviewError("ffmpeg live m3u8 jpg failed", err, stderr.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
b := out.Bytes()
|
b := out.Bytes()
|
||||||
|
|||||||
@ -1002,7 +1002,43 @@ func starsFromHighlightScore(score float64) int {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ratingUsernameFromVideoPath(videoPath string) string {
|
||||||
|
id := strings.TrimSpace(assetIDFromVideoPath(videoPath))
|
||||||
|
if id == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
user := strings.ToLower(strings.TrimSpace(modelNameFromFilename(id)))
|
||||||
|
if user != "" && user != "unknown" {
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback nur, wenn die ID nicht wie ein kompletter Dateiname mit Timestamp aussieht.
|
||||||
|
if !strings.Contains(id, "__") {
|
||||||
|
return strings.ToLower(strings.TrimSpace(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func ratingLogSubject(username string) string {
|
||||||
|
username = strings.ToLower(strings.TrimSpace(username))
|
||||||
|
if username == "" || username == "unknown" {
|
||||||
|
return "[rating]"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "[" + username + "]"
|
||||||
|
}
|
||||||
|
|
||||||
func computeHighlightRating(segments []aiSegmentMeta, durationSec float64) *aiRatingMeta {
|
func computeHighlightRating(segments []aiSegmentMeta, durationSec float64) *aiRatingMeta {
|
||||||
|
return computeHighlightRatingWithUsername(segments, durationSec, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func computeHighlightRatingForVideo(segments []aiSegmentMeta, durationSec float64, videoPath string) *aiRatingMeta {
|
||||||
|
return computeHighlightRatingWithUsername(segments, durationSec, ratingUsernameFromVideoPath(videoPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
func computeHighlightRatingWithUsername(segments []aiSegmentMeta, durationSec float64, username string) *aiRatingMeta {
|
||||||
r := &aiRatingMeta{
|
r := &aiRatingMeta{
|
||||||
Score: 0,
|
Score: 0,
|
||||||
Stars: 1,
|
Stars: 1,
|
||||||
@ -1079,7 +1115,7 @@ func computeHighlightRating(segments []aiSegmentMeta, durationSec float64) *aiRa
|
|||||||
}
|
}
|
||||||
|
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
appLogln("⚠️ [rating] result is zero because all segments were skipped")
|
appLogf("⚠️ %s rating result is zero because all segments were skipped", ratingLogSubject(username))
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1132,7 +1168,8 @@ func computeHighlightRating(segments []aiSegmentMeta, durationSec float64) *aiRa
|
|||||||
r.AvgConfidence = ratingRound(avgConfidence, 3)
|
r.AvgConfidence = ratingRound(avgConfidence, 3)
|
||||||
|
|
||||||
appLogf(
|
appLogf(
|
||||||
"✅ [rating] result score=%.1f stars=%d segments=%d flagged=%.2f weighted=%.2f coverage=%.4f weightedCoverage=%.4f longest=%.2f avgConf=%.3f",
|
"✅ %s rating result score=%.1f stars=%d segments=%d flagged=%.2f weighted=%.2f coverage=%.4f weightedCoverage=%.4f longest=%.2f avgConf=%.3f",
|
||||||
|
ratingLogSubject(username),
|
||||||
r.Score,
|
r.Score,
|
||||||
r.Stars,
|
r.Stars,
|
||||||
r.Segments,
|
r.Segments,
|
||||||
|
|||||||
@ -42,17 +42,17 @@ func RecordStream(
|
|||||||
loadFreshHLS := func() (*selectedHLSStream, error) {
|
loadFreshHLS := func() (*selectedHLSStream, error) {
|
||||||
body, err := hc.FetchPage(ctx, pageURL, httpCookie)
|
body, err := hc.FetchPage(ctx, pageURL, httpCookie)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, appErrorf("seite laden: %w", err)
|
return nil, appErrorf("[%s] seite laden: %w", username, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
hlsURL, err := ParseStream(body)
|
hlsURL, err := ParseStream(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, appErrorf("stream-parsing: %w", err)
|
return nil, appErrorf("[%s] stream-parsing: %w", username, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
hlsURL = strings.TrimSpace(hlsURL)
|
hlsURL = strings.TrimSpace(hlsURL)
|
||||||
if hlsURL == "" {
|
if hlsURL == "" {
|
||||||
return nil, errors.New("leere hls url")
|
return nil, appErrorf("[%s] leere hls url", username)
|
||||||
}
|
}
|
||||||
|
|
||||||
stream, err := getWantedResolutionPlaylistWithHeaders(
|
stream, err := getWantedResolutionPlaylistWithHeaders(
|
||||||
@ -63,10 +63,10 @@ func RecordStream(
|
|||||||
pageURL,
|
pageURL,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, appErrorf("variant-playlist: %w", err)
|
return nil, appErrorf("[%s] variant-playlist: %w", username, err)
|
||||||
}
|
}
|
||||||
if stream == nil || strings.TrimSpace(stream.VideoURL) == "" {
|
if stream == nil || strings.TrimSpace(stream.VideoURL) == "" {
|
||||||
return nil, errors.New("leere final video url")
|
return nil, appErrorf("[%s] leere final video url", username)
|
||||||
}
|
}
|
||||||
|
|
||||||
return stream, nil
|
return stream, nil
|
||||||
@ -257,8 +257,6 @@ func RecordStream(
|
|||||||
|
|
||||||
newStream, rerr := loadFreshHLSWithRetry(3, "after-ffmpeg-error")
|
newStream, rerr := loadFreshHLSWithRetry(3, "after-ffmpeg-error")
|
||||||
if rerr != nil {
|
if rerr != nil {
|
||||||
appLogln("⚠️ chaturbate hls refresh failed:", rerr)
|
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
"useMyFreeCamsWatcher": true,
|
"useMyFreeCamsWatcher": true,
|
||||||
"autoDeleteSmallDownloads": true,
|
"autoDeleteSmallDownloads": true,
|
||||||
"autoDeleteSmallDownloadsBelowMB": 300,
|
"autoDeleteSmallDownloadsBelowMB": 300,
|
||||||
"autoDeleteSmallDownloadsKeepFavorites": false,
|
"autoDeleteSmallDownloadsKeepFavorites": true,
|
||||||
"lowDiskPauseBelowGB": 5,
|
"lowDiskPauseBelowGB": 5,
|
||||||
"blurPreviews": false,
|
"blurPreviews": false,
|
||||||
"teaserPlayback": "all",
|
"teaserPlayback": "all",
|
||||||
|
|||||||
@ -125,7 +125,7 @@ func regeneratePhaseTruthForVideo(videoPath string, assetID string, goal string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// KI nur dann fertig, wenn Sprite existiert UND passende AI in meta.json vorhanden ist
|
// KI nur dann fertig, wenn Sprite existiert UND passende AI in meta.json vorhanden ist
|
||||||
out.AnalyzeReady = out.SpritesReady && hasAIAnalysisForOutputGoal(videoPath, goal)
|
out.AnalyzeReady = hasAIAnalysisForOutputGoal(videoPath, goal)
|
||||||
}
|
}
|
||||||
|
|
||||||
return out
|
return out
|
||||||
@ -149,12 +149,10 @@ func firstMissingPrimaryRegeneratePhaseError(videoPath string, assetID string) e
|
|||||||
func firstMissingDeferredRegeneratePhaseError(videoPath string, assetID string) error {
|
func firstMissingDeferredRegeneratePhaseError(videoPath string, assetID string) error {
|
||||||
truth := regeneratePhaseTruthForVideo(videoPath, assetID, "highlights")
|
truth := regeneratePhaseTruthForVideo(videoPath, assetID, "highlights")
|
||||||
|
|
||||||
if !truth.SpritesReady {
|
|
||||||
return errors.New("preview-sprite.jpg fehlt oder ist leer")
|
|
||||||
}
|
|
||||||
if !truth.AnalyzeReady {
|
if !truth.AnalyzeReady {
|
||||||
return errors.New("Analyse fehlt oder wurde nicht in meta.json gespeichert")
|
return errors.New("Analyse fehlt oder wurde nicht in meta.json gespeichert")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -454,8 +452,18 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !regeneratePhaseTruthForVideo(videoPath, id, primaryGoal).SpritesReady {
|
if !regeneratePhaseTruthForVideo(videoPath, id, primaryGoal).SpritesReady {
|
||||||
failJob("postwork", "sprites", "preview-sprite.jpg fehlt oder ist leer")
|
// Sprite ist nice-to-have. Analyse/Rating läuft frame-basiert und darf weiterlaufen.
|
||||||
return
|
publishFinishedPostworkPhase(
|
||||||
|
file,
|
||||||
|
id,
|
||||||
|
"postwork",
|
||||||
|
"sprites",
|
||||||
|
"error",
|
||||||
|
"Sprites fehlgeschlagen",
|
||||||
|
"preview-sprite.jpg fehlt oder ist leer",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "done", "Sprites", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "done", "Sprites", "")
|
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "done", "Sprites", "")
|
||||||
|
|||||||
@ -1616,14 +1616,27 @@ function postworkDoneCount(summary: FinishedPostworkSummary): number {
|
|||||||
return summary.steps.filter((step) => step.state === 'done').length
|
return summary.steps.filter((step) => step.state === 'done').length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function postworkStepProgressValue(step: FinishedPostworkStepSummary): number {
|
||||||
|
if (step.state === 'done') return 1
|
||||||
|
|
||||||
|
if (step.state === 'running' && isAnalyzeStep(step)) {
|
||||||
|
const percent = analysisProgressPercent(step.label)
|
||||||
|
if (percent != null) return percent / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
function postworkProgressPercent(summary: FinishedPostworkSummary): number {
|
function postworkProgressPercent(summary: FinishedPostworkSummary): number {
|
||||||
const total = summary.steps.length
|
const total = summary.steps.length
|
||||||
if (total <= 0) return 0
|
if (total <= 0) return 0
|
||||||
|
|
||||||
const done = postworkDoneCount(summary)
|
const value = summary.steps.reduce(
|
||||||
const running = summary.steps.some((step) => step.state === 'running') ? 0.5 : 0
|
(sum, step) => sum + postworkStepProgressValue(step),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
|
||||||
return Math.max(0, Math.min(100, Math.round(((done + running) / total) * 100)))
|
return Math.max(0, Math.min(100, Math.round((value / total) * 100)))
|
||||||
}
|
}
|
||||||
|
|
||||||
function postworkStepStateLabel(step: FinishedPostworkStepSummary): string {
|
function postworkStepStateLabel(step: FinishedPostworkStepSummary): string {
|
||||||
@ -1677,28 +1690,55 @@ function postworkStepIcon(step: FinishedPostworkStepSummary): ReactNode {
|
|||||||
return <span className="size-3.5 shrink-0 rounded-full border border-gray-300 dark:border-white/20" />
|
return <span className="size-3.5 shrink-0 rounded-full border border-gray-300 dark:border-white/20" />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function postworkPreviewSrc(summary: FinishedPostworkSummary): string | null {
|
||||||
|
const id = String(summary.assetId || fileStemFromOutput(summary.file)).trim()
|
||||||
|
if (!id) return null
|
||||||
|
|
||||||
|
const base = `/api/preview?id=${encodeURIComponent(id)}&file=preview.jpg`
|
||||||
|
const version = Number.isFinite(summary.ts) && summary.ts > 0 ? String(summary.ts) : ''
|
||||||
|
|
||||||
|
return version ? `${base}&v=${encodeURIComponent(version)}` : base
|
||||||
|
}
|
||||||
|
|
||||||
function renderPostworkPopover(summary: FinishedPostworkSummary): ReactNode {
|
function renderPostworkPopover(summary: FinishedPostworkSummary): ReactNode {
|
||||||
const done = postworkDoneCount(summary)
|
const done = postworkDoneCount(summary)
|
||||||
const total = summary.steps.length
|
const total = summary.steps.length
|
||||||
const progress = postworkProgressPercent(summary)
|
const progress = postworkProgressPercent(summary)
|
||||||
|
const previewSrc = postworkPreviewSrc(summary)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-[340px] overflow-hidden rounded-[inherit] bg-white dark:bg-gray-950">
|
<div className="w-[320px] overflow-hidden rounded-[inherit] bg-white dark:bg-gray-950">
|
||||||
<div className="border-b border-gray-200/60 bg-gradient-to-br from-gray-50 to-white px-3 py-2.5 dark:border-white/10 dark:from-white/10 dark:to-white/5">
|
<div className="relative overflow-hidden border-b border-gray-200/60 bg-gray-950 px-3 py-3 text-white dark:border-white/10">
|
||||||
<div className="flex items-center gap-2.5">
|
{previewSrc ? (
|
||||||
<div className="grid size-7 shrink-0 place-items-center rounded-lg bg-white shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
|
<img
|
||||||
|
src={previewSrc}
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
className="absolute inset-0 h-full w-full object-cover blur-[1px]"
|
||||||
|
onError={(e) => {
|
||||||
|
e.currentTarget.style.display = 'none'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-black/75 via-black/45 to-black/75" />
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent" />
|
||||||
|
|
||||||
|
<div className="relative flex items-center gap-2.5">
|
||||||
|
<div className="grid size-8 shrink-0 place-items-center rounded-xl bg-white/90 shadow-sm ring-1 ring-white/40 backdrop-blur dark:bg-gray-950/75 dark:ring-white/15">
|
||||||
{postworkSummaryIcon(summary)}
|
{postworkSummaryIcon(summary)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="min-w-0 truncate text-[13px] font-semibold text-gray-900 dark:text-white">
|
<div className="min-w-0 truncate text-[13px] font-semibold text-white">
|
||||||
{postworkSummaryTitle(summary)}
|
{postworkSummaryTitle(summary)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
className={[
|
className={[
|
||||||
'shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-bold ring-1',
|
'shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-bold ring-1 backdrop-blur',
|
||||||
postworkBadgeTone(summary),
|
postworkBadgeTone(summary),
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
@ -1706,47 +1746,47 @@ function renderPostworkPopover(summary: FinishedPostworkSummary): ReactNode {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-0.5 truncate text-[10px] text-gray-500 dark:text-gray-400">
|
<div className="mt-0.5 truncate text-[10px] text-white/70" title={summary.file}>
|
||||||
{summary.file}
|
{summary.file}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 flex items-center gap-2">
|
<div className="relative mt-2.5 flex items-center gap-2">
|
||||||
<div className="h-1 flex-1 overflow-hidden rounded-full bg-gray-200 dark:bg-white/10">
|
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-white/25">
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
'h-full rounded-full transition-all duration-300',
|
'h-full rounded-full transition-all duration-300',
|
||||||
summary.state === 'error'
|
summary.state === 'error'
|
||||||
? 'bg-red-500'
|
? 'bg-red-400'
|
||||||
: summary.state === 'queued'
|
: summary.state === 'queued'
|
||||||
? 'bg-amber-500'
|
? 'bg-amber-400'
|
||||||
: summary.state === 'running'
|
: summary.state === 'running'
|
||||||
? 'bg-indigo-500'
|
? 'bg-indigo-400'
|
||||||
: 'bg-emerald-500',
|
: 'bg-emerald-400',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
style={{ width: `${progress}%` }}
|
style={{ width: `${progress}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="shrink-0 text-[10px] tabular-nums text-gray-500 dark:text-gray-400">
|
<span className="shrink-0 text-[10px] tabular-nums text-white/75">
|
||||||
{done}/{total}
|
{done}/{total}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-3 py-3">
|
<div className="px-2.5 py-2.5">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1">
|
||||||
{summary.steps.map((step) => (
|
{summary.steps.map((step) => (
|
||||||
<div
|
<div
|
||||||
key={step.key}
|
key={step.key}
|
||||||
className={[
|
className={[
|
||||||
'grid grid-cols-[minmax(0,1fr)_24px_auto] items-center gap-2 rounded-xl px-2.5 py-2',
|
'grid grid-cols-[minmax(0,1fr)_20px_auto] items-center gap-1.5 rounded-lg px-2 py-1.5',
|
||||||
'bg-gray-50/80 ring-1 ring-gray-200/70 dark:bg-white/[0.03] dark:ring-white/10',
|
'bg-gray-50/80 ring-1 ring-gray-200/70 dark:bg-white/[0.03] dark:ring-white/10',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate text-[12px] font-medium text-gray-900 dark:text-gray-100">
|
<div className="truncate text-[11px] font-medium text-gray-900 dark:text-gray-100">
|
||||||
{postworkStepDisplayLabel(step)}
|
{postworkStepDisplayLabel(step)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -1757,14 +1797,14 @@ function renderPostworkPopover(summary: FinishedPostworkSummary): ReactNode {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex size-6 items-center justify-center">
|
<div className="flex size-5 items-center justify-center">
|
||||||
{postworkStepIcon(step)}
|
{postworkStepIcon(step)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span
|
<span
|
||||||
className={[
|
className={[
|
||||||
'inline-flex min-w-[64px] justify-center rounded-full px-2 py-0.5 text-[11px] font-semibold ring-1',
|
'inline-flex min-w-[54px] justify-center rounded-full px-1.5 py-0.5 text-[10px] font-semibold ring-1',
|
||||||
postworkStepTone(step),
|
postworkStepTone(step),
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
title={step.state === 'error' ? step.error || 'Unbekannter Fehler' : undefined}
|
title={step.state === 'error' ? step.error || 'Unbekannter Fehler' : undefined}
|
||||||
|
|||||||
@ -57,7 +57,7 @@ export default function LoadingSpinner({
|
|||||||
height={px}
|
height={px}
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
className={cn(
|
className={cn(
|
||||||
'block shrink-0 text-gray-500 motion-safe:animate-spin',
|
'block shrink-0 text-gray-500 animate-spin',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user