From 078b9913ff983adf9b0c0ac573d3dd34f3577c58 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Fri, 19 Jun 2026 19:56:30 -0700 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20piper-tts=20narrated=20progr?= =?UTF-8?q?ess=20report=20(=F0=9F=8E=A7=20Narrated=20tab)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the narration-script builder (#143). Adds the generator + playback: - scripts/narrate-report.mjs (npm run dashboard:narrate / make dashboard-narrate): gathers REAL data โ€” newest unified runs across both repos, recently-shipped commits, live perf metrics, the feature-tour clips โ€” feeds buildNarration, then renders each segment to audio with piper-tts (high-quality female voice, en_US-lessac-high) + ffmpeg, plus a combined track and a narration.json manifest. - server.mjs: GET /api/narration (manifest) + GET /narration/.mp3 (path-safe, mp3-only, HTTP Range); factored streamFile() shared with media serving; mp3 MIME. - app.mjs + page.mjs: a "๐ŸŽง Narrated" tab โ€” plays each segment's audio while the synced media (trend chart / live video clip / screenshot) shows above and the transcript below, auto-advancing; live run updates no longer disturb the player. Voices + audio live under the already-gitignored test-artifacts/. Verified: 39 unit tests; manifest 14 segments / ~2.5 min; mp3 served with Range; traversal + non-mp3 rejected (400); browser playback renders chart + video media, survives a server poll cycle, 0 console errors. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 6 +- Makefile | 7 +- package.json | 1 + scripts/narrate-report.mjs | 149 +++++++++++++++++++++++++++++++++ tests/lib/dashboard/README.md | 1 + tests/lib/dashboard/app.mjs | 58 ++++++++++++- tests/lib/dashboard/page.mjs | 15 ++++ tests/lib/dashboard/server.mjs | 53 +++++++++--- 8 files changed, 276 insertions(+), 14 deletions(-) create mode 100644 scripts/narrate-report.mjs diff --git a/CLAUDE.md b/CLAUDE.md index 01c8fc76..81c877f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -190,7 +190,11 @@ in BOTH repos (Core `test-artifacts/unified*` + Cloud `live-full-report/` keeps append-only trend history that survives Core's per-run overwrite, and **updates live over SSE** when a new run lands โ€” performance trend charts, a runs timeline, per-check drill-down with embedded screenshots + video clips. Zero deps -(`node:http` + SSE). See `tests/lib/dashboard/README.md`. +(`node:http` + SSE). It also has a **๐ŸŽง Narrated tab**: `npm run dashboard:narrate` +(`make dashboard-narrate`) renders a piper-tts walkthrough of progress (female +voice en_US-lessac-high) โ€” test health, shipped PRs, perf trends, feature tour โ€” +each segment's audio synced to its chart/video/screenshot. See +`tests/lib/dashboard/README.md`. The unified harness (`tests/run-unified.mjs` + `tests/sequences/unified.config.mjs`) is the single entry; profiles are `smoke|pr|full|report`. The test tree is organised diff --git a/Makefile b/Makefile index af835609..dc131f74 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # GraphDone Makefile for Test Automation # Run all tests and generate HTML report with: make test-all -.PHONY: help test test-all test-https test-e2e test-unit test-report dashboard deploy clean +.PHONY: help test test-all test-https test-e2e test-unit test-report dashboard dashboard-narrate deploy clean # Default target - show help help: @@ -60,6 +60,11 @@ dashboard: @echo "๐Ÿ“Š Starting live test dashboard at http://localhost:3199 ..." @npm run dashboard:open +# Generate the piper-tts narrated progress report (Narrated tab in the dashboard) +dashboard-narrate: + @echo "๐ŸŽง Generating narrated progress report (piper-tts)..." + @npm run dashboard:narrate + # Start production deployment deploy: @echo "๐Ÿš€ Starting production deployment..." diff --git a/package.json b/package.json index 0b0531ca..d447420c 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "test:unified:lib": "node --test tests/lib/", "dashboard": "node tests/lib/dashboard/server.mjs", "dashboard:open": "node tests/lib/dashboard/server.mjs --open", + "dashboard:narrate": "node scripts/narrate-report.mjs", "test:installation": "./scripts/test-installation-simple.sh", "test:https": "node tests/integration/ssl-certificate-analysis.js && node tests/integration/mobile-https-compatibility-test.js", "test:report": "open test-artifacts/unified/report.html", diff --git a/scripts/narrate-report.mjs b/scripts/narrate-report.mjs new file mode 100644 index 00000000..d8baee82 --- /dev/null +++ b/scripts/narrate-report.mjs @@ -0,0 +1,149 @@ +#!/usr/bin/env node +/** + * Generates the narrated progress report for the live dashboard: gathers real + * data (newest unified runs across both repos, recently-shipped commits, live + * perf metrics, the feature-tour clips), builds a spoken script with narrate.mjs, + * renders each segment to audio with piper-tts (high-quality female voice) + + * ffmpeg, and writes a manifest the dashboard plays back, synced to charts/media. + * + * node scripts/narrate-report.mjs + * + * Voice: test-artifacts/dashboard/voices/en_US-lessac-high.onnx (override PIPER_VOICE). + * Output (git-ignored, under test-artifacts/): test-artifacts/dashboard/narration/. + */ +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync } from 'node:fs'; +import { resolve, join } from 'node:path'; +import { discover } from '../tests/lib/dashboard/ingest.mjs'; +import { readJsonl } from '../tests/lib/dashboard/history.mjs'; +import { buildNarration } from '../tests/lib/dashboard/narrate.mjs'; + +const CORE = resolve(process.cwd()); +const CLOUD = resolve(CORE, '..', 'GraphDone-Cloud'); +const ARTIFACTS = join(CORE, 'test-artifacts'); +const STORE = join(ARTIFACTS, 'dashboard'); +const OUT = join(STORE, 'narration'); +const VOICE = process.env.PIPER_VOICE || join(STORE, 'voices', 'en_US-lessac-high.onnx'); +const VOICE_NAME = VOICE.split('/').pop().replace(/\.onnx$/, ''); + +const ROOTS = [ + { name: 'unified', label: 'Unified (latest)', path: join(ARTIFACTS, 'unified'), mode: 'slot' }, + { name: 'unified-cloudcheck', label: 'Cloud audit check', path: join(ARTIFACTS, 'unified-cloudcheck'), mode: 'slot' }, + { name: 'unified-perfcheck', label: 'Perf budgets', path: join(ARTIFACTS, 'unified-perfcheck'), mode: 'slot' }, + { name: 'unified-showcase', label: 'Showcase', path: join(ARTIFACTS, 'unified-showcase'), mode: 'slot' }, + { name: 'live-full-report', label: 'Cloudflare live', path: join(CLOUD, 'live-full-report'), mode: 'stamped' }, +]; + +const PERF_LABELS = { + 'graph.idleFps': 'idle frame rate', 'graph.dragFps': 'drag frame rate', 'graph.interactionFps': 'interaction frame rate', + 'graph.avgTickMs': 'average simulation tick', 'graph.driftPx': 'layout drift', 'graph.queryP95Ms': 'query latency at the 95th percentile', + 'suite.passRate': 'suite pass rate', 'physics.settleSeconds': 'physics settle time', +}; +const PERF_ORDER = ['graph.idleFps', 'graph.dragFps', 'graph.interactionFps', 'graph.avgTickMs', 'graph.driftPx', 'physics.settleSeconds']; + +if (!existsSync(VOICE)) { + console.error(`โŒ Voice model not found: ${VOICE}\n Download it, e.g.:\n curl -L -o "${VOICE}" https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/lessac/high/en_US-lessac-high.onnx\n (and the matching .onnx.json)`); + process.exit(1); +} +for (const bin of ['piper', 'ffmpeg', 'ffprobe']) { + if (spawnSync(bin, ['--help'], { stdio: 'ignore' }).error) { console.error(`โŒ ${bin} not on PATH`); process.exit(1); } +} + +// โ”€โ”€ gather data โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +const runs = discover(ROOTS); +const healthRun = runs[0] || null; +const mediaRun = runs.find((r) => { + for (const s of r.report.sequences || []) for (const c of s.cases || []) if ((c.attachments || []).some((a) => a.type === 'video')) return true; + return false; +}) || healthRun; + +const health = healthRun ? (() => { + const t = (healthRun.report.rollup && healthRun.report.rollup.totals) || {}; + const cases = t.cases || 0; + return { cases, sequences: t.sequences || (healthRun.report.sequences || []).length, passed: t.passed || 0, failed: t.failed || 0, warned: t.warned || 0, passRate: cases ? Math.round((t.passed / cases) * 1000) / 10 : 0, target: healthRun.report.target }; +})() : {}; + +const shipped = (() => { + const r = spawnSync('git', ['log', '--no-merges', '--format=%s', '-n', '60'], { cwd: CORE, encoding: 'utf8' }); + const subjects = (r.stdout || '').split('\n').map((s) => s.trim()).filter(Boolean); + const wanted = subjects.filter((s) => /^(feat|fix|perf|refactor|chore)(\([^)]*\))?:/i.test(s)); + return wanted.slice(0, 7).map((title) => ({ title })); +})(); + +const metricsRows = readJsonl(join(STORE, 'metrics.jsonl')); +const latestByMetric = new Map(); +for (const m of metricsRows) { + const prev = latestByMetric.get(m.metric); + if (!prev || (m.ts || 0) >= (prev.ts || 0)) latestByMetric.set(m.metric, m); +} +const perf = PERF_ORDER.filter((k) => latestByMetric.has(k)).map((k) => { + const m = latestByMetric.get(k); + return { metric: k, label: PERF_LABELS[k] || k, value: m.value, unit: m.unit, better: m.better }; +}); + +const tour = []; +if (mediaRun) { + for (const s of mediaRun.report.sequences || []) { + for (const c of s.cases || []) { + for (const a of c.attachments || []) { + if (a.type === 'video') tour.push({ name: a.name || c.ref || `clip-${tour.length + 1}`, note: c.title || '', kind: 'video', runId: mediaRun.runId, href: a.href }); + } + } + } +} + +const dateISO = new Date().toISOString(); +const narration = buildNarration({ dateISO, voiceName: VOICE_NAME, health, shipped, perf, tour }); +console.log(`๐Ÿ“ ${narration.segments.length} segments (${shipped.length} shipped, ${perf.length} perf, ${tour.length} clips${narration.meta.tourTruncated ? ' โ€” truncated to 8' : ''})`); + +// โ”€โ”€ render audio โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +if (existsSync(OUT)) rmSync(OUT, { recursive: true, force: true }); +mkdirSync(OUT, { recursive: true }); + +const durOf = (f) => { + const r = spawnSync('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=nk=1:nw=1', f], { encoding: 'utf8' }); + return Math.round((parseFloat((r.stdout || '0').trim()) || 0) * 1000); +}; + +const outSegments = []; +let totalMs = 0; +for (const seg of narration.segments) { + const wav = join(OUT, `${seg.id}.wav`); + const mp3 = `${seg.id}.mp3`; + const piperRes = spawnSync('piper', ['-m', VOICE, '-f', wav], { input: seg.text, encoding: 'utf8' }); + if (piperRes.status !== 0 || !existsSync(wav)) { console.warn(` โš ๏ธ piper failed for ${seg.id}`); continue; } + const ff = spawnSync('ffmpeg', ['-y', '-i', wav, '-ac', '1', '-b:a', '64k', join(OUT, mp3)], { stdio: 'ignore' }); + rmSync(wav, { force: true }); + if (ff.status !== 0) { console.warn(` โš ๏ธ ffmpeg failed for ${seg.id}`); continue; } + const durationMs = durOf(join(OUT, mp3)); + totalMs += durationMs; + outSegments.push({ ...seg, audioHref: mp3, durationMs }); + console.log(` ๐ŸŽ™๏ธ ${seg.id} ${(durationMs / 1000).toFixed(1)}s`); +} + +// combined single track +let full = null; +if (outSegments.length) { + const listFile = join(OUT, 'concat.txt'); + writeFileSync(listFile, outSegments.map((s) => `file '${s.audioHref}'`).join('\n') + '\n'); + const ff = spawnSync('ffmpeg', ['-y', '-f', 'concat', '-safe', '0', '-i', listFile, '-c', 'copy', join(OUT, 'narration-full.mp3')], { stdio: 'ignore', cwd: OUT }); + rmSync(listFile, { force: true }); + if (ff.status === 0 && existsSync(join(OUT, 'narration-full.mp3'))) full = { audioHref: 'narration-full.mp3', durationMs: durOf(join(OUT, 'narration-full.mp3')) }; +} + +const manifest = { + schema: 'graphdone.narration/1', + generatedAt: Date.now(), + voice: VOICE_NAME, + target: health.target || null, + totalDurationMs: totalMs, + full, + segments: outSegments, +}; +writeFileSync(join(OUT, 'narration.json'), JSON.stringify(manifest, null, 2)); + +const sizeKb = readdirSync(OUT).reduce((s, f) => { try { return s + statSync(join(OUT, f)).size; } catch { return s; } }, 0) / 1024; +console.log(`\n๐ŸŽง Narration ready: ${outSegments.length} segments ยท ${(totalMs / 1000).toFixed(0)}s total ยท ${Math.round(sizeKb)}KB`); +console.log(` voice : ${VOICE_NAME}`); +console.log(` out : ${join(OUT, 'narration.json')}`); +console.log(` dashboard โ†’ Narrated tab (npm run dashboard)`); diff --git a/tests/lib/dashboard/README.md b/tests/lib/dashboard/README.md index 692a703e..93718e06 100644 --- a/tests/lib/dashboard/README.md +++ b/tests/lib/dashboard/README.md @@ -28,6 +28,7 @@ npm run test:perf:scale # perf trends update on the Overvie drill into its sequences and **every citable check** (`ยง3.2.4`), with embedded **screenshots and video clips** and failure detail. - **Media** โ€” a gallery of all screenshots + videos for any media-bearing run. +- **๐ŸŽง Narrated** โ€” a piper-tts narrated walkthrough of progress (high-quality female voice): test health, recently-shipped PRs, performance trends, and a feature tour, each segment playing its audio while the synced media (trend chart / video clip / screenshot) shows above and the transcript below. Auto-advances. Generate it with `npm run dashboard:narrate` (data-driven from the newest runs + git history + metrics). ## How it stays "continuous" diff --git a/tests/lib/dashboard/app.mjs b/tests/lib/dashboard/app.mjs index a6656319..7f2c6432 100644 --- a/tests/lib/dashboard/app.mjs +++ b/tests/lib/dashboard/app.mjs @@ -194,15 +194,71 @@ async function renderMedia() { function setTab(tab, render = true) { ui.tab = tab; document.querySelectorAll('.tab[data-tab]').forEach((el) => el.classList.toggle('active', el.getAttribute('data-tab') === tab)); + if (tab !== 'narrated') { try { narrAudio.pause(); narrIdx = -1; } catch { /* */ } } if (!render) return; if (tab === 'overview') renderOverview(); else if (tab === 'runs') renderRuns(); else if (tab === 'media') renderMedia(); + else if (tab === 'narrated') renderNarrated(); +} + +const narrAudio = new Audio(); +let narration = null; +let narrIdx = -1; + +function chartDef(ref) { return CHART_DEFS.find((d) => d.metric === ref) || { metric: ref, title: ref, unit: '' }; } + +function narrMediaHtml(seg) { + const m = seg.media || { kind: 'none' }; + if (m.kind === 'chart') { + const d = chartDef(m.ref); + return lineChart({ title: d.title, series: seriesFor(d.metric), unit: d.unit, budget: d.budget ?? null, xIsTime: true }); + } + if (m.kind === 'video' && m.runId && m.href) return ``; + if (m.kind === 'image' && m.runId && m.href) return `${esc(seg.title)}`; + return '
no media for this segment
'; +} + +function playNarrSegment(i) { + if (!narration || i < 0 || i >= narration.segments.length) { narrIdx = -1; return; } + narrIdx = i; + const seg = narration.segments[i]; + const stage = $('narr-stage'); + if (stage) stage.innerHTML = narrMediaHtml(seg); + document.querySelectorAll('.narr-seg').forEach((el) => el.classList.toggle('active', Number(el.getAttribute('data-i')) === i)); + const row = document.querySelector(`.narr-seg[data-i="${i}"]`); + if (row) row.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + narrAudio.src = `/narration/${encodeURIComponent(seg.audioHref)}`; + narrAudio.play().catch(() => {}); +} + +narrAudio.onended = () => { if (ui.tab === 'narrated' && narrIdx >= 0) playNarrSegment(narrIdx + 1); }; + +async function renderNarrated() { + let data; + try { data = await fetchJSON('/api/narration'); } + catch { + narrAudio.pause(); + $('view').innerHTML = `
No narrated report yet.

Generate it with npm run dashboard:narrate (renders a piper-tts walkthrough of the latest results), then reopen this tab.
`; + return; + } + narration = data; + const mins = Math.round((data.totalDurationMs || 0) / 600) / 100; + $('view').innerHTML = ` +
๐ŸŽง Narrated progress report
+
voice ${esc(data.voice || '')} ยท ${data.segments.length} segments ยท ~${mins} min ยท narrated by piper-tts${data.full ? ` ยท download full track` : ''}
+
+
press play
+
${data.segments.map((s, i) => `
${esc(s.title)}${((s.durationMs || 0) / 1000).toFixed(0)}s
${esc(s.text)}
`).join('')}
`; + $('narr-play').onclick = () => playNarrSegment(0); + $('narr-stop').onclick = () => { narrAudio.pause(); narrIdx = -1; document.querySelectorAll('.narr-seg').forEach((el) => el.classList.remove('active')); }; + document.querySelectorAll('.narr-seg').forEach((el) => el.onclick = () => playNarrSegment(Number(el.getAttribute('data-i')))); } function rerender() { renderHeader(); if (ui.tab === 'detail' && ui.runId) openRun(ui.runId); + else if (ui.tab === 'narrated') { /* leave the narration player undisturbed by live run updates */ } else setTab(ui.tab); } @@ -228,7 +284,7 @@ function connectSSE() { document.querySelectorAll('.tab[data-tab]').forEach((el) => el.onclick = () => { ui.runId = null; setTab(el.getAttribute('data-tab')); }); document.addEventListener('keydown', (e) => { if (e.key !== 'Enter' && e.key !== ' ') return; - const el = e.target.closest && e.target.closest('[data-run],[data-tab],[data-mrun],.back'); + const el = e.target.closest && e.target.closest('[data-run],[data-tab],[data-mrun],.narr-seg,.back'); if (el) { e.preventDefault(); el.click(); } }); diff --git a/tests/lib/dashboard/page.mjs b/tests/lib/dashboard/page.mjs index 3d7345f1..17868e48 100644 --- a/tests/lib/dashboard/page.mjs +++ b/tests/lib/dashboard/page.mjs @@ -63,6 +63,20 @@ h1{font-size:20px;margin:0;display:flex;align-items:center;gap:10px} .att img,.att video{width:300px;border-radius:8px;border:1px solid #1e293b;background:#000;display:block} .att figcaption{color:#64748b;font-size:11px;margin-top:4px;word-break:break-all} .empty-state{color:#64748b;padding:40px;text-align:center} +.narr-controls{display:flex;gap:10px;margin:8px 0 14px} +.narr-btn{padding:8px 16px;border-radius:8px;background:#1e293b;border:1px solid #334155;color:#e2e8f0;cursor:pointer;font-size:14px;font-weight:600} +.narr-btn:hover{background:#27364f} +.narr-stage{background:#0f1623;border:1px solid #243044;border-radius:12px;padding:12px;min-height:200px;display:flex;align-items:center;justify-content:center;margin-bottom:16px} +.narr-stage video,.narr-stage img{max-width:100%;max-height:420px;border-radius:8px;border:1px solid #1e293b;background:#000} +.narr-stage .chart{width:100%;max-width:640px} +.narr-list{display:flex;flex-direction:column;gap:8px} +.narr-seg{background:#0f1729;border:1px solid #1e293b;border-radius:10px;padding:10px 14px;cursor:pointer} +.narr-seg:hover{border-color:#334155} +.narr-seg.active{border-color:#34d399;background:#10241f;box-shadow:0 0 0 1px #34d39955} +.narr-seg-h{display:flex;align-items:center;gap:10px} +.narr-seg-t{font-weight:600;flex:1;text-transform:capitalize} +.narr-seg-d{color:#64748b;font-size:12px;font-variant-numeric:tabular-nums} +.narr-seg-text{color:#94a3b8;font-size:12px;margin-top:4px;line-height:1.5} footer{color:#475569;font-size:12px;margin-top:40px;text-align:center}
@@ -75,6 +89,7 @@ footer{color:#475569;font-size:12px;margin-top:40px;text-align:center} +
diff --git a/tests/lib/dashboard/server.mjs b/tests/lib/dashboard/server.mjs index 11435c2a..e86b78c8 100644 --- a/tests/lib/dashboard/server.mjs +++ b/tests/lib/dashboard/server.mjs @@ -47,8 +47,10 @@ const HERE = resolve(new URL('.', import.meta.url).pathname); const MIME = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.webm': 'video/webm', '.mp4': 'video/mp4', + '.mp3': 'audio/mpeg', }; const MEDIA_EXT = new Set(Object.keys(MIME)); +const NARRATION = join(STORE, 'narration'); let state = { generatedAt: 0, latest: null, runs: [], metrics: [], roots: [] }; const runIndex = new Map(); @@ -133,21 +135,14 @@ function send(res, code, type, body, extra = {}) { res.end(body); } -function serveMedia(req, res, runId, href) { - const entry = runIndex.get(runId); - if (!entry || !entry.mediaBase) return send(res, 404, 'text/plain', 'unknown run'); - let base, target; - try { - base = realpathSync(entry.mediaBase); - target = realpathSync(resolve(base, href)); - } catch { return send(res, 404, 'text/plain', 'not found'); } - if (target !== base && !target.startsWith(base + sep)) return send(res, 403, 'text/plain', 'forbidden'); +// Stream a file with HTTP Range support (incl. suffix bytes). Caller has already +// validated `target` is a real file within an allowed base + allowed extension. +function streamFile(req, res, target) { const ext = extname(target).toLowerCase(); - if (!MEDIA_EXT.has(ext)) return send(res, 415, 'text/plain', 'unsupported'); + const type = MIME[ext] || 'application/octet-stream'; let st; try { st = statSync(target); } catch { return send(res, 404, 'text/plain', 'not found'); } if (!st.isFile()) return send(res, 404, 'text/plain', 'not found'); - const type = MIME[ext] || 'application/octet-stream'; const range = req.headers.range; if (range) { const m = /bytes=(\d*)-(\d*)/.exec(range); @@ -166,6 +161,31 @@ function serveMedia(req, res, runId, href) { createReadStream(target).pipe(res); } +function serveMedia(req, res, runId, href) { + const entry = runIndex.get(runId); + if (!entry || !entry.mediaBase) return send(res, 404, 'text/plain', 'unknown run'); + let base, target; + try { + base = realpathSync(entry.mediaBase); + target = realpathSync(resolve(base, href)); + } catch { return send(res, 404, 'text/plain', 'not found'); } + if (target !== base && !target.startsWith(base + sep)) return send(res, 403, 'text/plain', 'forbidden'); + if (!MEDIA_EXT.has(extname(target).toLowerCase())) return send(res, 415, 'text/plain', 'unsupported'); + return streamFile(req, res, target); +} + +// Serve a narration audio file (only .mp3, only from the narration dir). +function serveNarration(req, res, name) { + if (!/^[a-zA-Z0-9._-]+\.mp3$/.test(name)) return send(res, 400, 'text/plain', 'bad name'); + let base, target; + try { + base = realpathSync(NARRATION); + target = realpathSync(resolve(base, name)); + } catch { return send(res, 404, 'text/plain', 'not found'); } + if (!target.startsWith(base + sep)) return send(res, 403, 'text/plain', 'forbidden'); + return streamFile(req, res, target); +} + const server = createServer((req, res) => { const url = new URL(req.url, `http://localhost:${PORT}`); const path = url.pathname; @@ -198,6 +218,17 @@ const server = createServer((req, res) => { return serveMedia(req, res, id, href); } + if (path === '/api/narration') { + const f = join(NARRATION, 'narration.json'); + if (!existsSync(f)) return send(res, 404, 'application/json', JSON.stringify({ error: 'no narration yet โ€” run npm run dashboard:narrate' })); + try { return send(res, 200, 'application/json', readFileSync(f, 'utf8')); } + catch { return send(res, 500, 'application/json', JSON.stringify({ error: 'unreadable narration manifest' })); } + } + + if (path.startsWith('/narration/')) { + return serveNarration(req, res, decodeURIComponent(path.slice('/narration/'.length))); + } + if (path === '/api/events') { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' }); res.write(`event: changed\ndata: ${JSON.stringify({ latest: state.latest })}\n\n`);