voxflow 1.15.2 → 1.15.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -231,6 +231,121 @@ function renderSliceStageHtml({ sourcePath, port }) {
231
231
  }
232
232
  .render-btn.completed:hover { filter: none; text-decoration: underline; }
233
233
 
234
+ /* Secondary (de-emphasised) render variant for "the other path". When the
235
+ user has no cached login, local becomes primary and cloud takes this
236
+ style; when they're logged in, local takes this style. The pair is
237
+ always visible — the emphasis just flips. */
238
+ .render-btn.secondary {
239
+ background: transparent; color: var(--text);
240
+ border-color: var(--border); font-weight: 500;
241
+ }
242
+ .render-btn.secondary:hover:not(:disabled) {
243
+ border-color: var(--accent); filter: none;
244
+ }
245
+ .render-btn.secondary.completed { color: var(--accent); }
246
+
247
+ /* Local-render mini-progress bar shown next to the local button while a
248
+ job is rendering. Sits inline with the button so the header doesn't
249
+ reflow. */
250
+ .render-local-progress {
251
+ display: inline-flex; align-items: center; gap: 6px;
252
+ font-size: 11px; color: var(--muted);
253
+ font-variant-numeric: tabular-nums;
254
+ }
255
+ .render-local-progress.hidden { display: none; }
256
+ .render-local-progress .bar {
257
+ width: 90px; height: 4px; border-radius: 2px;
258
+ background: var(--panel-2); overflow: hidden;
259
+ border: 1px solid var(--border);
260
+ }
261
+ .render-local-progress .bar > span {
262
+ display: block; height: 100%;
263
+ background: var(--accent);
264
+ transition: width 0.15s ease;
265
+ }
266
+
267
+ /* Toast that confirms the local mp4 was written. Persistent — user
268
+ dismisses with the close button. The "Open in Finder" link is a
269
+ file:// href; browser security may block click-to-open without a
270
+ prior user gesture, so we render it as a plain link. */
271
+ .local-render-toast {
272
+ position: fixed; right: 16px; bottom: 16px; z-index: 60;
273
+ max-width: 360px;
274
+ background: var(--panel); color: var(--text);
275
+ border: 1px solid var(--good); border-radius: 8px;
276
+ box-shadow: 0 8px 32px rgba(0,0,0,0.2);
277
+ padding: 10px 12px;
278
+ font-size: 12px;
279
+ display: none; flex-direction: column; gap: 6px;
280
+ }
281
+ .local-render-toast.open { display: flex; }
282
+ .local-render-toast .toast-head {
283
+ display: flex; align-items: center; gap: 6px;
284
+ font-weight: 600; color: var(--good);
285
+ }
286
+ .local-render-toast .toast-head .grow { flex: 1; }
287
+ .local-render-toast .toast-head .close {
288
+ appearance: none; border: 0; background: transparent;
289
+ color: var(--muted); font-size: 16px; line-height: 1;
290
+ padding: 0 4px; cursor: pointer;
291
+ }
292
+ .local-render-toast .path {
293
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
294
+ font-size: 11px; color: var(--muted); word-break: break-all;
295
+ }
296
+ .local-render-toast .actions { display: flex; gap: 8px; }
297
+ .local-render-toast .actions a, .local-render-toast .actions button {
298
+ appearance: none; cursor: pointer; font: inherit;
299
+ font-size: 11px; padding: 4px 8px; border-radius: 4px;
300
+ border: 1px solid var(--border); background: var(--panel-2);
301
+ color: var(--text); text-decoration: none;
302
+ }
303
+ .local-render-toast .actions a:hover,
304
+ .local-render-toast .actions button:hover { border-color: var(--accent); }
305
+
306
+ /* Inline-edit affordance — textarea replaces the caption / narration / title
307
+ text in place. Mirrors the original element's metrics so the card layout
308
+ doesn't jump when the user clicks to edit. */
309
+ .stage-card .editable {
310
+ cursor: text;
311
+ border-radius: 3px;
312
+ padding: 1px 3px; margin: -1px -3px;
313
+ transition: background 0.12s ease, outline 0.12s ease;
314
+ }
315
+ .stage-card .editable:hover {
316
+ background: rgba(0,0,0,0.04);
317
+ outline: 1px dashed rgba(0,0,0,0.2);
318
+ }
319
+ .stage-card[data-card-bg="dark"] .editable:hover {
320
+ background: rgba(255,255,255,0.06);
321
+ outline: 1px dashed rgba(255,255,255,0.25);
322
+ }
323
+ .stage-card textarea.inline-edit {
324
+ width: 100%; min-height: 1.6em;
325
+ font: inherit; color: inherit;
326
+ background: rgba(0,0,0,0.05);
327
+ border: 1px solid currentColor; border-radius: 3px;
328
+ padding: 2px 4px; margin: -2px -4px;
329
+ resize: none; outline: none;
330
+ box-shadow: 0 0 0 2px color-mix(in srgb, currentColor 25%, transparent);
331
+ }
332
+ .stage-card[data-card-bg="dark"] textarea.inline-edit {
333
+ background: rgba(255,255,255,0.08);
334
+ }
335
+ .stage-card .inline-edit-err {
336
+ position: absolute; left: 10px; right: 10px; bottom: 10px;
337
+ background: var(--bad); color: white;
338
+ font-size: 10px; line-height: 1.3;
339
+ padding: 4px 6px; border-radius: 4px;
340
+ z-index: 5;
341
+ }
342
+ .stage-card .inline-edit-hint {
343
+ position: absolute; left: 10px; right: 10px; bottom: 4px;
344
+ font-size: 9px; color: var(--muted); letter-spacing: 0.04em;
345
+ text-align: center; pointer-events: none; opacity: 0.7;
346
+ z-index: 4;
347
+ }
348
+
234
349
  .render-modal-body { display: flex; flex-direction: column; gap: 8px; padding: 4px 0 6px; }
