fix(progress): separate model warmup state
All checks were successful
Build & Push Docker Image / test (push) Successful in 11s
Build & Push Docker Image / build-and-push (push) Successful in 42s

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-05-12 00:52:33 +02:00
parent 929c482497
commit f70cefc5e9
9 changed files with 194 additions and 72 deletions

52
src/lib/job-progress.ts Normal file
View File

@@ -0,0 +1,52 @@
import type { Job, JobStatus } from '$lib/types.js';
export const TERMINAL_JOB_STATUSES: readonly JobStatus[] = ['done', 'failed', 'cancelled'];
const STATUS_LABELS: Record<JobStatus, string> = {
pending: 'Pending',
downloading: 'Downloading',
preparing: 'Preparing',
warming_model: 'Loading model',
transcribing: 'Transcribing',
processing: 'Processing',
done: 'Done',
failed: 'Failed',
cancelled: 'Cancelled'
};
const STATUS_COLORS: Record<JobStatus, string> = {
done: '#cdf24e',
failed: '#ff6b6b',
cancelled: 'rgba(232,233,236,0.3)',
processing: '#76daa2',
transcribing: '#80c7f7',
warming_model: '#76daa2',
preparing: '#fbc94b',
downloading: '#a78bfa',
pending: 'rgba(232,233,236,0.4)'
};
export function isTerminalJobStatus(status: JobStatus): boolean {
return TERMINAL_JOB_STATUSES.includes(status);
}
export function getJobStatusLabel(status: JobStatus): string {
return STATUS_LABELS[status];
}
export function getJobStatusColor(status: JobStatus): string {
return STATUS_COLORS[status];
}
export function getDisplayJobProgress(
job: Pick<Job, 'status' | 'progress' | 'segmentsJson'>,
options: { hasTranscript?: boolean } = {}
): number {
const progress = Math.max(0, Math.min(100, Math.round(job.progress)));
if (job.status === 'warming_model') return Math.min(progress, 15);
if (!isTerminalJobStatus(job.status)) return Math.min(progress, 99);
if (job.status === 'done' && !options.hasTranscript) return Math.min(progress, 99);
return progress;
}

View File

