mattkevan commited on
Commit
13a101b
·
1 Parent(s): 4079a6f

Add live previews

Browse files
Files changed (4) hide show
  1. app.py +6 -0
  2. frontend/app.js +65 -3
  3. frontend/styles.css +17 -0
  4. tests/test_render_api.py +46 -0
app.py CHANGED
@@ -138,6 +138,12 @@ def start_render_api(
138
  diffusion_steps=diffusion_steps,
139
  speed=speed,
140
  ):
 
 
 
 
 
 
141
  captured.append(event)
142
  yield event
143
 
 
138
  diffusion_steps=diffusion_steps,
139
  speed=speed,
140
  ):
141
+ if event.get("type") == "chapter_done" and event.get("output_path"):
142
+ filename = Path(str(event["output_path"])).name
143
+ event = {
144
+ **event,
145
+ "url": f"/files/{session_id}/renders/{filename}",
146
+ }
147
  captured.append(event)
148
  yield event
149
 
frontend/app.js CHANGED
@@ -306,7 +306,7 @@ async function submitRender() {
306
  if (event.type !== "data") continue;
307
  const payload = event.data?.[0] ?? event.data;
308
  if (!payload) continue;
309
- state.renderEvents.push(payload);
310
  updateRenderState(payload);
311
  render();
312
  }
@@ -326,6 +326,9 @@ function updateRenderState(payload) {
326
  break;
327
  case "chapter_done":
328
  updateChapterDuration(payload.chapter_id, payload.duration_seconds);
 
 
 
329
  break;
330
  case "completed":
331
  state.renderResult = payload;
@@ -434,6 +437,38 @@ function updateChapterDuration(chapterId, seconds) {
434
  }
435
  }
