This commit is contained in:
Linrador 2026-05-12 09:25:27 +02:00
parent 15b5c59a53
commit 1da3c11f04
12 changed files with 907 additions and 345 deletions

View File

@ -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

View File

@ -980,7 +980,7 @@ func recordAnalyzeVideo(w http.ResponseWriter, r *http.Request) {
segments = prepareAIRatingSegments(segments)
appLogf("🧪 [analyze] rating segments file=%q count=%d", file, len(segments))
rating := computeHighlightRating(segments, durationSec)
rating := computeHighlightRatingForVideo(segments, durationSec, outPath)
ai := &aiAnalysisMeta{
Goal: "highlights",

View File

@ -174,7 +174,7 @@ func ensureAnalyzeAllGoalsForVideoCtxForce(
segments := buildAnalyzeSegmentsForGoal(hits, durationSec)
segments = prepareAIRatingSegments(segments)
rating := computeHighlightRating(segments, durationSec)
rating := computeHighlightRatingForVideo(segments, durationSec, videoPath)
ai := &aiAnalysisMeta{
Goal: "highlights",
@ -1375,24 +1375,35 @@ func hasAIAnalysisForOutputGoal(outPath string, goal string) bool {
return false
}
// Wichtig:
// Eine Analyse mit 0 Hits / 0 Segments ist trotzdem eine fertige Analyse.
// Sonst zeigt das Frontend fälschlich "Analyse fehlt".
if jsonNumberPositive(aiMap["analyzedAtUnix"]) {
// Eine Analyse ist erst dann fertig, wenn wirklich ein Rating-Objekt
// mit typischen Rating-Feldern gespeichert wurde.
rating, ok := aiMap["rating"].(map[string]any)
if !ok || rating == nil {
return false
}
if jsonFieldExists(rating, "score") ||
jsonFieldExists(rating, "stars") ||
jsonFieldExists(rating, "segments") {
return true
}
if rating, ok := aiMap["rating"].(map[string]any); ok && rating != nil {
return true
}
// Legacy-Fallback für alte meta.json-Dateien.
// Legacy-Fallback: alte Dateien ohne Rating, aber mit Hits/Segments.
rawHits, hasHits := aiMap["hits"].([]any)
rawSegs, hasSegs := aiMap["segments"].([]any)
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 {
switch x := v.(type) {
case json.Number:
@ -1522,7 +1533,7 @@ func prepareVideoForSplit(ctx context.Context, videoPath, sourceURL, goal string
segments := buildAnalyzeSegmentsForGoal(hits, durationSec)
segments = prepareAIRatingSegments(segments)
rating := computeHighlightRating(segments, durationSec)
rating := computeHighlightRatingForVideo(segments, durationSec, videoPath)
ai := &aiAnalysisMeta{
Goal: "highlights",

View File

@ -81,9 +81,6 @@ func (am *AuthManager) initWebAuthn() error {
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])
if err != nil {
return err

View File

@ -1162,6 +1162,67 @@ func servePreviewStatusSVG(w http.ResponseWriter, label string, status int) {
_, _ = 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
// Route:
@ -1189,6 +1250,8 @@ func extractLastFrameJPG(path string) ([]byte, error) {
"-frames:v", "1",
"-vf", "scale=720:-2:flags=fast_bilinear",
"-vcodec", "mjpeg",
"-pix_fmt", "yuvj420p",
"-threads", "1",
"-q:v", "4",
"-f", "image2pipe",
"pipe:1",
@ -1207,7 +1270,7 @@ func extractLastFrameJPG(path string) ([]byte, error) {
if ctx.Err() == context.DeadlineExceeded {
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()
@ -1232,6 +1295,8 @@ func extractFrameAtTimeJPG(path string, seconds float64) ([]byte, error) {
"-frames:v", "1",
"-vf", "scale=720:-2",
"-vcodec", "mjpeg",
"-pix_fmt", "yuvj420p",
"-threads", "1",
"-q:v", "4",
"-f", "image2pipe",
"pipe:1",
@ -1246,7 +1311,7 @@ func extractFrameAtTimeJPG(path string, seconds float64) ([]byte, error) {
cmd.Stdout = &out
cmd.Stderr = &stderr
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()
if len(b) == 0 {
@ -1280,6 +1345,8 @@ func extractLastFrameJPGScaled(path string, width int, quality int) ([]byte, err
"-frames:v", "1",
"-vf", fmt.Sprintf("scale=%d:-2", width),
"-vcodec", "mjpeg",
"-pix_fmt", "yuvj420p",
"-threads", "1",
"-q:v", qv,
"-f", "image2pipe",
"pipe:1",
@ -1294,7 +1361,7 @@ func extractLastFrameJPGScaled(path string, width int, quality int) ([]byte, err
cmd.Stdout = &out
cmd.Stderr = &stderr
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()
if len(b) == 0 {
@ -1319,6 +1386,8 @@ func extractFirstFrameJPGScaled(path string, width int, quality int) ([]byte, er
"-frames:v", "1",
"-vf", fmt.Sprintf("scale=%d:-2", width),
"-vcodec", "mjpeg",
"-pix_fmt", "yuvj420p",
"-threads", "1",
"-q:v", "5",
"-f", "image2pipe",
"pipe:1",
@ -1333,7 +1402,7 @@ func extractFirstFrameJPGScaled(path string, width int, quality int) ([]byte, er
cmd.Stdout = &out
cmd.Stderr = &stderr
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()
if len(b) == 0 {
@ -1370,6 +1439,8 @@ func extractLiveFrameFromM3U8ThumbJPG(ctx context.Context, m3u8URL, cookie, user
"-frames:v", "1",
"-vf", "scale=320:-2",
"-vcodec", "mjpeg",
"-pix_fmt", "yuvj420p",
"-threads", "1",
"-q:v", "6",
"-f", "image2pipe",
"pipe:1",
@ -1387,7 +1458,7 @@ func extractLiveFrameFromM3U8ThumbJPG(ctx context.Context, m3u8URL, cookie, user
cmd.Stderr = &stderr
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()

View File

@ -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 {
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{
Score: 0,
Stars: 1,
@ -1079,7 +1115,7 @@ func computeHighlightRating(segments []aiSegmentMeta, durationSec float64) *aiRa
}
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
}
@ -1132,7 +1168,8 @@ func computeHighlightRating(segments []aiSegmentMeta, durationSec float64) *aiRa
r.AvgConfidence = ratingRound(avgConfidence, 3)
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.Stars,
r.Segments,

View File

@ -42,17 +42,17 @@ func RecordStream(
loadFreshHLS := func() (*selectedHLSStream, error) {
body, err := hc.FetchPage(ctx, pageURL, httpCookie)
if err != nil {
return nil, appErrorf("seite laden: %w", err)
return nil, appErrorf("[%s] seite laden: %w", username, err)
}
hlsURL, err := ParseStream(body)
if err != nil {
return nil, appErrorf("stream-parsing: %w", err)
return nil, appErrorf("[%s] stream-parsing: %w", username, err)
}
hlsURL = strings.TrimSpace(hlsURL)
if hlsURL == "" {
return nil, errors.New("leere hls url")
return nil, appErrorf("[%s] leere hls url", username)
}
stream, err := getWantedResolutionPlaylistWithHeaders(
@ -63,10 +63,10 @@ func RecordStream(
pageURL,
)
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) == "" {
return nil, errors.New("leere final video url")
return nil, appErrorf("[%s] leere final video url", username)
}
return stream, nil
@ -257,8 +257,6 @@ func RecordStream(
newStream, rerr := loadFreshHLSWithRetry(3, "after-ffmpeg-error")
if rerr != nil {
appLogln("⚠️ chaturbate hls refresh failed:", rerr)
select {
case <-ctx.Done():
return ctx.Err()

View File

@ -11,7 +11,7 @@
"useMyFreeCamsWatcher": true,
"autoDeleteSmallDownloads": true,
"autoDeleteSmallDownloadsBelowMB": 300,
"autoDeleteSmallDownloadsKeepFavorites": false,
"autoDeleteSmallDownloadsKeepFavorites": true,
"lowDiskPauseBelowGB": 5,
"blurPreviews": false,
"teaserPlayback": "all",

View File

@ -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
out.AnalyzeReady = out.SpritesReady && hasAIAnalysisForOutputGoal(videoPath, goal)
out.AnalyzeReady = hasAIAnalysisForOutputGoal(videoPath, goal)
}
return out
@ -149,12 +149,10 @@ func firstMissingPrimaryRegeneratePhaseError(videoPath string, assetID string) e
func firstMissingDeferredRegeneratePhaseError(videoPath string, assetID string) error {
truth := regeneratePhaseTruthForVideo(videoPath, assetID, "highlights")
if !truth.SpritesReady {
return errors.New("preview-sprite.jpg fehlt oder ist leer")
}
if !truth.AnalyzeReady {
return errors.New("Analyse fehlt oder wurde nicht in meta.json gespeichert")
}
return nil
}
@ -454,8 +452,18 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
}
if !regeneratePhaseTruthForVideo(videoPath, id, primaryGoal).SpritesReady {
failJob("postwork", "sprites", "preview-sprite.jpg fehlt oder ist leer")
return
// Sprite ist nice-to-have. Analyse/Rating läuft frame-basiert und darf weiterlaufen.
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", "")

View File

@ -1616,14 +1616,27 @@ function postworkDoneCount(summary: FinishedPostworkSummary): number {
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 {
const total = summary.steps.length
if (total <= 0) return 0
const done = postworkDoneCount(summary)
const running = summary.steps.some((step) => step.state === 'running') ? 0.5 : 0
const value = summary.steps.reduce(
(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 {
@ -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" />
}
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 {
const done = postworkDoneCount(summary)
const total = summary.steps.length
const progress = postworkProgressPercent(summary)
const previewSrc = postworkPreviewSrc(summary)
return (
<div className="w-[340px] 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="flex items-center gap-2.5">
<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">
<div className="w-[320px] overflow-hidden rounded-[inherit] bg-white dark:bg-gray-950">
<div className="relative overflow-hidden border-b border-gray-200/60 bg-gray-950 px-3 py-3 text-white dark:border-white/10">
{previewSrc ? (
<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)}
</div>
<div className="min-w-0 flex-1">
<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)}
</div>
<span
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),
].join(' ')}
>
@ -1706,47 +1746,47 @@ function renderPostworkPopover(summary: FinishedPostworkSummary): ReactNode {
</span>
</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}
</div>
</div>
</div>
<div className="mt-2 flex items-center gap-2">
<div className="h-1 flex-1 overflow-hidden rounded-full bg-gray-200 dark:bg-white/10">
<div className="relative mt-2.5 flex items-center gap-2">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-white/25">
<div
className={[
'h-full rounded-full transition-all duration-300',
summary.state === 'error'
? 'bg-red-500'
? 'bg-red-400'
: summary.state === 'queued'
? 'bg-amber-500'
? 'bg-amber-400'
: summary.state === 'running'
? 'bg-indigo-500'
: 'bg-emerald-500',
? 'bg-indigo-400'
: 'bg-emerald-400',
].join(' ')}
style={{ width: `${progress}%` }}
/>
</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}
</span>
</div>
</div>
<div className="px-3 py-3">
<div className="space-y-1.5">
<div className="px-2.5 py-2.5">
<div className="space-y-1">
{summary.steps.map((step) => (
<div
key={step.key}
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',
].join(' ')}
>
<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)}
</div>
@ -1757,14 +1797,14 @@ function renderPostworkPopover(summary: FinishedPostworkSummary): ReactNode {
) : null}
</div>
<div className="flex size-6 items-center justify-center">
<div className="flex size-5 items-center justify-center">
{postworkStepIcon(step)}
</div>
<div className="flex items-center gap-1.5">
<span
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),
].join(' ')}
title={step.state === 'error' ? step.error || 'Unbekannter Fehler' : undefined}

View File

@ -57,7 +57,7 @@ export default function LoadingSpinner({
height={px}
viewBox="0 0 24 24"
className={cn(
'block shrink-0 text-gray-500 motion-safe:animate-spin',
'block shrink-0 text-gray-500 animate-spin',
className,
)}
aria-hidden="true"

File diff suppressed because it is too large Load Diff