voxflow 1.15.3 → 1.15.5
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/dist/remotion-bundle/bundle.js +3 -0
- package/dist/remotion-bundle/bundle.js.map +1 -1
- package/dist/templates/data-finding/deck.json +40 -0
- package/dist/templates/founder-lesson/deck.json +37 -0
- package/dist/templates/incident-review/deck.json +37 -0
- package/dist/templates/manifest.json +45 -0
- package/dist/templates/product-launch/deck.json +37 -0
- package/dist/templates/quiet-essay/deck.json +37 -0
- package/lib/commands/slice-fork.js +151 -0
- package/lib/commands/slice-render.js +115 -8
- package/lib/commands/slice-stage.js +65 -0
- package/lib/commands/slice.js +9 -0
- package/lib/internal/deck-validator.js +150 -8
- package/lib/stage-core/image-gen.js +233 -0
- package/lib/stage-core/local-render.js +92 -1
- package/lib/stage-core/server.js +110 -2
- package/lib/stage-core/tts-audition.js +0 -0
- package/lib/stage-core/voiceover-mux.js +290 -0
- package/lib/stage-ui/slice/template.js +333 -0
- package/package.json +1 -1
- package/skills/voxflow-slice/SKILL.md +146 -2
- package/skills/voxflow-slice/templates/data-finding/deck.json +40 -0
- package/skills/voxflow-slice/templates/founder-lesson/deck.json +37 -0
- package/skills/voxflow-slice/templates/incident-review/deck.json +37 -0
- package/skills/voxflow-slice/templates/manifest.json +45 -0
- package/skills/voxflow-slice/templates/product-launch/deck.json +37 -0
- package/skills/voxflow-slice/templates/quiet-essay/deck.json +37 -0
|
@@ -547,6 +547,112 @@ function renderSliceStageHtml({ sourcePath, port }) {
|
|
|
547
547
|
.deck-toolbar button.copied {
|
|
548
548
|
color: var(--good); border-color: var(--good);
|
|
549
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
|
+
}
|
|
593
|
+
/* ─── Imagine (🎨) — per-card image gallery modal ─────────────────────── */
|
|
594
|
+
.stage-card .card-actions button[data-action="imagine"] .imagine-icon {
|
|
595
|
+
display: inline-block; margin-right: 4px; font-size: 10px;
|
|
596
|
+
}
|
|
597
|
+
.stage-card .card-actions button[data-action="imagine"] .imagine-count {
|
|
598
|
+
display: inline-block; margin-left: 4px;
|
|
599
|
+
padding: 1px 5px; border-radius: 8px;
|
|
600
|
+
background: rgba(255,255,255,0.25); font-size: 10px;
|
|
601
|
+
font-variant-numeric: tabular-nums;
|
|
602
|
+
}
|
|
603
|
+
.stage-card .card-actions button[data-action="imagine"][data-image-count="0"] {
|
|
604
|
+
opacity: 0.6;
|
|
605
|
+
}
|
|
606
|
+
.imagine-entry {
|
|
607
|
+
display: grid; gap: 10px;
|
|
608
|
+
padding: 14px 0; border-bottom: 1px solid var(--border);
|
|
609
|
+
}
|
|
610
|
+
.imagine-entry:last-child { border-bottom: 0; padding-bottom: 4px; }
|
|
611
|
+
.imagine-entry-meta {
|
|
612
|
+
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
|
613
|
+
}
|
|
614
|
+
.imagine-entry-id {
|
|
615
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
616
|
+
font-size: 11px; font-weight: 600;
|
|
617
|
+
padding: 2px 8px; border-radius: 4px;
|
|
618
|
+
background: rgba(88,81,184,0.12); color: var(--accent);
|
|
619
|
+
}
|
|
620
|
+
.imagine-entry-aspect {
|
|
621
|
+
font-size: 10px; color: var(--muted);
|
|
622
|
+
letter-spacing: 0.06em; text-transform: uppercase;
|
|
623
|
+
}
|
|
624
|
+
.imagine-entry-prompt {
|
|
625
|
+
font-size: 12px; color: var(--text);
|
|
626
|
+
flex: 1 1 100%; min-width: 0;
|
|
627
|
+
line-height: 1.4;
|
|
628
|
+
word-break: break-word;
|
|
629
|
+
}
|
|
630
|
+
.imagine-entry-img {
|
|
631
|
+
max-width: 100%; max-height: 280px;
|
|
632
|
+
border: 1px solid var(--border); border-radius: 8px;
|
|
633
|
+
display: block; background: var(--surface-2);
|
|
634
|
+
}
|
|
635
|
+
.imagine-entry-img.loading {
|
|
636
|
+
opacity: 0.5;
|
|
637
|
+
animation: imagine-pulse 1.4s ease-in-out infinite;
|
|
638
|
+
}
|
|
639
|
+
@keyframes imagine-pulse {
|
|
640
|
+
0%, 100% { opacity: 0.4; }
|
|
641
|
+
50% { opacity: 0.7; }
|
|
642
|
+
}
|
|
643
|
+
.imagine-entry-error {
|
|
644
|
+
color: #b91c1c; font-size: 12px;
|
|
645
|
+
padding: 12px; border-radius: 8px;
|
|
646
|
+
background: rgba(239,68,68,0.08);
|
|
647
|
+
}
|
|
648
|
+
.imagine-entry-empty {
|
|
649
|
+
padding: 32px 16px; text-align: center;
|
|
650
|
+
color: var(--muted); font-size: 13px;
|
|
651
|
+
}
|
|
652
|
+
.imagine-entry-empty code {
|
|
653
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
654
|
+
background: var(--surface-2); padding: 1px 5px; border-radius: 3px;
|
|
655
|
+
}
|
|
550
656
|
|
|
551
657
|
.selection-fab {
|
|
552
658
|
position: fixed; z-index: 50;
|
|
@@ -765,7 +871,14 @@ function renderSliceStageHtml({ sourcePath, port }) {
|
|
|
765
871
|
<button id="copy-json-btn" type="button" disabled title="Copy raw deck.json to clipboard">Copy JSON</button>
|
|
766
872
|
<button id="download-json-btn" type="button" disabled title="Save deck.json to disk">Download .json</button>
|
|
767
873
|
<button id="copy-md-btn" type="button" disabled title="Copy as Markdown — paste into Notion / blog / 飞书">Copy as Markdown</button>
|
|
874
|
+
<span class="toolbar-divider" aria-hidden="true"></span>
|
|
875
|
+
<label class="voice-picker" title="Voice ID override (empty = let card.voiceover.voiceId or default win)">
|
|
876
|
+
<span class="voice-picker-icon" aria-hidden="true">♪</span>
|
|
877
|
+
<input id="voice-picker-input" type="text" placeholder="Voice (default)" spellcheck="false" autocomplete="off" aria-label="Voice override for audition" />
|
|
878
|
+
</label>
|
|
879
|
+
<span class="audition-status" id="audition-status" hidden aria-live="polite"></span>
|
|
768
880
|
</div>
|
|
881
|
+
<audio id="audition-audio" preload="none"></audio>
|
|
769
882
|
<div id="cards-pane" class="empty">Waiting for deck…</div>
|
|
770
883
|
</section>
|
|
771
884
|
<section>
|
|
@@ -785,6 +898,17 @@ function renderSliceStageHtml({ sourcePath, port }) {
|
|
|
785
898
|
<span>✏</span><span>Edit selection with AI</span>
|
|
786
899
|
</button>
|
|
787
900
|
|
|
901
|
+
<div class="modal-backdrop" id="imagine-modal" role="dialog" aria-modal="true" aria-labelledby="imagine-modal-title" hidden>
|
|
902
|
+
<div class="modal">
|
|
903
|
+
<div class="modal-header">
|
|
904
|
+
<h3 id="imagine-modal-title">Card images</h3>
|
|
905
|
+
<div class="grow"></div>
|
|
906
|
+
<button class="modal-close" id="imagine-modal-close" type="button" aria-label="Close">×</button>
|
|
907
|
+
</div>
|
|
908
|
+
<div class="modal-body" id="imagine-modal-body"></div>
|
|
909
|
+
</div>
|
|
910
|
+
</div>
|
|
911
|
+
|
|
788
912
|
<div class="local-render-toast" id="local-render-toast" role="status" aria-live="polite">
|
|
789
913
|
<div class="toast-head">
|
|
790
914
|
<span>✓</span>
|
|
@@ -1018,6 +1142,204 @@ function renderSliceStageHtml({ sourcePath, port }) {
|
|
|
1018
1142
|
copyTextToClipboard(formatCardAsText(card), btn);
|
|
1019
1143
|
});
|
|
1020
1144
|
|
|
1145
|
+
// ─── Per-card Audition (▶) — fetch /api/audition, play in <audio>. ────
|
|
1146
|
+
// Voice override comes from the toolbar voice-picker input; empty input
|
|
1147
|
+
// means let the server bridge resolve via the documented precedence
|
|
1148
|
+
// (card.voiceover.voiceId → card.voiceId → SYNTHESIZE_DEFAULTS.voice).
|
|
1149
|
+
// Status bar surfaces loading / cache HIT / upstream error so the user
|
|
1150
|
+
// can spot quota or auth issues without opening browser devtools.
|
|
1151
|
+
var auditionAudio = document.getElementById('audition-audio');
|
|
1152
|
+
var auditionStatus = document.getElementById('audition-status');
|
|
1153
|
+
var voicePickerInput = document.getElementById('voice-picker-input');
|
|
1154
|
+
var currentAuditionBtn = null;
|
|
1155
|
+
|
|
1156
|
+
function setAuditionStatus(state, message) {
|
|
1157
|
+
if (!auditionStatus) return;
|
|
1158
|
+
if (!state) {
|
|
1159
|
+
auditionStatus.hidden = true;
|
|
1160
|
+
auditionStatus.removeAttribute('data-state');
|
|
1161
|
+
auditionStatus.textContent = '';
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
auditionStatus.hidden = false;
|
|
1165
|
+
auditionStatus.dataset.state = state;
|
|
1166
|
+
auditionStatus.textContent = message || '';
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function resetAuditionBtn(btn) {
|
|
1170
|
+
if (!btn) return;
|
|
1171
|
+
btn.classList.remove('playing', 'loading', 'error');
|
|
1172
|
+
var icon = btn.querySelector('.audition-icon');
|
|
1173
|
+
if (icon) icon.textContent = '▶';
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
cardsPane.addEventListener('click', function (ev) {
|
|
1177
|
+
var btn = ev.target.closest && ev.target.closest('[data-action="audition"]');
|
|
1178
|
+
if (!btn || !currentDeck) return;
|
|
1179
|
+
var idx = parseInt(btn.getAttribute('data-card-index'), 10);
|
|
1180
|
+
if (!Number.isFinite(idx)) return;
|
|
1181
|
+
|
|
1182
|
+
// Click on the currently playing button = stop + reset.
|
|
1183
|
+
if (currentAuditionBtn === btn && !auditionAudio.paused) {
|
|
1184
|
+
auditionAudio.pause();
|
|
1185
|
+
auditionAudio.currentTime = 0;
|
|
1186
|
+
resetAuditionBtn(btn);
|
|
1187
|
+
currentAuditionBtn = null;
|
|
1188
|
+
setAuditionStatus(null);
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
// Reset a previously playing button (if any) when starting a new one.
|
|
1192
|
+
if (currentAuditionBtn && currentAuditionBtn !== btn) {
|
|
1193
|
+
resetAuditionBtn(currentAuditionBtn);
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
currentAuditionBtn = btn;
|
|
1197
|
+
btn.classList.remove('error');
|
|
1198
|
+
btn.classList.add('loading');
|
|
1199
|
+
var icon = btn.querySelector('.audition-icon');
|
|
1200
|
+
if (icon) icon.textContent = '⟳';
|
|
1201
|
+
setAuditionStatus('loading', 'Synthesizing card ' + (idx + 1) + '…');
|
|
1202
|
+
|
|
1203
|
+
var voiceOverride = (voicePickerInput && voicePickerInput.value.trim()) || '';
|
|
1204
|
+
var url = '/api/audition?card=' + encodeURIComponent(idx)
|
|
1205
|
+
+ (voiceOverride ? '&voice=' + encodeURIComponent(voiceOverride) : '');
|
|
1206
|
+
|
|
1207
|
+
fetch(url, { method: 'GET', credentials: 'same-origin' })
|
|
1208
|
+
.then(function (res) {
|
|
1209
|
+
var cache = res.headers.get('X-Audition-Cache') || '';
|
|
1210
|
+
if (!res.ok) {
|
|
1211
|
+
return res.json().then(function (j) {
|
|
1212
|
+
throw new Error((j && j.message) || ('HTTP ' + res.status));
|
|
1213
|
+
}, function () {
|
|
1214
|
+
throw new Error('HTTP ' + res.status);
|
|
1215
|
+
});
|
|
1216
|
+
}
|
|
1217
|
+
return res.blob().then(function (blob) { return { blob: blob, cache: cache }; });
|
|
1218
|
+
})
|
|
1219
|
+
.then(function (out) {
|
|
1220
|
+
var objectUrl = URL.createObjectURL(out.blob);
|
|
1221
|
+
auditionAudio.src = objectUrl;
|
|
1222
|
+
return auditionAudio.play().then(function () {
|
|
1223
|
+
btn.classList.remove('loading');
|
|
1224
|
+
btn.classList.add('playing');
|
|
1225
|
+
if (icon) icon.textContent = '❚❚';
|
|
1226
|
+
setAuditionStatus(
|
|
1227
|
+
out.cache === 'HIT' ? 'cache' : 'loading',
|
|
1228
|
+
out.cache === 'HIT' ? 'Cache hit — no quota used.' : 'Playing card ' + (idx + 1) + '.'
|
|
1229
|
+
);
|
|
1230
|
+
});
|
|
1231
|
+
})
|
|
1232
|
+
.catch(function (err) {
|
|
1233
|
+
btn.classList.remove('loading', 'playing');
|
|
1234
|
+
btn.classList.add('error');
|
|
1235
|
+
if (icon) icon.textContent = '!';
|
|
1236
|
+
setAuditionStatus('error', String(err && err.message || err));
|
|
1237
|
+
setTimeout(function () {
|
|
1238
|
+
if (currentAuditionBtn === btn) currentAuditionBtn = null;
|
|
1239
|
+
resetAuditionBtn(btn);
|
|
1240
|
+
}, 3000);
|
|
1241
|
+
});
|
|
1242
|
+
});
|
|
1243
|
+
|
|
1244
|
+
auditionAudio.addEventListener('ended', function () {
|
|
1245
|
+
if (currentAuditionBtn) {
|
|
1246
|
+
resetAuditionBtn(currentAuditionBtn);
|
|
1247
|
+
currentAuditionBtn = null;
|
|
1248
|
+
}
|
|
1249
|
+
setAuditionStatus(null);
|
|
1250
|
+
});
|
|
1251
|
+
auditionAudio.addEventListener('error', function () {
|
|
1252
|
+
if (currentAuditionBtn) {
|
|
1253
|
+
currentAuditionBtn.classList.remove('loading', 'playing');
|
|
1254
|
+
currentAuditionBtn.classList.add('error');
|
|
1255
|
+
var ic = currentAuditionBtn.querySelector('.audition-icon');
|
|
1256
|
+
if (ic) ic.textContent = '!';
|
|
1257
|
+
currentAuditionBtn = null;
|
|
1258
|
+
}
|
|
1259
|
+
setAuditionStatus('error', 'Audio playback failed.');
|
|
1260
|
+
});
|
|
1261
|
+
|
|
1262
|
+
// ─── Per-card Imagine (🎨) — open modal listing registered card.images. ─
|
|
1263
|
+
// Click loads each image via /api/imagine which proxies hunyuan-image
|
|
1264
|
+
// and caches by (prompt, aspect, quality) hash. <img> error handler
|
|
1265
|
+
// surfaces backend errors (auth / quota / failed gen) inline.
|
|
1266
|
+
var imagineModal = document.getElementById('imagine-modal');
|
|
1267
|
+
var imagineModalBody = document.getElementById('imagine-modal-body');
|
|
1268
|
+
var imagineModalClose = document.getElementById('imagine-modal-close');
|
|
1269
|
+
|
|
1270
|
+
function closeImagineModal() {
|
|
1271
|
+
if (imagineModal) imagineModal.hidden = true;
|
|
1272
|
+
if (imagineModalBody) imagineModalBody.innerHTML = '';
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
cardsPane.addEventListener('click', function (ev) {
|
|
1276
|
+
var btn = ev.target.closest && ev.target.closest('[data-action="imagine"]');
|
|
1277
|
+
if (!btn || !currentDeck) return;
|
|
1278
|
+
var idx = parseInt(btn.getAttribute('data-card-index'), 10);
|
|
1279
|
+
if (!Number.isFinite(idx)) return;
|
|
1280
|
+
var cards = Array.isArray(currentDeck.cards) ? currentDeck.cards : [];
|
|
1281
|
+
var card = cards[idx];
|
|
1282
|
+
if (!card) return;
|
|
1283
|
+
openImagineModal(idx, card);
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1286
|
+
function openImagineModal(cardIdx, card) {
|
|
1287
|
+
if (!imagineModal || !imagineModalBody) return;
|
|
1288
|
+
var images = Array.isArray(card.images) ? card.images : [];
|
|
1289
|
+
if (images.length === 0) {
|
|
1290
|
+
imagineModalBody.innerHTML =
|
|
1291
|
+
'<div class="imagine-entry-empty">' +
|
|
1292
|
+
'Card #' + (cardIdx + 1) + ' has no images registered.<br/>' +
|
|
1293
|
+
'Add an entry to <code>cards[' + cardIdx + '].images: [{ id, prompt, aspect, quality }]</code> in deck.json.' +
|
|
1294
|
+
'</div>';
|
|
1295
|
+
} else {
|
|
1296
|
+
imagineModalBody.innerHTML = images.map(function (img) {
|
|
1297
|
+
if (!img || typeof img !== 'object') return '';
|
|
1298
|
+
var src = '/api/imagine?card=' + encodeURIComponent(cardIdx) +
|
|
1299
|
+
'&img=' + encodeURIComponent(img.id || '');
|
|
1300
|
+
var aspectStr = img.aspect ? ' ' + escapeHtml(img.aspect) : '';
|
|
1301
|
+
var qualityStr = img.quality ? ' · ' + escapeHtml(img.quality) : '';
|
|
1302
|
+
return '<div class="imagine-entry">' +
|
|
1303
|
+
'<div class="imagine-entry-meta">' +
|
|
1304
|
+
'<span class="imagine-entry-id">#' + escapeHtml(img.id || '?') + '</span>' +
|
|
1305
|
+
(aspectStr || qualityStr ? '<span class="imagine-entry-aspect">' + escapeHtml(aspectStr) + qualityStr + '</span>' : '') +
|
|
1306
|
+
'</div>' +
|
|
1307
|
+
'<div class="imagine-entry-prompt">' + escapeHtml(img.prompt || '') + '</div>' +
|
|
1308
|
+
'<img class="imagine-entry-img loading" alt="image for ' + escapeHtml(img.id || '') + '" loading="lazy" data-src="' + src + '" />' +
|
|
1309
|
+
'</div>';
|
|
1310
|
+
}).join('');
|
|
1311
|
+
// Wire up each <img> after insertion. We do it programmatically to
|
|
1312
|
+
// attach load / error handlers without inline JS in the HTML
|
|
1313
|
+
// (safer with the page-side escapeHtml + interpolation).
|
|
1314
|
+
var imgs = imagineModalBody.querySelectorAll('img.imagine-entry-img');
|
|
1315
|
+
imgs.forEach(function (el) {
|
|
1316
|
+
var src = el.getAttribute('data-src');
|
|
1317
|
+
el.addEventListener('load', function () { el.classList.remove('loading'); });
|
|
1318
|
+
el.addEventListener('error', function () {
|
|
1319
|
+
var msg = document.createElement('div');
|
|
1320
|
+
msg.className = 'imagine-entry-error';
|
|
1321
|
+
msg.textContent = 'Failed to load — check auth (voxflow login), quota, or prompt content.';
|
|
1322
|
+
el.replaceWith(msg);
|
|
1323
|
+
});
|
|
1324
|
+
el.src = src;
|
|
1325
|
+
});
|
|
1326
|
+
}
|
|
1327
|
+
imagineModal.hidden = false;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
if (imagineModalClose) {
|
|
1331
|
+
imagineModalClose.addEventListener('click', closeImagineModal);
|
|
1332
|
+
}
|
|
1333
|
+
if (imagineModal) {
|
|
1334
|
+
imagineModal.addEventListener('click', function (e) {
|
|
1335
|
+
// Click on backdrop (the modal-backdrop element itself, not its children) closes.
|
|
1336
|
+
if (e.target === imagineModal) closeImagineModal();
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
1339
|
+
document.addEventListener('keydown', function (e) {
|
|
1340
|
+
if (e.key === 'Escape' && imagineModal && !imagineModal.hidden) closeImagineModal();
|
|
1341
|
+
});
|
|
1342
|
+
|
|
1021
1343
|
// For highlighting cards that just changed on hot-reload, we keep a
|
|
1022
1344
|
// hash of each card's stringified JSON. On the next deck event we
|
|
1023
1345
|
// diff per-index and add the just-changed CSS class to whichever
|
|
@@ -1117,6 +1439,17 @@ function renderSliceStageHtml({ sourcePath, port }) {
|
|
|
1117
1439
|
+ '<div class="body">' + titleHtml + '</div>'
|
|
1118
1440
|
+ '<div class="accent-bar"></div>'
|
|
1119
1441
|
+ '<div class="card-actions">'
|
|
1442
|
+
+ '<button type="button" data-action="audition" data-card-index="' + i + '"'
|
|
1443
|
+
+ ' aria-label="Audition card ' + (i + 1) + ' voiceover" title="Play TTS preview — uses card.voiceover or card.narration, costs 100 quota first time then cached">'
|
|
1444
|
+
+ '<span class="audition-icon" aria-hidden="true">▶</span>Audition'
|
|
1445
|
+
+ '</button>'
|
|
1446
|
+
+ '<button type="button" data-action="imagine" data-card-index="' + i + '"'
|
|
1447
|
+
+ ' aria-label="Show generated images for card ' + (i + 1) + '"'
|
|
1448
|
+
+ ' title="View AI images registered on card.images — 200-500 quota first time per (prompt, aspect, quality), cached after"'
|
|
1449
|
+
+ ' data-image-count="' + (Array.isArray(card.images) ? card.images.length : 0) + '">'
|
|
1450
|
+
+ '<span class="imagine-icon" aria-hidden="true">🎨</span>Imagine'
|
|
1451
|
+
+ (Array.isArray(card.images) && card.images.length > 0 ? ' <span class="imagine-count">' + card.images.length + '</span>' : '')
|
|
1452
|
+
+ '</button>'
|
|
1120
1453
|
+ '<button type="button" data-action="copy-card" data-card-index="' + i + '"'
|
|
1121
1454
|
+ ' aria-label="Copy card ' + (i + 1) + ' as text">Copy text</button>'
|
|
1122
1455
|
+ '</div>'
|
package/package.json
CHANGED
|
@@ -145,6 +145,85 @@ All cards require a non-empty `narration` string (TTS reads this; 30–60 zh cha
|
|
|
145
145
|
}
|
|
146
146
|
```
|
|
147
147
|
|
|
148
|
+
### Optional per-card `images` registry (Phase B)
|
|
149
|
+
|
|
150
|
+
Any card kind may carry an `images: [{ id, prompt, aspect?, quality? }]`
|
|
151
|
+
array of AI-generation recipes. The stage UI's 🎨 button resolves each
|
|
152
|
+
entry through `/api/imagine` (proxies hunyuan-image, content-hash cached
|
|
153
|
+
at `~/.config/voxflow/stage-image-cache/`); the first entry's resolved
|
|
154
|
+
URL is used as the card's `slide.imageUrl` at render time, overriding any
|
|
155
|
+
external `card.imageUrl`.
|
|
156
|
+
|
|
157
|
+
```jsonc
|
|
158
|
+
{
|
|
159
|
+
"kind": "body",
|
|
160
|
+
"caption": "...",
|
|
161
|
+
"narration": "...",
|
|
162
|
+
"figureKeyword": "growth-system",
|
|
163
|
+
"images": [
|
|
164
|
+
{
|
|
165
|
+
"id": "hero", // stable id ([a-zA-Z0-9_-]+, ≤64 chars, unique on card)
|
|
166
|
+
"prompt": "晨雾中的山脉,水墨风", // ≤1000 chars
|
|
167
|
+
"aspect": "portrait", // portrait | landscape | square (default: portrait)
|
|
168
|
+
"quality": "fast" // fast (200 quota) | hd (500 quota)
|
|
169
|
+
}
|
|
170
|
+
]
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Validator caps: `prompt` ≤ 1000 chars, `id` ≤ 64 chars matching `[a-zA-Z0-9_-]+`,
|
|
175
|
+
at most 8 images per card, unique `id` within a card.
|
|
176
|
+
|
|
177
|
+
### Optional per-card `el: "raw-html"` element (Phase C)
|
|
178
|
+
|
|
179
|
+
V2 LayoutTree decks may carry a `{ el: "raw-html", html: "..." }` child to
|
|
180
|
+
escape into arbitrary markup. The validator accepts strings up to 4096
|
|
181
|
+
chars and `normalizeV2Children` maps the first occurrence to a `rawHtml`
|
|
182
|
+
field on the V1 normalized output. **PaperSlide composition rendering of
|
|
183
|
+
arbitrary HTML lands in a follow-up PR** — for now the composition
|
|
184
|
+
silently skips the element, so a deck with raw-html validates + saves +
|
|
185
|
+
edits cleanly but renders blank visually until the JSX side is updated.
|
|
186
|
+
|
|
187
|
+
```jsonc
|
|
188
|
+
{
|
|
189
|
+
"kind": "body",
|
|
190
|
+
"narration": "...",
|
|
191
|
+
"children": [
|
|
192
|
+
{ "el": "heading", "text": "Custom panel" },
|
|
193
|
+
{ "el": "raw-html", "html": "<div style='font-size:48px'>★</div>" }
|
|
194
|
+
]
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
On a body card, exactly one of `paper-figure` OR `raw-html` is required
|
|
199
|
+
(both is rejected). Other kinds (title / quote / data / list) allow
|
|
200
|
+
`raw-html` as a supplementary element.
|
|
201
|
+
|
|
202
|
+
### Optional per-card `voiceover` override
|
|
203
|
+
|
|
204
|
+
Any card kind may carry a nested `voiceover` object to tune its audio
|
|
205
|
+
track. All four sub-fields are optional inside an optional object —
|
|
206
|
+
absent ⇒ the renderer uses the job-level default voice with
|
|
207
|
+
`card.narration` at 1× speed (back-compat with Phase 0 silent decks).
|
|
208
|
+
|
|
209
|
+
```jsonc
|
|
210
|
+
{
|
|
211
|
+
"kind": "body",
|
|
212
|
+
"caption": "短字幕",
|
|
213
|
+
"narration": "默认是 TTS 朗读的文本",
|
|
214
|
+
"voiceover": {
|
|
215
|
+
"enabled": true, // false → this card is silent in the mp4
|
|
216
|
+
"voiceId": "v-female-R2s4N9qJ", // overrides the job-level default voice
|
|
217
|
+
"text": "口播稿可以跟字幕不一样", // overrides narration for TTS only (visible caption unaffected)
|
|
218
|
+
"rate": 1.1 // [0.5, 2.0], default 1.0
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Validator caps: `voiceover.text` ≤ 500 chars; `rate ∈ [0.5, 2.0]`.
|
|
224
|
+
Render-time resolution precedence:
|
|
225
|
+
`voiceover.voiceId → card.voiceId → job-level default`.
|
|
226
|
+
|
|
148
227
|
> **Source of truth**: `backend/services/paper-slide/deck-validator.js` — caps live at lines 39–46 (QUOTE_TEXT_MAX, DATA_VALUE_MAX, LIST_ITEM_MAX_LEN, etc.). Read it if anything below seems ambiguous.
|
|
149
228
|
|
|
150
229
|
### Controlled `figureKeyword` list
|
|
@@ -225,6 +304,21 @@ These are the most common failure modes. The validator surfaces a clean error, b
|
|
|
225
304
|
4. **`figureKeyword`** has no validator check on the keyword string itself (only that it's a string if present) — but **unknown keywords render as a default arrow**. Always pick from the controlled list.
|
|
226
305
|
5. **`outro` card invariant** — at most one, must be last. `deck-validator.js:204–208`. Multiple outros = wiring bug, mid-deck outro = bug.
|
|
227
306
|
|
|
307
|
+
## Quick start with curated templates
|
|
308
|
+
|
|
309
|
+
When the user is new to Slice and wants something to copy from, point them
|
|
310
|
+
at the curated gallery. Five hand-picked decks ship with the CLI and
|
|
311
|
+
match the most common content shapes (product launch, founder lesson,
|
|
312
|
+
data finding, incident review, quiet essay).
|
|
313
|
+
|
|
314
|
+
```bash
|
|
315
|
+
voxflow slice fork --list # browse the gallery
|
|
316
|
+
voxflow slice fork product-launch # copy the deck.json to cwd
|
|
317
|
+
voxflow slice preview product-launch-deck.json # iterate
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
The same gallery is browsable at `voxflow.studio/apps/slice/templates`.
|
|
321
|
+
|
|
228
322
|
## Hand-off
|
|
229
323
|
|
|
230
324
|
After writing `deck.json`, tell the user:
|
|
@@ -232,12 +326,61 @@ After writing `deck.json`, tell the user:
|
|
|
232
326
|
```
|
|
233
327
|
Wrote deck.json (<N> cards, theme: <theme-id>). Next:
|
|
234
328
|
|
|
235
|
-
voxflow slice
|
|
236
|
-
voxflow slice
|
|
329
|
+
voxflow slice preview deck.json # browser preview + per-card audition + 🎨 + render
|
|
330
|
+
voxflow slice render deck.json --output out.mp4 # one-shot mp4 from the terminal
|
|
237
331
|
```
|
|
238
332
|
|
|
239
333
|
Do not run either command yourself unless the user asks.
|
|
240
334
|
|
|
335
|
+
Both commands work fully offline for the visual side. **Audio (per-card
|
|
336
|
+
TTS audition + render audio track) requires `voxflow login`** — 100 quota
|
|
337
|
+
per unique `(voice, text)` clip, then cached at
|
|
338
|
+
`~/.config/voxflow/stage-tts-cache/`. With no login, both commands fall
|
|
339
|
+
back silently to a Phase-0-style silent video; pass `--no-audio` to
|
|
340
|
+
`render` to suppress the audio pass entirely.
|
|
341
|
+
|
|
342
|
+
## Multi-turn editing loop (Claude Code / Cursor / native `Edit`)
|
|
343
|
+
|
|
344
|
+
When the user is iterating — "shorten card 2", "swap order", "different
|
|
345
|
+
voice for card 3", "hear card 1 again" — they are **NOT** asking for a
|
|
346
|
+
regen. Stay in this loop:
|
|
347
|
+
|
|
348
|
+
```bash
|
|
349
|
+
# Run once at the start of the session; auto-opens http://127.0.0.1:5180.
|
|
350
|
+
# The page hot-reloads on every save of deck.json (~50 ms fs watcher).
|
|
351
|
+
voxflow slice preview deck.json &
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
Then for every follow-up:
|
|
355
|
+
|
|
356
|
+
| User intent | Your move | Cost |
|
|
357
|
+
|---|---|---|
|
|
358
|
+
| "card N is too long" | `Edit` `cards[N-1].caption` / `.narration` — page hot-reloads | 0 |
|
|
359
|
+
| "swap card 2 and 3" | `Edit` the `cards` array order | 0 |
|
|
360
|
+
| "different voice for card 3" | `Edit` `cards[2].voiceover.voiceId` — or tell user to paste a voiceId in the toolbar voice picker | 0 (Edit) |
|
|
361
|
+
| "make card 4 silent" | `Edit` `cards[3].voiceover = { "enabled": false }` | 0 |
|
|
362
|
+
| "口播说点不一样的" | `Edit` `cards[i].voiceover.text` so TTS reads override while caption stays | 0 |
|
|
363
|
+
| "I want to hear card 3" | Tell user to click ▶ on card 3 in the browser; the toolbar shows cache hit / quota cost / error | 100 (first time per clip), 0 (cached) |
|
|
364
|
+
| "regenerate the image on card 2" | `Edit` `cards[1].images[0].prompt` — user clicks 🎨 in stage to see the new variant | 200 (first time per prompt), 0 (cached) |
|
|
365
|
+
| "give me a custom panel on card 4" | `Edit` `cards[3].children` to swap `paper-figure` for `raw-html` (V2 LayoutTree only) | 0 |
|
|
366
|
+
| "render mp4" | Tell user: click **Render mp4 (local)** in the browser, OR `voxflow slice render deck.json` | TTS + image pass (cached if seen) + render |
|
|
367
|
+
|
|
368
|
+
### Loop rules
|
|
369
|
+
|
|
370
|
+
1. **Edit only the fields the user asked about.** Other cards must stay
|
|
371
|
+
byte-identical — the stage UI's diff highlight is the user's "what
|
|
372
|
+
changed" indicator. Touching extra fields breaks that signal.
|
|
373
|
+
2. **Never re-run `voxflow slice <article>` during iteration** — that
|
|
374
|
+
costs 200 quota AND overwrites every user edit with a fresh LLM draft.
|
|
375
|
+
3. **Re-validate after every save** by re-reading the file. If the user
|
|
376
|
+
says "page shows old content" or "red banner appeared", the JSON has a
|
|
377
|
+
syntax error (trailing comma, unbalanced quote) — open, fix, save.
|
|
378
|
+
4. **Don't restart the preview server.** One process handles the whole
|
|
379
|
+
session; restarting wipes snapshot history.
|
|
380
|
+
5. **Don't call `/api/audition` yourself.** It's user-driven via the ▶
|
|
381
|
+
button. Editing `cards[i].voiceover.voiceId` is enough — the next ▶
|
|
382
|
+
click picks up the new voice.
|
|
383
|
+
|
|
241
384
|
## Self-review checklist
|
|
242
385
|
|
|
243
386
|
Before declaring the slice done:
|
|
@@ -254,6 +397,7 @@ Before declaring the slice done:
|
|
|
254
397
|
- [ ] If the theme is `photo-feature` or `atmospheric` and the user provided per-card images, `imageUrl` starts with `https://`
|
|
255
398
|
- [ ] No outro card unless the user explicitly asked for one
|
|
256
399
|
- [ ] No React, TSX, or CSS files were created
|
|
400
|
+
- [ ] If any card has a `voiceover` object, every key inside it (`enabled` / `voiceId` / `text` / `rate`) matches the schema (boolean / non-empty string ≤128 / string ≤500 / number in [0.5, 2.0])
|
|
257
401
|
|
|
258
402
|
## Anti-patterns
|
|
259
403
|
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"header": "数据发现",
|
|
3
|
+
"seriesTitle": "一个反直觉数字",
|
|
4
|
+
"seriesTagline": "看完你大概率会改方法",
|
|
5
|
+
"theme": "bold-poster",
|
|
6
|
+
"cards": [
|
|
7
|
+
{
|
|
8
|
+
"kind": "title",
|
|
9
|
+
"title": ["90%", "其实是错的"],
|
|
10
|
+
"narration": "我们查了一千个团队的工时表,发现一个反直觉的数字。"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"kind": "data",
|
|
14
|
+
"data": {
|
|
15
|
+
"value": "47",
|
|
16
|
+
"unit": "%",
|
|
17
|
+
"label": "时间花在等其他人决策"
|
|
18
|
+
},
|
|
19
|
+
"narration": "受访的产品经理里,平均 47% 的工时不是在做事,是在等其他人拍板。"
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"kind": "body",
|
|
23
|
+
"caption": "瓶颈不在产能,在决策",
|
|
24
|
+
"figureKeyword": "decision-fork",
|
|
25
|
+
"narration": "团队越大,决策的链路越长,工时就越多地耗在等待。"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"kind": "body",
|
|
29
|
+
"caption": "把决策权下放到能做的人",
|
|
30
|
+
"figureKeyword": "owner-deadline",
|
|
31
|
+
"narration": "解法不复杂:明确谁能拍板,让 ta 不必再等上级签字。"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"kind": "body",
|
|
35
|
+
"caption": "47% 是可以拿回来的",
|
|
36
|
+
"figureKeyword": "growth-system",
|
|
37
|
+
"narration": "下调一层决策权之后,工时回收的中位数是 22%。值得动一下。"
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"header": "创业 · 复盘",
|
|
3
|
+
"seriesTitle": "一个人创业",
|
|
4
|
+
"seriesTagline": "回看一年前的自己",
|
|
5
|
+
"theme": "editorial-mag",
|
|
6
|
+
"cards": [
|
|
7
|
+
{
|
|
8
|
+
"kind": "title",
|
|
9
|
+
"title": ["最重要的事", "不是技术"],
|
|
10
|
+
"narration": "一年前我以为是技术决定生死,一年后才发现真正的决定者是别的。"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"kind": "body",
|
|
14
|
+
"caption": "把功能写得太多",
|
|
15
|
+
"figureKeyword": "stuck",
|
|
16
|
+
"narration": "前半年我加了二十个功能,用户记得的只有三个。剩下的成了我的债务。"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"kind": "body",
|
|
20
|
+
"caption": "把用户的话当作功能列表",
|
|
21
|
+
"figureKeyword": "problem-framing",
|
|
22
|
+
"narration": "用户说什么我就做什么,没意识到他们说的是问题,不是答案。"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"kind": "body",
|
|
26
|
+
"caption": "把孤独当作专注的代价",
|
|
27
|
+
"figureKeyword": "thinking",
|
|
28
|
+
"narration": "一个人写完所有代码,但没有人和我讨论方向,慢慢就走偏了。"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"kind": "body",
|
|
32
|
+
"caption": "现在每周强制和三个用户聊",
|
|
33
|
+
"figureKeyword": "team-alignment",
|
|
34
|
+
"narration": "现在每周固定和三个真实用户聊半小时,比写代码更影响下一步。"
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"header": "线上事故 · 复盘",
|
|
3
|
+
"seriesTitle": "一次三小时的宕机",
|
|
4
|
+
"seriesTagline": "我们认了,下次不会再这样",
|
|
5
|
+
"theme": "brutalist",
|
|
6
|
+
"cards": [
|
|
7
|
+
{
|
|
8
|
+
"kind": "title",
|
|
9
|
+
"title": ["3 小时宕机", "我们的复盘"],
|
|
10
|
+
"narration": "周二晚上九点,我们的服务挂了三个小时。这是怎么发生的。"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"kind": "body",
|
|
14
|
+
"caption": "一次例行发布触发了 OOM",
|
|
15
|
+
"figureKeyword": "risk-guardrail",
|
|
16
|
+
"narration": "晚上 21:08 上线了一个新功能,新代码在峰值流量下吃掉了所有内存。"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"kind": "body",
|
|
20
|
+
"caption": "告警没响,监控盲区",
|
|
21
|
+
"figureKeyword": "evidence-board",
|
|
22
|
+
"narration": "OOM 杀死了进程但没杀掉容器,健康检查仍然通过,告警延迟了 40 分钟。"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"kind": "body",
|
|
26
|
+
"caption": "回滚花了一小时,因为没演练过",
|
|
27
|
+
"figureKeyword": "timeline-review",
|
|
28
|
+
"narration": "我们有回滚脚本,但从来没真正跑过,第一次跑发现配置漂移了,又花一小时手动修。"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"kind": "body",
|
|
32
|
+
"caption": "三件改变:金丝雀、内存告警、月度演练",
|
|
33
|
+
"figureKeyword": "learning-loop",
|
|
34
|
+
"narration": "下个版本起:所有发布走 5% 金丝雀;监控加内存阈值;每月演练一次回滚。"
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
}
|