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.
- package/dist/index.js +1 -1
- package/lib/commands/slice-render.js +71 -7
- package/lib/commands/slice-stage.js +97 -0
- package/lib/internal/deck-validator.js +47 -0
- package/lib/stage-core/local-render.js +324 -0
- package/lib/stage-core/server.js +228 -2
- package/lib/stage-core/tts-audition.js +0 -0
- package/lib/stage-core/voiceover-mux.js +183 -0
- package/lib/stage-ui/slice/template.js +660 -7
- package/package.json +1 -1
- package/skills/voxflow-slice/SKILL.md +75 -2
|
@@ -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
|
-
<
|
|
604
|
-
<span class="
|
|
605
|
-
<span id="render-
|
|
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"
|
|
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
|
-
|
|
936
|
-
|
|
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
|