Initial commit: Børnetime app scaffold

FastAPI + Celery + Svelte app for English→Danish video translation
with voice cloning. Includes diarization, STT, translation, TTS,
and audio mixing pipeline.
This commit is contained in:
2026-07-10 22:42:23 +02:00
commit 6851b26923
43 changed files with 3390 additions and 0 deletions
+227
View File
@@ -0,0 +1,227 @@
<script>
import { api } from '../lib/api.js';
import VideoCard from '../lib/components/VideoCard.svelte';
let { navigate } = $props();
let videos = $state([]);
let loading = $state(true);
let uploading = $state(false);
let error = $state(null);
let dragOver = $state(false);
async function load() {
loading = true;
error = null;
try {
const data = await api.listVideos();
videos = data.videos;
} catch (e) {
error = e.message;
} finally {
loading = false;
}
}
async function handleUpload(file) {
if (!file) return;
uploading = true;
try {
await api.uploadVideo(file);
await load();
} catch (e) {
error = e.message;
} finally {
uploading = false;
}
}
function onDrop(e) {
e.preventDefault();
dragOver = false;
const file = e.dataTransfer?.files?.[0];
if (file) handleUpload(file);
}
function onDragOver(e) {
e.preventDefault();
dragOver = true;
}
function onDragLeave() {
dragOver = false;
}
function onFileSelected(e) {
const file = e.target?.files?.[0];
if (file) handleUpload(file);
}
async function onDelete(id) {
await api.deleteVideo(id);
await load();
}
$effect(() => { load(); });
</script>
<div class="dashboard">
<div class="header">
<h1>Videos</h1>
<button class="btn-refresh" onclick={load}>
</button>
</div>
{#if error}
<div class="alert error">{error}</div>
{/if}
<div
class="upload-zone"
class:drag-over={dragOver}
ondragover={onDragOver}
ondragleave={onDragLeave}
ondrop={onDrop}
role="button"
tabindex="0"
onclick={() => document.getElementById('file-upload')?.click()}
>
<input
id="file-upload"
type="file"
accept=".mp4,.mkv,.mov,.avi,.webm,.m4v"
hidden
onchange={onFileSelected}
/>
{#if uploading}
<span class="upload-hint">Uploading…</span>
{:else}
<span class="upload-icon"></span>
<span class="upload-hint">Drop a video here or click to upload</span>
<span class="upload-formats">MP4, MKV, MOV, AVI, WebM, M4V</span>
{/if}
</div>
{#if loading}
<div class="loading">Loading videos…</div>
{:else if videos.length === 0}
<div class="empty">
<span class="empty-icon">🎥</span>
<p>No videos yet. Upload one to get started.</p>
</div>
{:else}
<div class="grid">
{#each videos as video}
<VideoCard {video} {navigate} ondelete={() => onDelete(video.id)} />
{/each}
</div>
{/if}
</div>
<style>
.dashboard {
max-width: 1400px;
margin: 0 auto;
width: 100%;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
h1 {
font-size: 24px;
font-weight: 700;
letter-spacing: -0.5px;
}
.btn-refresh {
background: var(--bg-tertiary);
border: 1px solid var(--border);
color: var(--text-secondary);
width: 36px;
height: 36px;
border-radius: var(--radius-sm);
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.btn-refresh:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.upload-zone {
border: 2px dashed var(--border);
border-radius: var(--radius);
padding: 32px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 20px;
background: var(--bg-secondary);
}
.upload-zone:hover, .upload-zone.drag-over {
border-color: var(--accent);
background: var(--accent-subtle);
}
.upload-icon {
display: block;
font-size: 28px;
margin-bottom: 8px;
}
.upload-hint {
display: block;
color: var(--text-secondary);
font-size: 15px;
font-weight: 500;
}
.upload-formats {
display: block;
color: var(--text-muted);
font-size: 12px;
margin-top: 4px;
}
.loading, .empty {
text-align: center;
padding: 64px 24px;
color: var(--text-muted);
}
.empty-icon {
font-size: 48px;
display: block;
margin-bottom: 12px;
}
.alert {
padding: 12px 16px;
border-radius: var(--radius-sm);
margin-bottom: 16px;
font-size: 13px;
}
.alert.error {
background: var(--red-bg);
color: var(--red);
border: 1px solid var(--red);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
</style>
+265
View File
@@ -0,0 +1,265 @@
<script>
import { api } from '../lib/api.js';
import ProgressBar from '../lib/components/ProgressBar.svelte';
let { navigate } = $props();
let jobs = $state([]);
let loading = $state(true);
let polling = $state(null);
async function load() {
try {
const data = await api.listJobs();
jobs = data.jobs;
} catch {} finally {
loading = false;
}
}
async function handleCancel(jobId, e) {
e.stopPropagation();
try {
await api.cancelJob(jobId);
await load();
} catch (err) {
console.error(err);
}
}
$effect(() => {
load();
polling = setInterval(load, 3000);
return () => clearInterval(polling);
});
const statusColors = {
pending: 'var(--blue)',
running: 'var(--yellow)',
done: 'var(--green)',
failed: 'var(--red)',
};
const statusBg = {
pending: 'var(--blue-bg)',
running: 'var(--yellow-bg)',
done: 'var(--green-bg)',
failed: 'var(--red-bg)',
};
function formatDate(d) {
return new Date(d).toLocaleString('en-GB');
}
</script>
<div class="queue">
<div class="header">
<h1>Jobs</h1>
<button class="btn-refresh" onclick={load}>↻</button>
</div>
{#if loading}
<div class="loading">Loading…</div>
{:else if jobs.length === 0}
<div class="empty">
<span class="empty-icon">📋</span>
<p>No jobs yet. Process a video to see jobs here.</p>
</div>
{:else}
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>ID</th>
<th>Type</th>
<th>Status</th>
<th>Progress</th>
<th>Video</th>
<th>Created</th>
<th>Error</th>
</tr>
</thead>
<tbody>
{#each jobs as job}
<tr onclick={() => navigate(`video/${job.video_id}`)}>
<td class="cell-id">#{job.id}</td>
<td><span class="job-type">{job.job_type}</span></td>
<td>
<span
class="badge"
style="background: {statusBg[job.status] || 'transparent'}; color: {statusColors[job.status] || 'var(--text-muted)'};"
>
{job.status}
</span>
</td>
<td class="cell-progress">
<ProgressBar value={job.progress} />
</td>
<td class="cell-video">#{job.video_id}</td>
<td class="cell-date">{formatDate(job.created_at)}</td>
<td class="cell-error">
{#if job.error_message}
<span class="error-text" title={job.error_message}> {job.error_message.slice(0, 40)}…</span>
{:else if job.status === 'pending' || job.status === 'running'}
<button class="cancel-btn" onclick={(e) => handleCancel(job.id, e)}> Cancel</button>
{:else}{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<style>
.queue {
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
h1 {
font-size: 24px;
font-weight: 700;
}
.btn-refresh {
background: var(--bg-tertiary);
border: 1px solid var(--border);
color: var(--text-secondary);
width: 36px;
height: 36px;
border-radius: var(--radius-sm);
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.btn-refresh:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.loading, .empty {
text-align: center;
padding: 64px 24px;
color: var(--text-muted);
}
.empty-icon {
font-size: 48px;
display: block;
margin-bottom: 12px;
}
.table-wrapper {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
thead th {
text-align: left;
padding: 12px 14px;
color: var(--text-muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid var(--border);
font-weight: 600;
}
tbody tr {
cursor: pointer;
transition: background 0.1s;
}
tbody tr:hover {
background: var(--bg-hover);
}
tbody td {
padding: 10px 14px;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
.cell-id {
font-family: 'SF Mono', 'Fira Code', monospace;
color: var(--text-muted);
font-size: 12px;
}
.job-type {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 12px;
font-weight: 600;
color: var(--accent);
text-transform: uppercase;
}
.badge {
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.cell-progress {
min-width: 120px;
}
.cell-video {
font-family: 'SF Mono', 'Fira Code', monospace;
color: var(--text-secondary);
}
.cell-date {
color: var(--text-muted);
font-size: 12px;
}
.cell-error {
max-width: 200px;
}
.error-text {
color: var(--red);
font-size: 12px;
}
.cancel-btn {
background: none;
border: 1px solid var(--yellow);
color: var(--yellow);
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
}
.cancel-btn:hover {
background: var(--yellow-bg);
}
</style>
+286
View File
@@ -0,0 +1,286 @@
<script>
import { api } from '../lib/api.js';
let { id, navigate } = $props();
let segments = $state([]);
let video = $state(null);
let loading = $state(true);
let saving = $state(false);
let error = $state(null);
let successMsg = $state(null);
async function load() {
try {
const [v, t] = await Promise.all([api.getVideo(id), api.getTranscript(id)]);
video = v;
segments = t.segments;
} catch (e) {
error = e.message;
} finally {
loading = false;
}
}
async function saveSegment(seg) {
saving = true;
try {
const updated = await api.updateSegment(id, seg.id, {
english_text: seg.english_text,
danish_text: seg.danish_text,
speaker_label: seg.speaker_label,
});
Object.assign(seg, updated);
successMsg = 'Saved';
setTimeout(() => successMsg = null, 2000);
} catch (e) {
error = e.message;
} finally {
saving = false;
}
}
async function handleGenerate() {
await api.generateAudio(id);
navigate(`video/${id}`);
}
function formatTime(sec) {
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
const ms = Math.floor((sec % 1) * 100);
return `${m}:${String(s).padStart(2, '0')}.${String(ms).padStart(2, '0')}`;
}
$effect(() => { load(); });
</script>
<div class="editor">
<button class="back" onclick={() => navigate(`video/${id}`)}> Back to Video</button>
<div class="editor-header">
<h1>{video?.filename || 'Transcript'}</h1>
{#if video?.status === 'review'}
<button class="btn accent" onclick={handleGenerate}>
🎙 Confirm & Generate Audio
</button>
{/if}
{#if successMsg}
<span class="success-toast">{successMsg}</span>
{/if}
</div>
{#if error}
<div class="alert error">{error}</div>
{/if}
{#if loading}
<div class="loading">Loading transcript…</div>
{:else if segments.length === 0}
<div class="empty">No segments available.</div>
{:else}
<div class="segment-list">
{#each segments as seg}
<div class="segment">
<div class="segment-header">
<span class="timestamp">{formatTime(seg.start_time)}{formatTime(seg.end_time)}</span>
<span class="duration">({(seg.end_time - seg.start_time).toFixed(1)}s)</span>
<span class="speaker-badge">{seg.speaker_label || '?'}</span>
<span class="seg-status done">✓ Transcribed</span>
</div>
<div class="segment-texts">
<div class="text-block">
<label>English</label>
<textarea
bind:value={seg.english_text}
rows="2"
onchange={() => saveSegment(seg)}
></textarea>
</div>
<div class="text-block">
<label>Danish</label>
<textarea
bind:value={seg.danish_text}
rows="2"
onchange={() => saveSegment(seg)}
></textarea>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
<style>
.editor {
max-width: 900px;
margin: 0 auto;
width: 100%;
}
.back {
background: none;
border: none;
color: var(--text-secondary);
font-size: 14px;
padding: 4px 0;
margin-bottom: 16px;
}
.back:hover {
color: var(--text-primary);
}
.editor-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
}
h1 {
font-size: 22px;
font-weight: 700;
flex: 1;
}
.btn {
padding: 8px 16px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
font-weight: 600;
font-size: 13px;
transition: all 0.15s;
background: var(--bg-tertiary);
color: var(--text-primary);
cursor: pointer;
}
.btn.accent {
background: var(--green);
border-color: var(--green);
color: var(--bg-primary);
}
.btn.accent:hover {
filter: brightness(1.1);
}
.success-toast {
background: var(--green-bg);
color: var(--green);
padding: 4px 12px;
border-radius: var(--radius-sm);
font-size: 12px;
font-weight: 600;
}
.alert {
padding: 12px 16px;
border-radius: var(--radius-sm);
margin-bottom: 16px;
font-size: 13px;
}
.alert.error {
background: var(--red-bg);
color: var(--red);
border: 1px solid var(--red);
}
.loading, .empty {
text-align: center;
padding: 48px;
color: var(--text-muted);
}
.segment-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.segment {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px;
transition: border-color 0.15s;
}
.segment:hover {
border-color: var(--accent);
}
.segment-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.timestamp {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 13px;
color: var(--accent);
font-weight: 600;
}
.duration {
font-size: 12px;
color: var(--text-muted);
}
.speaker-badge {
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
background: var(--accent-subtle);
color: var(--accent);
border-radius: 4px;
}
.seg-status {
font-size: 11px;
font-weight: 600;
color: var(--green);
margin-left: auto;
}
.segment-texts {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.text-block label {
display: block;
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 4px;
}
.text-block textarea {
width: 100%;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
padding: 8px 10px;
font-size: 13px;
line-height: 1.5;
resize: vertical;
transition: border-color 0.15s;
}
.text-block textarea:focus {
outline: none;
border-color: var(--accent);
}
</style>
+321
View File
@@ -0,0 +1,321 @@
<script>
import { api } from '../lib/api.js';
let { id, navigate } = $props();
let video = $state(null);
let loading = $state(true);
let error = $state(null);
let polling = $state(null);
async function load() {
try {
video = await api.getVideo(id);
} catch (e) {
error = e.message;
} finally {
loading = false;
}
}
function startPolling() {
if (polling) return;
polling = setInterval(async () => {
try {
video = await api.getVideo(id);
if (video.status === 'review' || video.status === 'done' || video.status === 'error') {
clearInterval(polling);
polling = null;
}
} catch {}
}, 2000);
}
async function handleProcess() {
await api.processVideo(id);
video = await api.getVideo(id);
startPolling();
}
async function handleGenerate() {
await api.generateAudio(id);
video = await api.getVideo(id);
startPolling();
}
async function handleDelete() {
await api.deleteVideo(id);
navigate('dashboard');
}
async function handleCancel() {
try {
const jobs = await api.listJobs();
const activeJob = jobs.jobs.find(j => j.video_id == id && (j.status === 'pending' || j.status === 'running'));
if (activeJob) {
await api.cancelJob(activeJob.id);
video = await api.getVideo(id);
}
} catch (e) {
error = e.message;
}
}
$effect(() => {
load();
return () => { if (polling) clearInterval(polling); };
});
$effect(() => {
if (video && (video.status === 'processing' || video.status === 'generating' || video.status === 'queued')) {
startPolling();
}
});
function videoUrl(id) {
return `/api/videos/${id}/file`;
}
function outputUrl(path) {
return path ? `/api/videos/file/${encodeURIComponent(path)}` : null;
}
</script>
<div class="detail">
<button class="back" onclick={() => navigate('dashboard')}> Back</button>
{#if loading}
<div class="loading">Loading…</div>
{:else if error}
<div class="alert error">{error}</div>
{:else if video}
<div class="detail-header">
<h1>{video.filename}</h1>
<div class="actions">
{#if video.status === 'uploaded' || video.status === 'error'}
<button class="btn primary" onclick={handleProcess}>
▶ Process
</button>
{/if}
{#if video.status === 'review'}
<button class="btn primary" onclick={() => navigate(`transcript/${video.id}`)}>
✏ Review Transcript
</button>
<button class="btn accent" onclick={handleGenerate}>
🎙 Generate Audio
</button>
{/if}
{#if video.status === 'done'}
<button class="btn primary" onclick={() => navigate(`transcript/${video.id}`)}>
✏ View Transcript
</button>
<a class="btn accent" href={outputUrl(video.output_path)} download>
⬇ Download
</a>
{/if}
{#if video.status === 'processing' || video.status === 'generating' || video.status === 'queued'}
<span class="processing-badge">
{video.status === 'queued' ? '⏳ Queued' : '⏳ Processing…'}
</span>
<button class="btn cancel" onclick={handleCancel}> Cancel</button>
{/if}
<button class="btn danger" onclick={handleDelete}>🗑 Delete</button>
</div>
</div>
<div class="meta-row">
<span class="meta-item">
<span class="meta-label">Status</span>
<span class="meta-value badge badge-{video.status}">{video.status}</span>
</span>
<span class="meta-item">
<span class="meta-label">Duration</span>
<span class="meta-value">{video.duration ? Math.floor(video.duration / 60) + 'm ' + Math.floor(video.duration % 60) + 's' : '—'}</span>
</span>
<span class="meta-item">
<span class="meta-label">Created</span>
<span class="meta-value">{new Date(video.created_at).toLocaleString('en-GB')}</span>
</span>
</div>
{#if video.status === 'error' && video.error_message}
<div class="alert error">Error: {video.error_message}</div>
{/if}
<div class="video-player">
<video controls width="100%" src={videoUrl(video.id)}>
Your browser does not support video playback.
</video>
</div>
{/if}
</div>
<style>
.detail {
max-width: 900px;
margin: 0 auto;
width: 100%;
}
.back {
background: none;
border: none;
color: var(--text-secondary);
font-size: 14px;
padding: 4px 0;
margin-bottom: 16px;
}
.back:hover {
color: var(--text-primary);
}
.detail-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
flex-wrap: wrap;
}
h1 {
font-size: 22px;
font-weight: 700;
word-break: break-word;
}
.actions {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.btn {
padding: 8px 16px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
font-weight: 600;
font-size: 13px;
transition: all 0.15s;
background: var(--bg-tertiary);
color: var(--text-primary);
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 4px;
}
.btn.primary {
background: var(--accent);
border-color: var(--accent);
color: white;
}
.btn.primary:hover {
background: var(--accent-hover);
}
.btn.accent {
background: var(--green);
border-color: var(--green);
color: var(--bg-primary);
}
.btn.accent:hover {
filter: brightness(1.1);
}
.btn.danger {
border-color: var(--red);
color: var(--red);
}
.btn.danger:hover {
background: var(--red-bg);
}
.btn.cancel {
border-color: var(--yellow);
color: var(--yellow);
}
.btn.cancel:hover {
background: var(--yellow-bg);
}
.processing-badge {
background: var(--yellow-bg);
color: var(--yellow);
padding: 8px 16px;
border-radius: var(--radius-sm);
font-weight: 600;
font-size: 13px;
}
.meta-row {
display: flex;
gap: 24px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.meta-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.meta-label {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.meta-value {
font-size: 14px;
color: var(--text-primary);
}
.badge-uploaded { color: var(--text-muted); }
.badge-queued { color: var(--blue); }
.badge-processing { color: var(--yellow); }
.badge-review { color: var(--accent); }
.badge-generating { color: var(--yellow); }
.badge-done { color: var(--green); }
.badge-error { color: var(--red); }
.alert {
padding: 12px 16px;
border-radius: var(--radius-sm);
margin-bottom: 16px;
font-size: 13px;
}
.alert.error {
background: var(--red-bg);
color: var(--red);
border: 1px solid var(--red);
}
.video-player {
background: var(--bg-secondary);
border-radius: var(--radius);
overflow: hidden;
border: 1px solid var(--border);
margin-top: 8px;
}
.video-player video {
display: block;
max-height: 500px;
}
.loading {
text-align: center;
padding: 48px;
color: var(--text-muted);
}
</style>