fix(progress): separate model warmup state
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
52
src/lib/job-progress.ts
Normal file
52
src/lib/job-progress.ts
Normal 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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
49
src/tests/job-progress.test.ts
Normal file
49
src/tests/job-progress.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user