436
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
437
  function render() {
438
  const previousChapterListScrollTop = document.querySelector(".chapter-list")?.scrollTop ?? 0;
439
  const previousReadingContainer = document.querySelector("[data-reading-scroll]");
@@ -587,6 +622,7 @@ function renderConfigure() {
587
  function renderGenerate() {
588
  const included = includedChapters();
589
  const currentEvent = [...state.renderEvents].reverse().find((event) => event.type === "chapter_started");
 
590
  const currentId = currentEvent?.chapter_id;
591
  const doneCount = state.renderEvents.filter((event) => event.type === "chapter_done").length;
592
  const percent = currentEvent ? Math.round((currentEvent.overall_progress || 0) * 100) : state.renderResult ? 100 : 0;
@@ -650,6 +686,13 @@ function renderGenerate() {
650
  <div class="log">
651
  ${state.renderEvents.slice(-8).reverse().map(renderLogRow).join("") || `<div class="log-row"><span class="time">—</span><span>Waiting for render events…</span></div>`}
652
  </div>
 
 
 
 
 
 
 
653
  </div>
654
  </div>
655
 
@@ -941,7 +984,7 @@ function renderQueueRow(chapter, currentId) {
941
  }
942
 
943
  function renderLogRow(event) {
944
- const stamp = new Date().toLocaleTimeString();
945
  return `<div class="log-row ${event.type === "chapter_started" ? "current" : ""}"><span class="time">${stamp}</span><span>${escapeHtml(logMessage(event))}</span></div>`;
946
  }
947
 
@@ -951,8 +994,10 @@ function logMessage(event) {
951
  return `Began binding ${event.total_chapters} chapters`;
952
  case "chapter_started":
953
  return `Synthesising ${event.chapter_title}`;
 
 
954
  case "chapter_done":
955
- return `Bound ${event.chapter_id} · ${event.duration_seconds}s`;
956
  case "completed":
957
  return "Completed audiobook render";
958
  case "cancelled":
@@ -962,6 +1007,18 @@ function logMessage(event) {
962
  }
963
  }
964
 
 
 
 
 
 
 
 
 
 
 
 
 
965
  function canStartRender() {
966
  if (!state.book) return false;
967
  if (!selectedChapterIds().length) return false;
@@ -987,6 +1044,11 @@ function activeNarratorName() {
987
  return narrator?.name || "Custom narrator";
988
  }
989
 
 
 
 
 
 
990
  function syncNarratorMetadata() {
991
  state.exportMetadata.narrator = `${activeNarratorName()} (OmniVoice)`;
992
  }
 
306
  if (event.type !== "data") continue;
307
  const payload = event.data?.[0] ?? event.data;
308
  if (!payload) continue;
309
+ appendRenderEvent(payload);
310
  updateRenderState(payload);
311
  render();
312
  }
 
326
  break;
327
  case "chapter_done":
328
  updateChapterDuration(payload.chapter_id, payload.duration_seconds);
329
+ updateChapterRenderUrl(payload.chapter_id, payload.url);
330
+ state.previewUrl = payload.url || state.previewUrl;
331
+ state.statusMessage = `Bound ${chapterTitle(payload.chapter_id)}. Listen while the next chapter renders.`;
332
  break;
333
  case "completed":
334
  state.renderResult = payload;
 
437
  }
438
  }
439
 
440
+ function updateChapterRenderUrl(chapterId, url) {
441
+ const chapter = state.chapters.find((item) => item.id === chapterId);
442
+ if (chapter && url) {
443
+ chapter.render_url = url;
444
+ }
445
+ }
446
+
447
+ function appendRenderEvent(payload) {
448
+ const stamped = {
449
+ ...payload,
450
+ received_at: payload.received_at || new Date().toISOString(),
451
+ };
452
+ const previous = state.renderEvents[state.renderEvents.length - 1];
453
+ if (previous && renderEventKey(previous) === renderEventKey(stamped)) {
454
+ return;
455
+ }
456
+ state.renderEvents.push(stamped);
457
+ }
458
+
459
+ function renderEventKey(event) {
460
+ return JSON.stringify({
461
+ type: event.type,
462
+ chapter_id: event.chapter_id,
463
+ chapter_title: event.chapter_title,
464
+ percent: event.percent,
465
+ duration_seconds: event.duration_seconds,
466
+ output_path: event.output_path,
467
+ url: event.url,
468
+ overall_progress: event.overall_progress,
469
+ });
470
+ }
471
+
472
  function render() {
473
  const previousChapterListScrollTop = document.querySelector(".chapter-list")?.scrollTop ?? 0;
474
  const previousReadingContainer = document.querySelector("[data-reading-scroll]");
 
622
  function renderGenerate() {
623
  const included = includedChapters();
624
  const currentEvent = [...state.renderEvents].reverse().find((event) => event.type === "chapter_started");
625
+ const completedChapters = included.filter((chapter) => chapter.render_url);
626
  const currentId = currentEvent?.chapter_id;
627
  const doneCount = state.renderEvents.filter((event) => event.type === "chapter_done").length;
628
  const percent = currentEvent ? Math.round((currentEvent.overall_progress || 0) * 100) : state.renderResult ? 100 : 0;
 
686
  <div class="log">
687
  ${state.renderEvents.slice(-8).reverse().map(renderLogRow).join("") || `<div class="log-row"><span class="time">—</span><span>Waiting for render events…</span></div>`}
688
  </div>
689
+ <div class="panel-head" style="margin-top:18px;">
690
+ <h2 class="panel-title" style="font-size:19px;">Completed chapters</h2>
691
+ </div>
692
+ <div class="queue">
693
+ <div class="queue-head">Listen while rendering continues <span class="count">${completedChapters.length} ready</span></div>
694
+ ${completedChapters.length ? completedChapters.map(renderCompletedChapterRow).join("") : `<div class="log-row"><span class="time">—</span><span>No chapter audio ready yet.</span></div>`}
695
+ </div>
696
  </div>
697
  </div>
698
 
 
984
  }
985
 
986
  function renderLogRow(event) {
987
+ const stamp = new Date(event.received_at || Date.now()).toLocaleTimeString();
988
  return `<div class="log-row ${event.type === "chapter_started" ? "current" : ""}"><span class="time">${stamp}</span><span>${escapeHtml(logMessage(event))}</span></div>`;
989
  }
990
 
 
994
  return `Began binding ${event.total_chapters} chapters`;
995
  case "chapter_started":
996
  return `Synthesising ${event.chapter_title}`;
997
+ case "chapter_progress":
998
+ return `${chapterTitle(event.chapter_id)} is still rendering`;
999
  case "chapter_done":
1000
+ return `Bound ${chapterTitle(event.chapter_id)} · ${event.duration_seconds}s`;
1001
  case "completed":
1002
  return "Completed audiobook render";
1003
  case "cancelled":
 
1007
  }
1008
  }
1009
 
1010
+ function renderCompletedChapterRow(chapter) {
1011
+ return `
1012
+ <div class="completed-row">
1013
+ <div>
1014
+ <div class="queue-name">${escapeHtml(chapter.title)}</div>
1015
+ <div class="queue-meta">${escapeHtml(`chapter ${chapter.n} · ${formatStamp(Number(chapter.duration_seconds || 0))}`)}</div>
1016
+ </div>
1017
+ <audio controls preload="metadata" src="${chapter.render_url}"></audio>
1018
+ </div>
1019
+ `;
1020
+ }
1021
+
1022
  function canStartRender() {
1023
  if (!state.book) return false;
1024
  if (!selectedChapterIds().length) return false;
 
1044
  return narrator?.name || "Custom narrator";
1045
  }
1046
 
1047
+ function chapterTitle(chapterId) {
1048
+ const chapter = state.chapters.find((item) => item.id === chapterId);
1049
+ return chapter?.title || chapterId || "chapter";
1050
+ }
1051
+
1052
  function syncNarratorMetadata() {
1053
  state.exportMetadata.narrator = `${activeNarratorName()} (OmniVoice)`;
1054
  }
frontend/styles.css CHANGED
@@ -916,6 +916,23 @@ select {
916
  color: var(--leather);
917
  }
918
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
919
  .time {
920
  color: var(--brassDeep);
921
  font-variant-numeric: tabular-nums;
 
916
  color: var(--leather);
917
  }
918
 
919
+ .completed-row {
920
+ display: grid;
921
+ grid-template-columns: minmax(0, 1fr) minmax(220px, 320px);
922
+ gap: 16px;
923
+ align-items: center;
924
+ padding: 12px 16px;
925
+ border-top: 1px solid rgba(135, 99, 39, 0.12);
926
+ }
927
+
928
+ .completed-row:first-of-type {
929
+ border-top: 0;
930
+ }
931
+
932
+ .completed-row audio {
933
+ width: 100%;
934
+ }
935
+
936
  .time {
937
  color: var(--brassDeep);
938
  font-variant-numeric: tabular-nums;
tests/test_render_api.py CHANGED
@@ -1,5 +1,8 @@
1
  import importlib
2
  import sys
 
 
 
3
 
4
  import gradio as gr
5
 
@@ -13,3 +16,46 @@ def test_start_render_api_registers_one_streamed_output() -> None:
13
 
14
  assert dependency.get("outputs")
15
  assert len(dependency["outputs"]) == 1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import importlib
2
  import sys
3
+ from types import SimpleNamespace
4
+
5
+ import pytest
6
 
7
  import gradio as gr
8
 
 
16
 
17
  assert dependency.get("outputs")
18
  assert len(dependency["outputs"]) == 1
19
+
20
+
21
+ def test_start_render_api_adds_public_audio_url_to_completed_chapters(monkeypatch) -> None:
22
+ sys.modules.pop("app", None)
23
+ module = importlib.import_module("app")
24
+
25
+ class FakePipeline:
26
+ def get_job(self, session_id):
27
+ return SimpleNamespace(status="idle")
28
+
29
+ def render(self, **kwargs):
30
+ yield {
31
+ "type": "chapter_done",
32
+ "session_id": kwargs["session_id"],
33
+ "chapter_id": "c1",
34
+ "duration_seconds": 12,
35
+ "overall_progress": 0.5,
36
+ "output_path": "/tmp/scriptorium/session-a/renders/001-c1.wav",
37
+ }
38
+ yield {
39
+ "type": "completed",
40
+ "session_id": kwargs["session_id"],
41
+ "outputs": ["/tmp/scriptorium/session-a/renders/001-c1.wav"],
42
+ }
43
+
44
+ monkeypatch.setattr(module, "pipeline", FakePipeline())
45
+ monkeypatch.setattr(module, "_selected_book", lambda session_id, selected_ids: {"chapters": [{"id": "c1", "included": True}]})
46
+ monkeypatch.setattr(module, "_voice_config_for_backend", lambda voice_config, session_id: voice_config)
47
+ monkeypatch.setattr(module.store, "save_json", lambda *args, **kwargs: None)
48
+
49
+ events = list(
50
+ module.start_render_api(
51
+ session_id="session-a",
52
+ selected_chapter_ids=["c1"],
53
+ voice_config={"mode": "auto"},
54
+ diffusion_steps=32,
55
+ speed=1.0,
56
+ )
57
+ )
58
+
59
+ chapter_done = events[0]
60
+ assert chapter_done["type"] == "chapter_done"
61
+ assert chapter_done["url"] == "/files/session-a/renders/001-c1.wav"