voxflow 1.15.4 → 1.15.6

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.
@@ -31,51 +31,78 @@ const path = require('path');
31
31
  const { contentTypeFor } = require('./tts-audition');
32
32
  const { SYNTHESIZE_DEFAULTS } = require('../core/config');
33
33
 
34
+ // Mime mapper covering both audio + image extensions so the local media
35
+ // server can serve cached audition mp3s and generated PNG/JPEG/WebP from a
36
+ // single process. Anything unrecognised becomes octet-stream — Remotion's
37
+ // fetch still goes through but the composition decides what to do.
38
+ const MIME_BY_EXT = {
39
+ mp3: 'audio/mpeg',
40
+ wav: 'audio/wav',
41
+ pcm: 'audio/L16',
42
+ png: 'image/png',
43
+ jpg: 'image/jpeg',
44
+ jpeg: 'image/jpeg',
45
+ webp: 'image/webp',
46
+ };
47
+ function mimeFor(ext) {
48
+ if (!ext) return 'application/octet-stream';
49
+ return MIME_BY_EXT[ext.toLowerCase()] || contentTypeFor(ext);
50
+ }
51
+
52
+ function serveFileFrom(rootDir, urlFname, res) {
53
+ const fname = urlFname.split('?')[0];
54
+ if (fname === '' || fname.includes('/') || fname.includes('\\') || fname.includes('..')) {
55
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
56
+ res.end('bad filename');
57
+ return;
58
+ }
59
+ const filePath = path.join(rootDir, fname);
60
+ fs.stat(filePath, (statErr, st) => {
61
+ if (statErr || !st.isFile()) {
62
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
63
+ res.end('not found');
64
+ return;
65
+ }
66
+ const ext = path.extname(fname).slice(1);
67
+ res.writeHead(200, {
68
+ 'Content-Type': mimeFor(ext),
69
+ 'Content-Length': st.size,
70
+ 'Cache-Control': 'no-store',
71
+ });
72
+ fs.createReadStream(filePath).pipe(res);
73
+ });
74
+ }
75
+
34
76
  /**
35
- * Tiny localhost HTTP server serving the audition cache directory.
36
- * Only responds to GET /audio/<filename>; everything else is 404. Path
37
- * traversal (.. or nested directories) is rejected up front since the
38
- * cache layout is intentionally flat.
77
+ * Tiny localhost HTTP server serving the audition + image-gen caches.
78
+ * Responds to GET /audio/<filename> and (optionally) GET /image/<filename>;
79
+ * everything else is 404. Path traversal is rejected the cache layouts
80
+ * are intentionally flat <sha256>.<ext> files.
39
81
  *
40
82
  * @param {object} opts
41
- * @param {string} opts.cacheDir Directory containing <hash>.mp3 files.
83
+ * @param {string} opts.cacheDir Directory containing audio <hash>.<ext> files.
84
+ * @param {string} [opts.imageCacheDir] Optional second root mounted at /image/.
42
85
  * @param {number} [opts.preferredPort=0] 0 lets the OS pick a free port.
43
86
  * @returns {Promise<{server, port, url, close}>}
44
87
  */
