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 ────────────────────────────────
|
// ── 4. Submit to whisper with webhook ────────────────────────────────
|
||||||
setJobStatus(jobId, 'transcribing', 10);
|
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 webhookUrl = `${WEBHOOK_BASE_URL}/api/webhook/${jobId}`;
|
||||||
const whisperJobId = await submitJob(wavPath, webhookUrl, language, (state, retryAfterSecs) => {
|
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 });
|
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) ───────────────
|
// ── 5. Open SSE for live progress (non-blocking relay) ───────────────
|
||||||
streamJob(
|
streamJob(
|
||||||
|
|||||||
@@ -12,7 +12,16 @@ export interface ModelStatus {
|
|||||||
vram_total_mb?: number;
|
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 {
|
export interface Segment {
|
||||||
index: number;
|
index: number;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import type { Job, AudioMode } from '$lib/types.js';
|
import type { Job, AudioMode } from '$lib/types.js';
|
||||||
|
import { getDisplayJobProgress, getJobStatusLabel } from '$lib/job-progress.js';
|
||||||
import SourceIcon from '$lib/components/SourceIcon.svelte';
|
import SourceIcon from '$lib/components/SourceIcon.svelte';
|
||||||
import Waveform from '$lib/components/Waveform.svelte';
|
import Waveform from '$lib/components/Waveform.svelte';
|
||||||
import RecordButton from '$lib/components/RecordButton.svelte';
|
import RecordButton from '$lib/components/RecordButton.svelte';
|
||||||
@@ -95,8 +96,7 @@
|
|||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (job.source && !job.source.startsWith('http')) parts.push(job.source.split('/').pop() ?? '');
|
if (job.source && !job.source.startsWith('http')) parts.push(job.source.split('/').pop() ?? '');
|
||||||
if (job.audioMode) parts.push(job.audioMode);
|
if (job.audioMode) parts.push(job.audioMode);
|
||||||
if (job.status === 'done') parts.push('done');
|
parts.push(getJobStatusLabel(job.status).toLowerCase());
|
||||||
else parts.push(job.status);
|
|
||||||
return parts.join(' · ');
|
return parts.join(' · ');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,7 +271,9 @@
|
|||||||
<div class="recent-meta mono">{jobMeta(job)}</div>
|
<div class="recent-meta mono">{jobMeta(job)}</div>
|
||||||
</div>
|
</div>
|
||||||
{#if job.status !== 'done' && job.status !== 'failed' && job.status !== 'cancelled'}
|
{#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}
|
{/if}
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" style="color: var(--text-dim); flex-shrink:0">
|
<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"/>
|
<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);
|
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 }) {
|
export async function DELETE({ params }) {
|
||||||
const job = getJob(params.id);
|
const job = getJob(params.id);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import type { Job } from '$lib/types.js';
|
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 SourceIcon from '$lib/components/SourceIcon.svelte';
|
||||||
import Waveform from '$lib/components/Waveform.svelte';
|
import Waveform from '$lib/components/Waveform.svelte';
|
||||||
import { accent } from '$lib/accent.js';
|
import { accent } from '$lib/accent.js';
|
||||||
@@ -10,27 +11,6 @@
|
|||||||
let jobs = $state<Job[]>([]);
|
let jobs = $state<Job[]>([]);
|
||||||
let loading = $state(true);
|
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' {
|
function jobKind(job: Job): 'youtube' | 'audio' | 'video' | 'file' {
|
||||||
const s = job.source ?? '';
|
const s = job.source ?? '';
|
||||||
if (s.includes('youtube') || s.includes('youtu.be')) return 'youtube';
|
if (s.includes('youtube') || s.includes('youtu.be')) return 'youtube';
|
||||||
@@ -108,8 +88,8 @@
|
|||||||
<div class="job-info">
|
<div class="job-info">
|
||||||
<div class="job-name">{job.title || job.source}</div>
|
<div class="job-name">{job.title || job.source}</div>
|
||||||
<div class="job-meta mono">
|
<div class="job-meta mono">
|
||||||
<span style="color: {statusColor[job.status] ?? 'rgba(232,233,236,0.5)'}">
|
<span style="color: {getJobStatusColor(job.status)}">
|
||||||
{statusLabel[job.status] ?? job.status}
|
{getJobStatusLabel(job.status)}
|
||||||
</span>
|
</span>
|
||||||
{#if job.createdAt}
|
{#if job.createdAt}
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
@@ -122,14 +102,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if !['done', 'failed', 'cancelled'].includes(job.status)}
|
{#if !isTerminalJobStatus(job.status)}
|
||||||
<div class="job-wave">
|
<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>
|
</div>
|
||||||
{:else if job.status === 'done'}
|
{: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}
|
{: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}
|
{/if}
|
||||||
|
|
||||||
<!-- Row actions -->
|
<!-- Row actions -->
|
||||||
@@ -142,7 +132,7 @@
|
|||||||
title="Retry"
|
title="Retry"
|
||||||
>↺</button>
|
>↺</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if ['done', 'failed', 'cancelled'].includes(job.status)}
|
{#if isTerminalJobStatus(job.status)}
|
||||||
<button
|
<button
|
||||||
class="row-btn danger"
|
class="row-btn danger"
|
||||||
onclick={(e) => deleteJob(e, job)}
|
onclick={(e) => deleteJob(e, job)}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import type { Job, Segment } from '$lib/types.js';
|
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 SourceIcon from '$lib/components/SourceIcon.svelte';
|
||||||
import Waveform from '$lib/components/Waveform.svelte';
|
import Waveform from '$lib/components/Waveform.svelte';
|
||||||
import { accent } from '$lib/accent.js';
|
import { accent } from '$lib/accent.js';
|
||||||
@@ -16,17 +17,6 @@
|
|||||||
let modelWarming = $state<{ state: string; retryAfterSecs: number } | null>(null);
|
let modelWarming = $state<{ state: string; retryAfterSecs: number } | null>(null);
|
||||||
let eventSource: EventSource | 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
|
// Pipeline stages derived from job status
|
||||||
const pipelineStages = $derived.by(() => {
|
const pipelineStages = $derived.by(() => {
|
||||||
const status = job?.status ?? 'pending';
|
const status = job?.status ?? 'pending';
|
||||||
@@ -34,16 +24,26 @@
|
|||||||
{ k: 'fetch', label: 'Fetch source' },
|
{ k: 'fetch', label: 'Fetch source' },
|
||||||
{ k: 'extract', label: 'Extract audio track' },
|
{ k: 'extract', label: 'Extract audio track' },
|
||||||
{ k: 'process', label: `Audio processing · ${job?.audioMode ?? 'auto'}` },
|
{ 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' }
|
{ k: 'finalize', label: 'Format & save' }
|
||||||
];
|
];
|
||||||
const order = ['pending', 'downloading', 'preparing', 'transcribing', 'processing', 'done'];
|
const stageIndex = {
|
||||||
const idx = order.indexOf(status);
|
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) => ({
|
return stages.map((s, i) => ({
|
||||||
...s,
|
...s,
|
||||||
done: i < idx - 1 || status === 'done',
|
done: status === 'done' || i + 1 < stageIndex,
|
||||||
active: i === idx - 1 && status !== 'done' && status !== 'failed',
|
active: i + 1 === stageIndex && !isTerminalJobStatus(status),
|
||||||
pending: i > idx - 1 && status !== 'done'
|
pending: status !== 'done' && i + 1 > stageIndex
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await loadJob();
|
await loadJob();
|
||||||
if (job && !['done', 'failed', 'cancelled'].includes(job.status)) {
|
if (job && !isTerminalJobStatus(job.status)) {
|
||||||
openStream();
|
openStream();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -71,6 +71,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
job = await res.json();
|
job = await res.json();
|
||||||
|
segments = [];
|
||||||
if (job?.segmentsJson) {
|
if (job?.segmentsJson) {
|
||||||
try {
|
try {
|
||||||
segments = JSON.parse(job.segmentsJson);
|
segments = JSON.parse(job.segmentsJson);
|
||||||
@@ -89,7 +90,11 @@
|
|||||||
if (job) job = { ...job, progress: data.progress ?? job.progress, status: 'transcribing' };
|
if (job) job = { ...job, progress: data.progress ?? job.progress, status: 'transcribing' };
|
||||||
} else if (data.type === 'model_warming') {
|
} else if (data.type === 'model_warming') {
|
||||||
modelWarming = { state: data.state ?? 'loading', retryAfterSecs: data.retryAfterSecs ?? 30 };
|
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') {
|
} 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 };
|
if (job) job = { ...job, status: data.status, progress: data.progress ?? job.progress };
|
||||||
} else if (data.type === 'done') {
|
} else if (data.type === 'done') {
|
||||||
modelWarming = null;
|
modelWarming = null;
|
||||||
@@ -113,8 +118,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const formats = ['srt', 'txt', 'md', 'json'] as const;
|
const formats = ['srt', 'txt', 'md', 'json'] as const;
|
||||||
const isActive = $derived(!job || !['done', 'failed', 'cancelled'].includes(job.status));
|
const hasTranscript = $derived((job?.segmentsJson ? true : false) || segments.length > 0);
|
||||||
const isTerminal = $derived(job !== null && ['done', 'failed', 'cancelled'].includes(job.status));
|
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(
|
const canRetry = $derived(
|
||||||
job !== null &&
|
job !== null &&
|
||||||
['failed', 'cancelled'].includes(job.status) &&
|
['failed', 'cancelled'].includes(job.status) &&
|
||||||
@@ -197,22 +215,22 @@
|
|||||||
<div class="progress-card glass">
|
<div class="progress-card glass">
|
||||||
<!-- Waveform coloured by progress -->
|
<!-- Waveform coloured by progress -->
|
||||||
<div class="progress-wave">
|
<div class="progress-wave">
|
||||||
<Waveform
|
<Waveform
|
||||||
bars={140}
|
bars={140}
|
||||||
progress={job.progress}
|
progress={displayProgress}
|
||||||
accent={ACCENT}
|
accent={ACCENT}
|
||||||
height={80}
|
height={80}
|
||||||
pattern="default"
|
pattern="default"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="progress-footer">
|
<div class="progress-footer">
|
||||||
<div class="progress-left">
|
<div class="progress-left">
|
||||||
<span class="progress-pct mono">
|
<span class="progress-pct mono">
|
||||||
{job.progress}<span style="color: var(--text-dim); font-weight: 400">%</span>
|
{displayProgress}<span style="color: var(--text-dim); font-weight: 400">%</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="progress-status">{statusLabel[job.status] ?? job.status}</span>
|
<span class="progress-status">{progressStatusLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
{#if chunkInfo.total > 0}
|
{#if chunkInfo.total > 0}
|
||||||
<span class="progress-chunks mono">
|
<span class="progress-chunks mono">
|
||||||
chunk {chunkInfo.chunk} / {chunkInfo.total}
|
chunk {chunkInfo.chunk} / {chunkInfo.total}
|
||||||
@@ -233,7 +251,7 @@
|
|||||||
<div class="progress-bar-track">
|
<div class="progress-bar-track">
|
||||||
<div
|
<div
|
||||||
class="progress-bar-fill"
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -275,7 +293,7 @@
|
|||||||
{@html stage.label}
|
{@html stage.label}
|
||||||
</span>
|
</span>
|
||||||
{#if stage.active}
|
{#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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -690,4 +708,3 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ describe('setJobStatus', () => {
|
|||||||
|
|
||||||
it('transitions through all valid statuses', () => {
|
it('transitions through all valid statuses', () => {
|
||||||
const job = createJob('src', 'title', 'auto');
|
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) {
|
for (const status of statuses) {
|
||||||
setJobStatus(job.id, status, 50);
|
setJobStatus(job.id, status, 50);
|
||||||
expect(getJob(job.id)!.status).toBe(status);
|
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