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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "voxflow",
3
- "version": "1.15.3",
3
+ "version": "1.15.5",
4
4
  "description": "AI audio content creation CLI — stories, podcasts, narration, dubbing, transcription, translation, and video translation with TTS",
5
5
  "bin": {
6
6
  "voxflow": "./dist/index.js"
@@ -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 render deck.json --output out.mp4 # render mp4 locally (~30s)
236
- voxflow slice stage deck.json # live preview in browser
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
+ }