45
- async function startVoiceoverServer({ cacheDir, preferredPort = 0 }) {
88
+ async function startVoiceoverServer({ cacheDir, imageCacheDir = null, preferredPort = 0 }) {
46
89
  if (typeof cacheDir !== 'string' || !cacheDir) {
47
90
  throw new Error('startVoiceoverServer: cacheDir required');
48
91
  }
49
92
  const server = http.createServer((req, res) => {
50
- if (req.method !== 'GET' || !req.url.startsWith('/audio/')) {
93
+ if (req.method !== 'GET') {
51
94
  res.writeHead(404, { 'Content-Type': 'text/plain' });
52
95
  res.end('not found');
53
96
  return;
54
97
  }
55
- const fname = req.url.slice('/audio/'.length).split('?')[0];
56
- // Defense in depth — reject path traversal even on a localhost-only
57
- // server. The audition cache is a flat dir of <sha256>.<ext> filenames.
58
- if (fname === '' || fname.includes('/') || fname.includes('\\') || fname.includes('..')) {
59
- res.writeHead(400, { 'Content-Type': 'text/plain' });
60
- res.end('bad filename');
61
- return;
98
+ if (req.url.startsWith('/audio/')) {
99
+ return serveFileFrom(cacheDir, req.url.slice('/audio/'.length), res);
62
100
  }
63
- const filePath = path.join(cacheDir, fname);
64
- fs.stat(filePath, (statErr, st) => {
65
- if (statErr || !st.isFile()) {
66
- res.writeHead(404, { 'Content-Type': 'text/plain' });
67
- res.end('not found');
68
- return;
69
- }
70
- const ext = path.extname(fname).slice(1);
71
- const ctype = contentTypeFor(ext);
72
- res.writeHead(200, {
73
- 'Content-Type': ctype,
74
- 'Content-Length': st.size,
75
- 'Cache-Control': 'no-store',
76
- });
77
- fs.createReadStream(filePath).pipe(res);
78
- });
101
+ if (imageCacheDir && req.url.startsWith('/image/')) {
102
+ return serveFileFrom(imageCacheDir, req.url.slice('/image/'.length), res);
103
+ }
104
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
105
+ res.end('not found');
79
106
  });
80
107
  await new Promise((resolve, reject) => {
81
108
  server.once('error', reject);
@@ -177,7 +204,87 @@ async function prepareVoiceovers({ deck, auditionClient, baseUrl, onProgress })
177
204
  return { byIdx, skipped };
178
205
  }
179
206
 
207
+ /**
208
+ * Image counterpart of prepareVoiceovers — resolves the primary image
209
+ * for each card. Precedence per card:
210
+ * (a) card.imageUrl present → use as-is, no API call (external asset)
211
+ * (b) card.images[0] present → call image-gen client, route through
212
+ * the local server's /image/ mount, return URL.
213
+ * Returns { byIdx: { cardIdx → URL }, skipped: [{cardIdx, reason, message?}] }.
214
+ *
215
+ * @param {object} opts
216
+ * @param {object} opts.deck
217
+ * @param {{imagine: Function}} opts.imgClient
218
+ * @param {string} opts.baseUrl e.g. http://127.0.0.1:54321
219
+ * @param {(p:object) => void} [opts.onProgress]
220
+ */
221
+ async function prepareImages({ deck, imgClient, baseUrl, onProgress }) {
222
+ const byIdx = {};
223
+ const skipped = [];
224
+ if (!deck || !Array.isArray(deck.cards)) return { byIdx, skipped };
225
+ if (!imgClient || typeof imgClient.imagine !== 'function') {
226
+ throw new Error('prepareImages: imgClient.imagine is required');
227
+ }
228
+ if (typeof baseUrl !== 'string' || !baseUrl) {
229
+ throw new Error('prepareImages: baseUrl is required');
230
+ }
231
+ const cards = deck.cards;
232
+
233
+ for (let i = 0; i < cards.length; i++) {
234
+ const card = cards[i];
235
+ if (!card) { skipped.push({ cardIdx: i, reason: 'missing-card' }); continue; }
236
+
237
+ // External imageUrl wins — themes that already consume it (photo-feature
238
+ // / atmospheric) keep working unchanged.
239
+ if (typeof card.imageUrl === 'string' && card.imageUrl.trim()) {
240
+ byIdx[i] = card.imageUrl;
241
+ continue;
242
+ }
243
+
244
+ const images = Array.isArray(card.images) ? card.images : [];
245
+ const primary = images[0];
246
+ if (!primary) {
247
+ skipped.push({ cardIdx: i, reason: 'no-images' });
248
+ continue;
249
+ }
250
+
251
+ let r;
252
+ try {
253
+ r = await imgClient.imagine({
254
+ prompt: primary.prompt,
255
+ aspect: primary.aspect,
256
+ quality: primary.quality,
257
+ });
258
+ } catch (err) {
259
+ skipped.push({ cardIdx: i, reason: 'network_error', message: err.message || String(err) });
260
+ continue;
261
+ }
262
+ if (r.code !== 'success') {
263
+ skipped.push({ cardIdx: i, reason: r.code, message: r.message });
264
+ if (r.code === 'not_logged_in' || r.code === 'quota_exceeded') break;
265
+ continue;
266
+ }
267
+ const fname = `${r.cacheKey}.${r.ext}`;
268
+ byIdx[i] = `${baseUrl.replace(/\/$/, '')}/image/${fname}`;
269
+ if (typeof onProgress === 'function') {
270
+ try {
271
+ onProgress({
272
+ cardIdx: i,
273
+ total: cards.length,
274
+ fromCache: !!r.fromCache,
275
+ prompt: primary.prompt,
276
+ aspect: primary.aspect,
277
+ });
278
+ } catch { /* swallow */ }
279
+ }
280
+ }
281
+
282
+ return { byIdx, skipped };
283
+ }
284
+
180
285
  module.exports = {
181
286
  startVoiceoverServer,
182
287
  prepareVoiceovers,
288
+ prepareImages,
289
+ mimeFor,
183
290
  };
@@ -590,6 +590,69 @@ function renderSliceStageHtml({ sourcePath, port }) {
590
590
  .audition-status[data-state="error"] {
591
591
  color: #b91c1c; background: rgba(239,68,68,0.08);
592
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
+ }
593
656
 
594
657
  .selection-fab {
595
658
  position: fixed; z-index: 50;
@@ -835,6 +898,17 @@ function renderSliceStageHtml({ sourcePath, port }) {
835
898
  <span>✏</span><span>Edit selection with AI</span>
836
899
  </button>
837
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
+
838
912
  <div class="local-render-toast" id="local-render-toast" role="status" aria-live="polite">
839
913
  <div class="toast-head">
840
914
  <span>✓</span>
@@ -1185,6 +1259,87 @@ function renderSliceStageHtml({ sourcePath, port }) {
1185
1259
  setAuditionStatus('error', 'Audio playback failed.');
1186
1260
  });
1187
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
+
1188
1343
  // For highlighting cards that just changed on hot-reload, we keep a
1189
1344
  // hash of each card's stringified JSON. On the next deck event we
1190
1345
  // diff per-index and add the just-changed CSS class to whichever
@@ -1288,6 +1443,13 @@ function renderSliceStageHtml({ sourcePath, port }) {
1288
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">'
1289
1444
  + '<span class="audition-icon" aria-hidden="true">▶</span>Audition'
1290
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>'
1291
1453
  + '<button type="button" data-action="copy-card" data-card-index="' + i + '"'
1292
1454
  + ' aria-label="Copy card ' + (i + 1) + ' as text">Copy text</button>'
1293
1455
  + '</div>'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "voxflow",
3
- "version": "1.15.4",
3
+ "version": "1.15.6",
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"
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: voxflow-slice
3
- description: "OFFLINE / LOCAL-ONLY slicing route use when the user explicitly wants no-cloud / no-quota / offline / local slicing of a markdown / text article into a VoxFlow Slice deck.json. The user's own Claude does the slicing there is no VoxFlow API call, no JWT, no quota deduction. Produces a validated 5–8 card JSON ready for `voxflow slice render deck.json` (local Remotion render). Pick this skill ONLY when the user says offline / local / no-cloud / 离线 / 本地 / 不联网 / no quota / 不用配额 for cloud-route slicing (which calls VoxFlow backend and consumes quota), use the `slice` / `voxflow:slice` skill instead. Triggers: offline slice / local slice / 离线切片 / 本地切片 / no-cloud slice / 不联网切片 / slice this article offline / 文章离线转切片 / 本地切片视频."
3
+ description: "Author + iterate on a VoxFlow Slice card video (1080×1920 vertical, 33 themes) locally in Claude Code / Cursor — the user's own Claude does the work, no VoxFlow API call to slice (0 quota). Full multi-turn workflow: (1) fork a curated template via `voxflow slice fork <id>` OR slice an article into a deck.json by hand; (2) edit deck.json fields in place with Edit/Write tools while `voxflow slice preview deck.json` is running (browser hot-reloads in ~50ms); (3) audition voice per card with ▶ in the browser (proxies /api/tts/synthesize, content-hash cached); (4) regenerate AI images per card with 🎨 (card.images: [{id, prompt, aspect, quality}], proxies hunyuan-image, content-hash cached); (5) render mp4 with `voxflow slice render deck.json` — audio + AI images baked in by default, --no-audio / --no-images opt out, --legacy silent fallback. Schema gains: per-card `voiceover` (voiceId/text/rate/enabled), per-card `images` registry, V2 LayoutTree `el: \"raw-html\"` escape hatch (4096 char cap). Loop rules: edit only the fields the user asked about (preserve diff highlight), never re-run `voxflow slice <article>` during iteration (overwrites edits), don't restart the preview server, don't call /api/audition or /api/imagine yourself (user-driven via ▶/🎨). Pick this skill for ANY iterative Slice work — even when the user has a token; the iteration loop is faster + cheaper than the one-shot cloud route. Triggers: slice / paper-slide / paperslide / card video / 卡片视频 / 切片视频 / 知识卡片 / 文章转视频 / 抖音知识号 / 小红书图文转视频 / 知乎长文转视频 / 公众号转视频 / iterate slice / multi-turn slice / 多轮编辑切片 / 迭代切片 / fork template / slice fork / 模板 / 模板市场 / template gallery / audition card / play voiceover / 试听口播 / 试听卡片 / regenerate image / 生图 / image-gen card / raw-html slice / custom panel / offline slice / local slice / 离线切片 / 本地切片 / 不联网 / no-cloud slice. For the one-shot cloud route (calls /api/slice/deck, 200 quota, no iteration), use the `slice` skill instead."
4
4
  ---
5
5
 
6
6
  # voxflow-slice — Article → deck.json (offline)
@@ -145,6 +145,60 @@ 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
+
148
202
  ### Optional per-card `voiceover` override
149
203
 
150
204
  Any card kind may carry a nested `voiceover` object to tune its audio
@@ -250,6 +304,21 @@ These are the most common failure modes. The validator surfaces a clean error, b
250
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.
251
305
  5. **`outro` card invariant** — at most one, must be last. `deck-validator.js:204–208`. Multiple outros = wiring bug, mid-deck outro = bug.
252
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
+
253
322
  ## Hand-off
254
323
 
255
324
  After writing `deck.json`, tell the user:
@@ -257,7 +326,7 @@ After writing `deck.json`, tell the user:
257
326
  ```
258
327
  Wrote deck.json (<N> cards, theme: <theme-id>). Next:
259
328
 
260
- voxflow slice preview deck.json # browser preview + per-card audition + render button
329
+ voxflow slice preview deck.json # browser preview + per-card audition + 🎨 + render
261
330
  voxflow slice render deck.json --output out.mp4 # one-shot mp4 from the terminal
262
331
  ```
263
332
 
@@ -292,7 +361,9 @@ Then for every follow-up:
292
361
  | "make card 4 silent" | `Edit` `cards[3].voiceover = { "enabled": false }` | 0 |
293
362
  | "口播说点不一样的" | `Edit` `cards[i].voiceover.text` so TTS reads override while caption stays | 0 |
294
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) |
295
- | "render mp4" | Tell user: click **Render mp4 (local)** in the browser, OR `voxflow slice render deck.json` | TTS pass (cached if auditioned) + render |
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 |
296
367
 
297
368
  ### Loop rules
298
369
 
@@ -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
+ }