235
350
  .render-modal-body .quota-line {
236
351
  display: flex; justify-content: space-between; align-items: baseline;
@@ -432,6 +547,49 @@ function renderSliceStageHtml({ sourcePath, port }) {
432
547
  .deck-toolbar button.copied {
433
548
  color: var(--good); border-color: var(--good);
434
549
  }
550
+ /* ─── Audition (▶) — voice picker + per-card play + status ────────────── */
551
+ .toolbar-divider {
552
+ width: 1px; align-self: stretch;
553
+ background: var(--border); margin: 2px 4px;
554
+ }
555
+ .voice-picker {
556
+ display: inline-flex; align-items: center; gap: 6px;
557
+ border: 1px solid var(--border); border-radius: 8px;
558
+ padding: 0 8px; font-size: 12px;
559
+ }
560
+ .voice-picker:focus-within { border-color: var(--accent); }
561
+ .voice-picker-icon { color: var(--muted); font-size: 12px; }
562
+ #voice-picker-input {
563
+ appearance: none; border: 0; background: transparent;
564
+ font: inherit; color: var(--text); font-size: 12px;
565
+ padding: 6px 0; width: 180px; outline: none;
566
+ }
567
+ .stage-card .card-actions button[data-action="audition"] .audition-icon {
568
+ display: inline-block; margin-right: 4px; font-size: 9px;
569
+ }
570
+ .stage-card .card-actions button[data-action="audition"].playing {
571
+ background: rgba(88,81,184,0.92); color: #fff;
572
+ }
573
+ .stage-card .card-actions button[data-action="audition"].loading {
574
+ background: rgba(0,0,0,0.45); color: #fff; cursor: progress;
575
+ }
576
+ .stage-card .card-actions button[data-action="audition"].error {
577
+ background: rgba(239,68,68,0.92); color: #fff;
578
+ }
579
+ .audition-status {
580
+ display: inline-flex; align-items: center;
581
+ font-size: 11px; color: var(--muted);
582
+ padding: 4px 10px; border-radius: 6px;
583
+ max-width: 320px;
584
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
585
+ }
586
+ .audition-status[data-state="loading"] { color: var(--accent); }
587
+ .audition-status[data-state="cache"] {
588
+ color: var(--good); background: rgba(34,197,94,0.08);
589
+ }
590
+ .audition-status[data-state="error"] {
591
+ color: #b91c1c; background: rgba(239,68,68,0.08);
592
+ }
435
593
 
436
594
  .selection-fab {
437
595
  position: fixed; z-index: 50;
@@ -600,9 +758,17 @@ function renderSliceStageHtml({ sourcePath, port }) {
600
758
  <span class="dot" id="dot"></span>
601
759
  <span id="status-label">connecting…</span>
602
760
  </div>
603
- <button class="render-btn" id="render-btn" type="button" aria-label="Render this deck to mp4 (cloud)">
604
- <span class="icon">▶</span>
605
- <span id="render-btn-label">Render mp4</span>
761
+ <span class="render-local-progress hidden" id="render-local-progress" aria-live="polite">
762
+ <span class="bar"><span id="render-local-progress-fill" style="width:0%"></span></span>
763
+ <span id="render-local-progress-label">0%</span>
764
+ </span>
765
+ <button class="render-btn" id="render-local-btn" type="button" aria-label="Render this deck to mp4 locally (no cloud, no quota)" title="Render locally with @remotion/renderer — no network, no quota">
766
+ <span class="icon">⬇</span>
767
+ <span id="render-local-btn-label">Render mp4 (local)</span>
768
+ </button>
769
+ <button class="render-btn secondary" id="render-btn" type="button" aria-label="Render this deck to mp4 (cloud)" title="Submit to cloud renderer — costs 200 quota">
770
+ <span class="icon">☁</span>
771
+ <span id="render-btn-label">Render mp4 (cloud)</span>
606
772
  </button>
607
773
  <button class="versions-btn" id="versions-btn" type="button" aria-label="Show version history" aria-expanded="false">
608
774
  <span class="icon">📚</span>
@@ -642,7 +808,14 @@ function renderSliceStageHtml({ sourcePath, port }) {
642
808
  <button id="copy-json-btn" type="button" disabled title="Copy raw deck.json to clipboard">Copy JSON</button>
643
809
  <button id="download-json-btn" type="button" disabled title="Save deck.json to disk">Download .json</button>
644
810
  <button id="copy-md-btn" type="button" disabled title="Copy as Markdown — paste into Notion / blog / 飞书">Copy as Markdown</button>
811
+ <span class="toolbar-divider" aria-hidden="true"></span>
812
+ <label class="voice-picker" title="Voice ID override (empty = let card.voiceover.voiceId or default win)">
813
+ <span class="voice-picker-icon" aria-hidden="true">♪</span>
814
+ <input id="voice-picker-input" type="text" placeholder="Voice (default)" spellcheck="false" autocomplete="off" aria-label="Voice override for audition" />
815
+ </label>
816
+ <span class="audition-status" id="audition-status" hidden aria-live="polite"></span>
645
817
  </div>
818
+ <audio id="audition-audio" preload="none"></audio>
646
819
  <div id="cards-pane" class="empty">Waiting for deck…</div>
647
820
  </section>
648
821
  <section>
@@ -662,6 +835,21 @@ function renderSliceStageHtml({ sourcePath, port }) {
662
835
  <span>✏</span><span>Edit selection with AI</span>
663
836
  </button>
664
837
 
838
+ <div class="local-render-toast" id="local-render-toast" role="status" aria-live="polite">
839
+ <div class="toast-head">
840
+ <span>✓</span>
841
+ <span>Local render complete</span>
842
+ <span class="grow"></span>
843
+ <button class="close" id="local-render-toast-close" type="button" aria-label="Close">×</button>
844
+ </div>
845
+ <div class="path" id="local-render-toast-path"></div>
846
+ <div class="actions">
847
+ <a id="local-render-toast-open" href="#" target="_blank" rel="noopener">Open mp4</a>
848
+ <a id="local-render-toast-reveal" href="#">Open folder</a>
849
+ <button id="local-render-toast-copy" type="button">Copy path</button>
850
+ </div>
851
+ </div>
852
+
665
853
  <div class="modal-backdrop" id="render-modal" role="dialog" aria-modal="true" aria-labelledby="render-modal-title" hidden>
666
854
  <div class="modal">
667
855
  <div class="modal-header">
@@ -880,6 +1068,123 @@ function renderSliceStageHtml({ sourcePath, port }) {
880
1068
  copyTextToClipboard(formatCardAsText(card), btn);
881
1069
  });
882
1070
 
1071
+ // ─── Per-card Audition (▶) — fetch /api/audition, play in <audio>. ────
1072
+ // Voice override comes from the toolbar voice-picker input; empty input
1073
+ // means let the server bridge resolve via the documented precedence
1074
+ // (card.voiceover.voiceId → card.voiceId → SYNTHESIZE_DEFAULTS.voice).
1075
+ // Status bar surfaces loading / cache HIT / upstream error so the user
1076
+ // can spot quota or auth issues without opening browser devtools.
1077
+ var auditionAudio = document.getElementById('audition-audio');
1078
+ var auditionStatus = document.getElementById('audition-status');
1079
+ var voicePickerInput = document.getElementById('voice-picker-input');
1080
+ var currentAuditionBtn = null;
1081
+
1082
+ function setAuditionStatus(state, message) {
1083
+ if (!auditionStatus) return;
1084
+ if (!state) {
1085
+ auditionStatus.hidden = true;
1086
+ auditionStatus.removeAttribute('data-state');
1087
+ auditionStatus.textContent = '';
1088
+ return;
1089
+ }
1090
+ auditionStatus.hidden = false;
1091
+ auditionStatus.dataset.state = state;
1092
+ auditionStatus.textContent = message || '';
1093
+ }
1094
+
1095
+ function resetAuditionBtn(btn) {
1096
+ if (!btn) return;
1097
+ btn.classList.remove('playing', 'loading', 'error');
1098
+ var icon = btn.querySelector('.audition-icon');
1099
+ if (icon) icon.textContent = '▶';
1100
+ }
1101
+
1102
+ cardsPane.addEventListener('click', function (ev) {
1103
+ var btn = ev.target.closest && ev.target.closest('[data-action="audition"]');
1104
+ if (!btn || !currentDeck) return;
1105
+ var idx = parseInt(btn.getAttribute('data-card-index'), 10);
1106
+ if (!Number.isFinite(idx)) return;
1107
+
1108
+ // Click on the currently playing button = stop + reset.
1109
+ if (currentAuditionBtn === btn && !auditionAudio.paused) {
1110
+ auditionAudio.pause();
1111
+ auditionAudio.currentTime = 0;
1112
+ resetAuditionBtn(btn);
1113
+ currentAuditionBtn = null;
1114
+ setAuditionStatus(null);
1115
+ return;
1116
+ }
1117
+ // Reset a previously playing button (if any) when starting a new one.
1118
+ if (currentAuditionBtn && currentAuditionBtn !== btn) {
1119
+ resetAuditionBtn(currentAuditionBtn);
1120
+ }
1121
+
1122
+ currentAuditionBtn = btn;
1123
+ btn.classList.remove('error');
1124
+ btn.classList.add('loading');
1125
+ var icon = btn.querySelector('.audition-icon');
1126
+ if (icon) icon.textContent = '⟳';
1127
+ setAuditionStatus('loading', 'Synthesizing card ' + (idx + 1) + '…');
1128
+
1129
+ var voiceOverride = (voicePickerInput && voicePickerInput.value.trim()) || '';
1130
+ var url = '/api/audition?card=' + encodeURIComponent(idx)
1131
+ + (voiceOverride ? '&voice=' + encodeURIComponent(voiceOverride) : '');
1132
+
1133
+ fetch(url, { method: 'GET', credentials: 'same-origin' })
1134
+ .then(function (res) {
1135
+ var cache = res.headers.get('X-Audition-Cache') || '';
1136
+ if (!res.ok) {
1137
+ return res.json().then(function (j) {
1138
+ throw new Error((j && j.message) || ('HTTP ' + res.status));
1139
+ }, function () {
1140
+ throw new Error('HTTP ' + res.status);
1141
+ });
1142
+ }
1143
+ return res.blob().then(function (blob) { return { blob: blob, cache: cache }; });
1144
+ })
1145
+ .then(function (out) {
1146
+ var objectUrl = URL.createObjectURL(out.blob);
1147
+ auditionAudio.src = objectUrl;
1148
+ return auditionAudio.play().then(function () {
1149
+ btn.classList.remove('loading');
1150
+ btn.classList.add('playing');
1151
+ if (icon) icon.textContent = '❚❚';
1152
+ setAuditionStatus(
1153
+ out.cache === 'HIT' ? 'cache' : 'loading',
1154
+ out.cache === 'HIT' ? 'Cache hit — no quota used.' : 'Playing card ' + (idx + 1) + '.'
1155
+ );
1156
+ });
1157
+ })
1158
+ .catch(function (err) {
1159
+ btn.classList.remove('loading', 'playing');
1160
+ btn.classList.add('error');
1161
+ if (icon) icon.textContent = '!';
1162
+ setAuditionStatus('error', String(err && err.message || err));
1163
+ setTimeout(function () {
1164
+ if (currentAuditionBtn === btn) currentAuditionBtn = null;
1165
+ resetAuditionBtn(btn);
1166
+ }, 3000);
1167
+ });
1168
+ });
1169
+
1170
+ auditionAudio.addEventListener('ended', function () {
1171
+ if (currentAuditionBtn) {
1172
+ resetAuditionBtn(currentAuditionBtn);
1173
+ currentAuditionBtn = null;
1174
+ }
1175
+ setAuditionStatus(null);
1176
+ });
1177
+ auditionAudio.addEventListener('error', function () {
1178
+ if (currentAuditionBtn) {
1179
+ currentAuditionBtn.classList.remove('loading', 'playing');
1180
+ currentAuditionBtn.classList.add('error');
1181
+ var ic = currentAuditionBtn.querySelector('.audition-icon');
1182
+ if (ic) ic.textContent = '!';
1183
+ currentAuditionBtn = null;
1184
+ }
1185
+ setAuditionStatus('error', 'Audio playback failed.');
1186
+ });
1187
+
883
1188
  // For highlighting cards that just changed on hot-reload, we keep a
884
1189
  // hash of each card's stringified JSON. On the next deck event we
885
1190
  // diff per-index and add the just-changed CSS class to whichever
@@ -924,16 +1229,34 @@ function renderSliceStageHtml({ sourcePath, port }) {
924
1229
  if (Array.isArray(card.title)) titleArr = card.title;
925
1230
  else if (typeof card.title === 'string') titleArr = [card.title];
926
1231
  else titleArr = ['(card ' + (i + 1) + ')'];
1232
+ // Each title line is independently editable — saving rewrites the
1233
+ // entire title[] array, so a click on line 0 mutates only that
1234
+ // index and leaves the rest as-is.
927
1235
  titleHtml = '<div class="title-text">'
928
- + titleArr.map(function (line) {
929
- return '<span class="line">' + escapeHtml(line) + '</span>';
1236
+ + titleArr.map(function (line, lineIdx) {
1237
+ return '<span class="line editable" data-edit-field="title" data-edit-index="'
1238
+ + lineIdx + '" title="Click to edit, Esc to cancel, Cmd/Ctrl+Enter to save">'
1239
+ + escapeHtml(line) + '</span>';
930
1240
  }).join('')
931
1241
  + '</div>';
932
1242
  } else {
933
1243
  var caption = typeof card.caption === 'string' ? card.caption : ('Card ' + (i + 1));
934
1244
  var narration = typeof card.narration === 'string' ? card.narration : '';
935
- titleHtml = '<div class="caption">' + escapeHtml(caption) + '</div>'
936
- + (narration ? '<div class="narration">' + escapeHtml(narration) + '</div>' : '');
1245
+ // body cards may have caption + narration; only caption is editable
1246
+ // here when caption exists (some cards lack caption — quote/data/list).
1247
+ // Narration is editable on every non-title card.
1248
+ var captionHtml = (typeof card.caption === 'string')
1249
+ ? '<div class="caption editable" data-edit-field="caption" title="Click to edit">'
1250
+ + escapeHtml(caption) + '</div>'
1251
+ : '<div class="caption">' + escapeHtml(caption) + '</div>';
1252
+ var narrationHtml = narration
1253
+ ? '<div class="narration editable" data-edit-field="narration" title="Click to edit narration">'
1254
+ + escapeHtml(narration) + '</div>'
1255
+ : (kind === 'body'
1256
+ ? '<div class="narration editable" data-edit-field="narration" data-empty="1"'
1257
+ + ' title="Click to add narration" style="opacity:0.4">+ narration</div>'
1258
+ : '');
1259
+ titleHtml = captionHtml + narrationHtml;
937
1260
  }
938
1261
 
939
1262
  var figureChip = card.figureKeyword
@@ -961,6 +1284,10 @@ function renderSliceStageHtml({ sourcePath, port }) {
961
1284
  + '<div class="body">' + titleHtml + '</div>'
962
1285
  + '<div class="accent-bar"></div>'
963
1286
  + '<div class="card-actions">'
1287
+ + '<button type="button" data-action="audition" data-card-index="' + i + '"'
1288
+ + ' aria-label="Audition card ' + (i + 1) + ' voiceover" title="Play TTS preview — uses card.voiceover or card.narration, costs 100 quota first time then cached">'
1289
+ + '<span class="audition-icon" aria-hidden="true">▶</span>Audition'
1290
+ + '</button>'
964
1291
  + '<button type="button" data-action="copy-card" data-card-index="' + i + '"'
965
1292
  + ' aria-label="Copy card ' + (i + 1) + ' as text">Copy text</button>'
966
1293
  + '</div>'
@@ -1099,6 +1426,40 @@ function renderSliceStageHtml({ sourcePath, port }) {
1099
1426
  es.addEventListener('open', function () {
1100
1427
  setStatus('good', 'live');
1101
1428
  });
1429
+
1430
+ // ─── Local-render progress fan-in (same SSE channel as deck) ──────
1431
+ es.addEventListener('render-local-progress', function (e) {
1432
+ try {
1433
+ var msg = JSON.parse(e.data);
1434
+ if (!msg || msg.jobId !== renderLocalJobId) return;
1435
+ var pct = (typeof msg.progress === 'number' ? msg.progress : 0) * 100;
1436
+ showLocalProgress(pct);
1437
+ if (renderLocalState !== 'rendering') {
1438
+ renderLocalState = 'rendering';
1439
+ refreshRenderLocalBtn();
1440
+ }
1441
+ } catch (_) { /* malformed — ignore */ }
1442
+ });
1443
+ es.addEventListener('render-local-done', function (e) {
1444
+ try {
1445
+ var msg = JSON.parse(e.data);
1446
+ if (!msg || msg.jobId !== renderLocalJobId) return;
1447
+ renderLocalState = 'completed';
1448
+ refreshRenderLocalBtn();
1449
+ hideLocalProgress();
1450
+ showToast(msg.outputPath);
1451
+ } catch (_) { /* ignore */ }
1452
+ });
1453
+ es.addEventListener('render-local-error', function (e) {
1454
+ try {
1455
+ var msg = JSON.parse(e.data);
1456
+ if (!msg || msg.jobId !== renderLocalJobId) return;
1457
+ renderLocalState = 'failed';
1458
+ refreshRenderLocalBtn();
1459
+ hideLocalProgress();
1460
+ alert('Local render failed: ' + (msg.message || 'unknown error'));
1461
+ } catch (_) { /* ignore */ }
1462
+ });
1102
1463
  }
1103
1464
  connect();
1104
1465
 
@@ -1461,6 +1822,298 @@ function renderSliceStageHtml({ sourcePath, port }) {
1461
1822
 
1462
1823
  refreshRenderBtn();
1463
1824
 
1825
+ // ─── Render to MP4 (local, @remotion/renderer) ──────────────────────
1826
+ // Mirrors the cloud renderer state machine but skips the JWT / quota
1827
+ // dance. Progress arrives on the deck SSE channel (server fans out
1828
+ // render-local-progress / render-local-done / render-local-error) so
1829
+ // we add listeners inside connect()/onmessage below — see the SSE
1830
+ // wiring further down for that fan-in.
1831
+ var renderLocalBtn = document.getElementById('render-local-btn');
1832
+ var renderLocalBtnLabel = document.getElementById('render-local-btn-label');
1833
+ var renderLocalProgress = document.getElementById('render-local-progress');
1834
+ var renderLocalProgressFill = document.getElementById('render-local-progress-fill');
1835
+ var renderLocalProgressLabel = document.getElementById('render-local-progress-label');
1836
+ var toast = document.getElementById('local-render-toast');
1837
+ var toastPath = document.getElementById('local-render-toast-path');
1838
+ var toastOpen = document.getElementById('local-render-toast-open');
1839
+ var toastReveal = document.getElementById('local-render-toast-reveal');
1840
+ var toastCopy = document.getElementById('local-render-toast-copy');
1841
+ var toastClose = document.getElementById('local-render-toast-close');
1842
+
1843
+ var renderLocalState = 'idle'; // idle | submitting | rendering | completed | failed
1844
+ var renderLocalJobId = null;
1845
+ var renderLocalOutput = null;
1846
+
1847
+ function refreshRenderLocalBtn() {
1848
+ renderLocalBtn.classList.remove('completed');
1849
+ if (renderLocalState === 'idle' || renderLocalState === 'failed') {
1850
+ renderLocalBtn.disabled = false;
1851
+ renderLocalBtnLabel.textContent = 'Render mp4 (local)';
1852
+ } else if (renderLocalState === 'submitting') {
1853
+ renderLocalBtn.disabled = true;
1854
+ renderLocalBtnLabel.textContent = 'Starting…';
1855
+ } else if (renderLocalState === 'rendering') {
1856
+ renderLocalBtn.disabled = true;
1857
+ renderLocalBtnLabel.textContent = 'Rendering…';
1858
+ } else if (renderLocalState === 'completed') {
1859
+ renderLocalBtn.disabled = false;
1860
+ renderLocalBtn.classList.add('completed');
1861
+ renderLocalBtnLabel.textContent = 'Re-render local';
1862
+ }
1863
+ }
1864
+ function showLocalProgress(pct) {
1865
+ renderLocalProgress.classList.remove('hidden');
1866
+ var clamped = Math.max(0, Math.min(100, pct));
1867
+ renderLocalProgressFill.style.width = clamped.toFixed(1) + '%';
1868
+ renderLocalProgressLabel.textContent = clamped.toFixed(0) + '%';
1869
+ }
1870
+ function hideLocalProgress() {
1871
+ renderLocalProgress.classList.add('hidden');
1872
+ renderLocalProgressFill.style.width = '0%';
1873
+ renderLocalProgressLabel.textContent = '0%';
1874
+ }
1875
+ function showToast(outputPath) {
1876
+ renderLocalOutput = outputPath;
1877
+ toastPath.textContent = outputPath;
1878
+ // file:// link only renders if the user copy-pastes — Chrome blocks
1879
+ // file:// navigation from http://. We still expose the URL via Copy
1880
+ // path, and the anchor itself is a no-op fallback.
1881
+ var fileUrl = 'file://' + outputPath;
1882
+ toastOpen.setAttribute('href', fileUrl);
1883
+ toastReveal.setAttribute('href', 'file://' + outputPath.replace(/[^/]+$/, ''));
1884
+ toast.classList.add('open');
1885
+ }
1886
+ function hideToast() { toast.classList.remove('open'); }
1887
+
1888
+ toastClose.addEventListener('click', hideToast);
1889
+ toastCopy.addEventListener('click', function () {
1890
+ if (!renderLocalOutput) return;
1891
+ copyTextToClipboard(renderLocalOutput, toastCopy);
1892
+ });
1893
+
1894
+ renderLocalBtn.addEventListener('click', function () {
1895
+ if (renderLocalState === 'rendering' || renderLocalState === 'submitting') return;
1896
+ if (!currentDeck || !Array.isArray(currentDeck.cards)) {
1897
+ alert('No deck loaded — fix the JSON parse error first.');
1898
+ return;
1899
+ }
1900
+ renderLocalState = 'submitting';
1901
+ refreshRenderLocalBtn();
1902
+ showLocalProgress(0);
1903
+ hideToast();
1904
+ fetch('/api/render-local', {
1905
+ method: 'POST',
1906
+ headers: { 'Content-Type': 'application/json' },
1907
+ credentials: 'omit',
1908
+ body: JSON.stringify({}), // server reads the live snapshot
1909
+ })
1910
+ .then(function (r) { return r.json().then(function (j) { return { status: r.status, json: j }; }); })
1911
+ .then(function (resp) {
1912
+ if (resp.status !== 200 || resp.json.code !== 'success') {
1913
+ renderLocalState = 'failed';
1914
+ refreshRenderLocalBtn();
1915
+ hideLocalProgress();
1916
+ alert('Local render failed: ' + (resp.json.message || ('HTTP ' + resp.status)));
1917
+ return;
1918
+ }
1919
+ renderLocalJobId = resp.json.jobId;
1920
+ renderLocalOutput = resp.json.outputPath;
1921
+ renderLocalState = 'rendering';
1922
+ refreshRenderLocalBtn();
1923
+ })
1924
+ .catch(function (err) {
1925
+ renderLocalState = 'failed';
1926
+ refreshRenderLocalBtn();
1927
+ hideLocalProgress();
1928
+ alert('Network error: ' + err.message);
1929
+ });
1930
+ });
1931
+
1932
+ // Auth-state probe — flips the visual emphasis between local and cloud
1933
+ // render. When the user is logged out (no cached JWT), the local
1934
+ // button stays primary and the cloud button gets the secondary style
1935
+ // (already the default in the HTML). Logged in: emphasis flips.
1936
+ fetch('/api/auth-state', { credentials: 'omit' })
1937
+ .then(function (r) { return r.ok ? r.json() : null; })
1938
+ .then(function (j) {
1939
+ if (!j) return;
1940
+ if (j.tokenAvailable) {
1941
+ // Cloud → primary, local → secondary
1942
+ renderBtn.classList.remove('secondary');
1943
+ renderLocalBtn.classList.add('secondary');
1944
+ }
1945
+ })
1946
+ .catch(function () { /* leave defaults; logged-out is the safer assumption */ });
1947
+
1948
+ refreshRenderLocalBtn();
1949
+
1950
+ // ─── Inline edit (caption / narration / title rows) ─────────────────
1951
+ // Conservative scope: only "safe" text fields are click-to-edit. theme,
1952
+ // figureKeyword, kind, and card add/delete still require external
1953
+ // editor (or AI-prompt flow above). Save trigger: blur or Cmd/Ctrl+
1954
+ // Enter. Cancel: Escape.
1955
+ var activeInlineEdit = null; // { node, originalText, cardIdx, field, lineIdx }
1956
+
1957
+ function cancelInlineEdit() {
1958
+ if (!activeInlineEdit) return;
1959
+ var ed = activeInlineEdit;
1960
+ activeInlineEdit = null;
1961
+ var ta = ed.node.querySelector && ed.node.querySelector('textarea.inline-edit');
1962
+ // Restore original text — we re-render the card label from
1963
+ // currentDeck so theme styles re-apply cleanly.
1964
+ ed.node.textContent = ed.originalText;
1965
+ if (ed.errNode && ed.errNode.parentNode) ed.errNode.parentNode.removeChild(ed.errNode);
1966
+ if (ed.hintNode && ed.hintNode.parentNode) ed.hintNode.parentNode.removeChild(ed.hintNode);
1967
+ }
1968
+
1969
+ function applyEditToDeck(deck, cardIdx, field, lineIdx, newValue) {
1970
+ // Returns a shallow-copied deck with the single field replaced.
1971
+ // Never mutates the original; the save endpoint validates the result.
1972
+ var copy = JSON.parse(JSON.stringify(deck));
1973
+ var card = copy.cards && copy.cards[cardIdx];
1974
+ if (!card) return null;
1975
+ if (field === 'caption') card.caption = newValue;
1976
+ else if (field === 'narration') {
1977
+ if (newValue === '') {
1978
+ delete card.narration;
1979
+ } else {
1980
+ card.narration = newValue;
1981
+ }
1982
+ }
1983
+ else if (field === 'title') {
1984
+ if (!Array.isArray(card.title)) card.title = [];
1985
+ card.title[lineIdx] = newValue;
1986
+ }
1987
+ return copy;
1988
+ }
1989
+
1990
+ function commitInlineEdit(newValue) {
1991
+ if (!activeInlineEdit) return;
1992
+ var ed = activeInlineEdit;
1993
+ if (newValue === ed.originalText) {
1994
+ cancelInlineEdit();
1995
+ return;
1996
+ }
1997
+ var updatedDeck = applyEditToDeck(currentDeck, ed.cardIdx, ed.field, ed.lineIdx, newValue);
1998
+ if (!updatedDeck) { cancelInlineEdit(); return; }
1999
+
2000
+ // Optimistically swap in the new text so the user sees it instantly;
2001
+ // server validation may still reject (rare — schema caps). If it
2002
+ // does, we restore originalText and surface the error inline.
2003
+ var node = ed.node;
2004
+ node.textContent = newValue;
2005
+ var ta = node.querySelector && node.querySelector('textarea.inline-edit');
2006
+ if (ta) ta.remove();
2007
+ if (ed.hintNode && ed.hintNode.parentNode) ed.hintNode.parentNode.removeChild(ed.hintNode);
2008
+ if (ed.errNode && ed.errNode.parentNode) ed.errNode.parentNode.removeChild(ed.errNode);
2009
+ node.style.opacity = '0.6';
2010
+
2011
+ var snapshot = ed;
2012
+ activeInlineEdit = null;
2013
+
2014
+ fetch('/api/deck', {
2015
+ method: 'POST',
2016
+ headers: { 'Content-Type': 'application/json' },
2017
+ credentials: 'omit',
2018
+ body: JSON.stringify({ deck: updatedDeck }),
2019
+ })
2020
+ .then(function (r) { return r.json().then(function (j) { return { status: r.status, json: j }; }); })
2021
+ .then(function (resp) {
2022
+ node.style.opacity = '';
2023
+ if (resp.status !== 200 || resp.json.code !== 'success') {
2024
+ // Restore + surface the validation error inline. The file
2025
+ // watcher will not have fired (we didn't write), so the page
2026
+ // text needs explicit revert.
2027
+ node.textContent = snapshot.originalText;
2028
+ var card = node.closest && node.closest('.stage-card');
2029
+ if (card) {
2030
+ var err = document.createElement('div');
2031
+ err.className = 'inline-edit-err';
2032
+ err.textContent = resp.json.message || 'Save rejected';
2033
+ card.appendChild(err);
2034
+ setTimeout(function () { if (err.parentNode) err.parentNode.removeChild(err); }, 3500);
2035
+ }
2036
+ return;
2037
+ }
2038
+ // Success — watcher will broadcast deck SSE, which re-renders
2039
+ // the card with the new value + diff highlight ring. Nothing
2040
+ // more to do here.
2041
+ })
2042
+ .catch(function (err) {
2043
+ node.style.opacity = '';
2044
+ node.textContent = snapshot.originalText;
2045
+ alert('Save failed: ' + err.message);
2046
+ });
2047
+ }
2048
+
2049
+ function startInlineEdit(node) {
2050
+ if (activeInlineEdit) return;
2051
+ var card = node.closest && node.closest('.stage-card');
2052
+ if (!card) return;
2053
+ var cardIdx = parseInt(card.getAttribute('data-card-idx'), 10);
2054
+ if (!Number.isFinite(cardIdx)) return;
2055
+ var field = node.getAttribute('data-edit-field');
2056
+ var lineIdx = parseInt(node.getAttribute('data-edit-index') || '0', 10);
2057
+ if (!Number.isFinite(lineIdx)) lineIdx = 0;
2058
+ var isEmpty = node.getAttribute('data-empty') === '1';
2059
+ var original = isEmpty ? '' : node.textContent;
2060
+ var ta = document.createElement('textarea');
2061
+ ta.className = 'inline-edit';
2062
+ ta.value = original;
2063
+ // Approximate visual metrics so the card doesn't reflow noticeably.
2064
+ var rect = node.getBoundingClientRect();
2065
+ ta.style.minHeight = Math.max(20, rect.height) + 'px';
2066
+ ta.rows = field === 'narration' ? 4 : 2;
2067
+ node.textContent = '';
2068
+ node.appendChild(ta);
2069
+ var hint = document.createElement('div');
2070
+ hint.className = 'inline-edit-hint';
2071
+ hint.textContent = 'Cmd/Ctrl+Enter: save · Esc: cancel · blur: save';
2072
+ card.appendChild(hint);
2073
+
2074
+ activeInlineEdit = {
2075
+ node: node,
2076
+ originalText: original,
2077
+ cardIdx: cardIdx,
2078
+ field: field,
2079
+ lineIdx: lineIdx,
2080
+ hintNode: hint,
2081
+ errNode: null,
2082
+ };
2083
+
2084
+ ta.focus();
2085
+ ta.setSelectionRange(0, ta.value.length);
2086
+
2087
+ ta.addEventListener('keydown', function (e) {
2088
+ if (e.key === 'Escape') {
2089
+ e.preventDefault();
2090
+ cancelInlineEdit();
2091
+ } else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
2092
+ e.preventDefault();
2093
+ commitInlineEdit(ta.value);
2094
+ }
2095
+ });
2096
+ ta.addEventListener('blur', function () {
2097
+ // Use rAF so escape-keydown (which calls cancelInlineEdit) wins the
2098
+ // race against blur. By the time rAF fires, activeInlineEdit will
2099
+ // be null if the user pressed Escape.
2100
+ requestAnimationFrame(function () {
2101
+ if (!activeInlineEdit || activeInlineEdit.node !== node) return;
2102
+ commitInlineEdit(ta.value);
2103
+ });
2104
+ });
2105
+ }
2106
+
2107
+ cardsPane.addEventListener('click', function (ev) {
2108
+ var target = ev.target.closest && ev.target.closest('.editable');
2109
+ if (!target) return;
2110
+ // Don't intercept clicks while an edit is already running on this
2111
+ // same node — the textarea inside catches its own events.
2112
+ if (target.querySelector && target.querySelector('textarea.inline-edit')) return;
2113
+ ev.stopPropagation();
2114
+ startInlineEdit(target);
2115
+ });
2116
+
1464
2117
  // ─── Edit-with-AI: prompt builder (mirrors stage-core/edit-prompt.js) ─
1465
2118
  // Kept as a small in-browser duplicate because the template is an
1466
2119
  // inline string — pulling the Node module across the boundary would