@@ -124,13 +124,16 @@ async function runJob(
// ── 4. Submit to whisper with webhook ────────────────────────────────
setJobStatus(jobId, 'transcribing', 10);
emitProgress(jobId, { type: 'status', status: 'transcribing' });
emitProgress(jobId, { type: 'status', status: 'transcribing', progress: 10 });
const webhookUrl = `${WEBHOOK_BASE_URL}/api/webhook/${jobId}`;
const whisperJobId = await submitJob(wavPath, webhookUrl, language, (state, retryAfterSecs) => {
emitProgress(jobId, { type: 'model_warming', state, retryAfterSecs });
setJobStatus(jobId, 'warming_model', 10);
emitProgress(jobId, { type: 'model_warming', status: 'warming_model', state, retryAfterSecs, progress: 10 });
});
updateJob({ id: jobId, whisperJobId });
setJobStatus(jobId, 'transcribing', 10);
emitProgress(jobId, { type: 'status', status: 'transcribing', progress: 10 });
// ── 5. Open SSE for live progress (non-blocking relay) ───────────────
streamJob(

View File

@@ -12,7 +12,16 @@ export interface ModelStatus {
vram_total_mb?: number;
}
export type JobStatus = 'pending' | 'downloading' | 'preparing' | 'transcribing' | 'processing' | 'done' | 'failed' | 'cancelled';
export type JobStatus =
| 'pending'
| 'downloading'
| 'preparing'
| 'warming_model'
| 'transcribing'
| 'processing'
| 'done'
| 'failed'
| 'cancelled';
export interface Segment {
index: number;

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { Job, AudioMode } from '$lib/types.js';
import { getDisplayJobProgress, getJobStatusLabel } from '$lib/job-progress.js';
import SourceIcon from '$lib/components/SourceIcon.svelte';
import Waveform from '$lib/components/Waveform.svelte';
import RecordButton from '$lib/components/RecordButton.svelte';
@@ -95,8 +96,7 @@
const parts: string[] = [];
if (job.source && !job.source.startsWith('http')) parts.push(job.source.split('/').pop() ?? '');
if (job.audioMode) parts.push(job.audioMode);
if (job.status === 'done') parts.push('done');
else parts.push(job.status);
parts.push(getJobStatusLabel(job.status).toLowerCase());
return parts.join(' · ');
}
@@ -271,7 +271,9 @@
<div class="recent-meta mono">{jobMeta(job)}</div>
</div>
{#if job.status !== 'done' && job.status !== 'failed' && job.status !== 'cancelled'}
<div class="recent-progress mono" style="color: {ACCENT}">{job.progress}%</div>
<div class="recent-progress mono" style="color: {ACCENT}">
{getDisplayJobProgress(job, { hasTranscript: Boolean(job.segmentsJson) })}%
</div>
{/if}
<svg width="14" height="14" viewBox="0 0 14 14" style="color: var(--text-dim); flex-shrink:0">
<path d="M5 3l4 4-4 4" stroke="currentColor" stroke-width="1.4" fill="none" stroke-linecap="round" stroke-linejoin="round"/>

View File

@@ -9,7 +9,7 @@ export async function GET({ params }) {
return json(job);
}
const ACTIVE = new Set(['pending', 'downloading', 'preparing', 'transcribing', 'processing']);
const ACTIVE = new Set(['pending', 'downloading', 'preparing', 'warming_model', 'transcribing', 'processing']);
export async function DELETE({ params }) {
const job = getJob(params.id);

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { Job } from '$lib/types.js';
import { getDisplayJobProgress, getJobStatusColor, getJobStatusLabel, isTerminalJobStatus } from '$lib/job-progress.js';
import SourceIcon from '$lib/components/SourceIcon.svelte';
import Waveform from '$lib/components/Waveform.svelte';
import { accent } from '$lib/accent.js';
@@ -10,27 +11,6 @@
let jobs = $state<Job[]>([]);
let loading = $state(true);
const statusColor: Record<string, string> = {
done: '#cdf24e',
failed: '#ff6b6b',
cancelled: 'rgba(232,233,236,0.3)',
transcribing: '#80c7f7',
preparing: '#fbc94b',
downloading: '#a78bfa',
pending: 'rgba(232,233,236,0.4)'
};
const statusLabel: Record<string, string> = {
pending: 'Pending',
downloading: 'Downloading',
preparing: 'Preparing',
transcribing: 'Transcribing',
processing: 'Processing',
done: 'Done',
failed: 'Failed',
cancelled: 'Cancelled'
};
function jobKind(job: Job): 'youtube' | 'audio' | 'video' | 'file' {
const s = job.source ?? '';
if (s.includes('youtube') || s.includes('youtu.be')) return 'youtube';
@@ -108,8 +88,8 @@
<div class="job-info">
<div class="job-name">{job.title || job.source}</div>
<div class="job-meta mono">
<span style="color: {statusColor[job.status] ?? 'rgba(232,233,236,0.5)'}">
{statusLabel[job.status] ?? job.status}
<span style="color: {getJobStatusColor(job.status)}">
{getJobStatusLabel(job.status)}
</span>
{#if job.createdAt}
<span>·</span>
@@ -122,14 +102,24 @@
</div>
</div>
{#if !['done', 'failed', 'cancelled'].includes(job.status)}
{#if !isTerminalJobStatus(job.status)}
<div class="job-wave">
<Waveform bars={40} progress={job.progress} accent={ACCENT} height={28} pattern="medium" />
<Waveform
bars={40}
progress={getDisplayJobProgress(job, { hasTranscript: Boolean(job.segmentsJson) })}
accent={ACCENT}
height={28}
pattern="medium"
/>
</div>
{:else if job.status === 'done'}
<div class="job-pct mono" style="color: {ACCENT}">{job.progress}%</div>
<div class="job-pct mono" style="color: {ACCENT}">
{getDisplayJobProgress(job, { hasTranscript: Boolean(job.segmentsJson) })}%
</div>
{:else}
<div class="job-pct mono" style="color: {statusColor[job.status]}">{job.status}</div>
<div class="job-pct mono" style="color: {getJobStatusColor(job.status)}">
{getJobStatusLabel(job.status)}
</div>
{/if}
<!-- Row actions -->
@@ -142,7 +132,7 @@
title="Retry"
></button>
{/if}
{#if ['done', 'failed', 'cancelled'].includes(job.status)}
{#if isTerminalJobStatus(job.status)}
<button
class="row-btn danger"
onclick={(e) => deleteJob(e, job)}

View File

@@ -2,6 +2,7 @@
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/stores';
import type { Job, Segment } from '$lib/types.js';
import { getDisplayJobProgress, getJobStatusLabel, isTerminalJobStatus } from '$lib/job-progress.js';
import SourceIcon from '$lib/components/SourceIcon.svelte';
import Waveform from '$lib/components/Waveform.svelte';
import { accent } from '$lib/accent.js';
@@ -16,17 +17,6 @@
let modelWarming = $state<{ state: string; retryAfterSecs: number } | null>(null);
let eventSource: EventSource | null = null;
const statusLabel: Record<string, string> = {
pending: 'Pending',
downloading: 'Downloading…',
preparing: 'Preparing audio…',
transcribing: 'Transcribing…',
processing: 'Post-processing…',
done: 'Done',
failed: 'Failed',
cancelled: 'Cancelled'
};
// Pipeline stages derived from job status
const pipelineStages = $derived.by(() => {
const status = job?.status ?? 'pending';
@@ -34,16 +24,26 @@
{ k: 'fetch', label: 'Fetch source' },
{ k: 'extract', label: 'Extract audio track' },
{ k: 'process', label: `Audio processing · ${job?.audioMode ?? 'auto'}` },
{ k: 'transcribe', label: 'Transcribing' },
{ k: 'transcribe', label: status === 'warming_model' ? 'Loading model' : 'Transcribing' },
{ k: 'finalize', label: 'Format &amp; save' }
];
const order = ['pending', 'downloading', 'preparing', 'transcribing', 'processing', 'done'];
const idx = order.indexOf(status);
const stageIndex = {
pending: 0,
downloading: 1,
preparing: 2,
warming_model: 3,
transcribing: 3,
processing: 4,
done: 5,
failed: -1,
cancelled: -1
}[status];
return stages.map((s, i) => ({
...s,
done: i < idx - 1 || status === 'done',
active: i === idx - 1 && status !== 'done' && status !== 'failed',
pending: i > idx - 1 && status !== 'done'
done: status === 'done' || i + 1 < stageIndex,
active: i + 1 === stageIndex && !isTerminalJobStatus(status),
pending: status !== 'done' && i + 1 > stageIndex
}));
});
@@ -57,7 +57,7 @@
onMount(async () => {
await loadJob();
if (job && !['done', 'failed', 'cancelled'].includes(job.status)) {
if (job && !isTerminalJobStatus(job.status)) {
openStream();
}
});
@@ -71,6 +71,7 @@
return;
}
job = await res.json();
segments = [];
if (job?.segmentsJson) {
try {
segments = JSON.parse(job.segmentsJson);
@@ -89,7 +90,11 @@
if (job) job = { ...job, progress: data.progress ?? job.progress, status: 'transcribing' };
} else if (data.type === 'model_warming') {
modelWarming = { state: data.state ?? 'loading', retryAfterSecs: data.retryAfterSecs ?? 30 };
chunkInfo = { chunk: 0, total: 0 };
if (job) job = { ...job, status: 'warming_model', progress: data.progress ?? job.progress };
} else if (data.type === 'status') {
if (data.status !== 'warming_model') modelWarming = null;
if (data.status !== 'transcribing') chunkInfo = { chunk: 0, total: 0 };
if (job) job = { ...job, status: data.status, progress: data.progress ?? job.progress };
} else if (data.type === 'done') {
modelWarming = null;
@@ -113,8 +118,21 @@
}
const formats = ['srt', 'txt', 'md', 'json'] as const;
const isActive = $derived(!job || !['done', 'failed', 'cancelled'].includes(job.status));
const isTerminal = $derived(job !== null && ['done', 'failed', 'cancelled'].includes(job.status));
const hasTranscript = $derived((job?.segmentsJson ? true : false) || segments.length > 0);
const displayProgress = $derived(job ? getDisplayJobProgress(job, { hasTranscript }) : 0);
const progressStatusLabel = $derived.by(() => {
if (!job) return 'Pending';
if (job.status === 'warming_model') {
const state = modelWarming?.state?.replace(/_/g, ' ');
return state ? `Loading model (${state})…` : 'Loading model…';
}
if (job.status === 'preparing') return 'Preparing audio…';
if (job.status === 'processing') return 'Saving transcript…';
if (job.status === 'transcribing') return 'Transcribing…';
return job.status === 'done' ? 'Done' : `${getJobStatusLabel(job.status)}…`;
});
const isActive = $derived(!job || !isTerminalJobStatus(job.status));
const isTerminal = $derived(job !== null && isTerminalJobStatus(job.status));
const canRetry = $derived(
job !== null &&
['failed', 'cancelled'].includes(job.status) &&
@@ -197,22 +215,22 @@
<div class="progress-card glass">
<!-- Waveform coloured by progress -->
<div class="progress-wave">
<Waveform
bars={140}
progress={job.progress}
accent={ACCENT}
height={80}
pattern="default"
<Waveform
bars={140}
progress={displayProgress}
accent={ACCENT}
height={80}
pattern="default"
/>
</div>
<div class="progress-footer">
<div class="progress-left">
<span class="progress-pct mono">
{job.progress}<span style="color: var(--text-dim); font-weight: 400">%</span>
</span>
<span class="progress-status">{statusLabel[job.status] ?? job.status}</span>
</div>
<div class="progress-footer">
<div class="progress-left">
<span class="progress-pct mono">
{displayProgress}<span style="color: var(--text-dim); font-weight: 400">%</span>
</span>
<span class="progress-status">{progressStatusLabel}</span>
</div>
{#if chunkInfo.total > 0}
<span class="progress-chunks mono">
chunk {chunkInfo.chunk} / {chunkInfo.total}
@@ -233,7 +251,7 @@
<div class="progress-bar-track">
<div
class="progress-bar-fill"
style="width: {job.progress}%; background: {ACCENT}; box-shadow: 0 0 12px {ACCENT}80;"
style="width: {displayProgress}%; background: {ACCENT}; box-shadow: 0 0 12px {ACCENT}80;"
></div>
</div>
</div>
@@ -275,7 +293,7 @@
{@html stage.label}
</span>
{#if stage.active}
<span class="mono" style="font-size: 11.5px; color: {ACCENT}">{job.progress}%</span>
<span class="mono" style="font-size: 11.5px; color: {ACCENT}">{displayProgress}%</span>
{/if}
</div>
{/each}
@@ -690,4 +708,3 @@
}
}
</style>

View File

@@ -123,7 +123,7 @@ describe('setJobStatus', () => {
it('transitions through all valid statuses', () => {
const job = createJob('src', 'title', 'auto');
const statuses = ['downloading', 'preparing', 'transcribing', 'processing', 'done'] as const;
const statuses = ['downloading', 'preparing', 'warming_model', 'transcribing', 'processing', 'done'] as const;
for (const status of statuses) {
setJobStatus(job.id, status, 50);
expect(getJob(job.id)!.status).toBe(status);

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import { getDisplayJobProgress, getJobStatusLabel, isTerminalJobStatus } from '$lib/job-progress.js';
import type { Job } from '$lib/types.js';
function makeJob(overrides: Partial<Job> = {}): Job {
return {
id: 'job-1',
status: 'transcribing',
title: 'Job',
source: 'https://example.com/audio.mp3',
audioMode: 'auto',
meanVolume: null,
whisperJobId: null,
progress: 42,
outputDir: null,
segmentsJson: null,
error: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides
};
}
describe('job progress helpers', () => {
it('keeps active jobs below 100 percent', () => {
expect(getDisplayJobProgress(makeJob({ status: 'transcribing', progress: 100 }))).toBe(99);
});
it('keeps model loading in an early progress band', () => {
expect(getDisplayJobProgress(makeJob({ status: 'warming_model', progress: 80 }))).toBe(15);
});
it('allows 100 percent once finished job has transcript payload', () => {
expect(
getDisplayJobProgress(makeJob({ status: 'done', progress: 100, segmentsJson: JSON.stringify([]) }), {
hasTranscript: true
})
).toBe(100);
});
it('holds done jobs below 100 percent until transcript data exists', () => {
expect(getDisplayJobProgress(makeJob({ status: 'done', progress: 100 }))).toBe(99);
});
it('exposes model-loading label as active state', () => {
expect(getJobStatusLabel('warming_model')).toBe('Loading model');
expect(isTerminalJobStatus('warming_model')).toBe(false);
});
});