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
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
"useMyFreeCamsWatcher": true,
|
||||
"autoDeleteSmallDownloads": true,
|
||||
"autoDeleteSmallDownloadsBelowMB": 300,
|
||||
"autoDeleteSmallDownloadsKeepFavorites": false,
|
||||
"autoDeleteSmallDownloadsKeepFavorites": true,
|
||||
"lowDiskPauseBelowGB": 5,
|
||||
"blurPreviews": false,
|
||||
"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
|
||||
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", "")
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user