voxflow 1.15.1 → 1.15.2

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.
@@ -205,11 +205,14 @@ async function handle(args) {
205
205
  const sliceRender = require('./slice-render');
206
206
  return sliceRender.handle(args.slice(1));
207
207
  }
208
- // `voxflow slice preview <deck.json>` — localhost HTML page with a
209
- // midpoint-frame still per card (no mp4, fraction of the render cost).
210
- if (args.length > 0 && args[0] === 'preview') {
211
- const slicePreview = require('./slice-preview');
212
- return slicePreview.handle(args.slice(1));
208
+ // `voxflow slice preview <deck.json>` — alias for `slice stage`. The
209
+ // earlier static-thumbnail Phase 0 preview was strictly less capable
210
+ // than stage's hot-reload edit workflow (no file watcher, no live
211
+ // updates, no version history), so we route both to the same handler
212
+ // and keep "preview" as a discoverable name for new users.
213
+ if (args.length > 0 && (args[0] === 'preview')) {
214
+ const sliceStage = require('./slice-stage');
215
+ return sliceStage.handle(args.slice(1));
213
216
  }
214
217
 
215
218
  const { parseFlag, runWithRetry } = require('../core/args');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "voxflow",
3
- "version": "1.15.1",
3
+ "version": "1.15.2",
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,266 +0,0 @@
1
- 'use strict';
2
-
3
- /**
4
- * `voxflow slice preview <deck.json>` — open a localhost HTML page that
5
- * shows a midpoint-frame still of every card in the deck so users can
6
- * see the cards before paying the full render cost (~30s for 20s video).
7
- *
8
- * Renders N PNG stills via @remotion/renderer.renderStill() at the
9
- * midpoint frame of each card, caches them under /tmp/, then serves a
10
- * minimal HTML page with the grid + auto-opens the browser. The server
11
- * stays alive until Ctrl+C.
12
- *
13
- * Shares buildInputProps / resolveServeUrl / chromeBinaryExists with
14
- * slice-render so behavior is consistent between preview and render.
15
- *
16
- * No `meta` export — reached via `slice.js` dispatch (`voxflow slice
17
- * preview <path>`), so it stays out of the registry parity test.
18
- */
19
-
20
- const fs = require('node:fs');
21
- const http = require('node:http');
22
- const path = require('node:path');
23
- const crypto = require('node:crypto');
24
- const os = require('node:os');
25
-
26
- const open = require('open').default;
27
-
28
- const { validatePaperSlideDeck, isV2LayoutTreeDeck } = require('../internal/deck-validator');
29
- const {
30
- buildInputProps,
31
- resolveServeUrl,
32
- chromeBinaryExists,
33
- THEME_TO_DECK_ID,
34
- DEFAULT_THEME,
35
- } = require('./slice-render');
36
-
37
- const FPS = 30;
38
- const DEFAULT_PORT = 5555;
39
- const PREVIEW_WIDTH = 540; // half of 1080 — stills don't need full-res for thumbnails
40
-
41
- function deckHash(deck) {
42
- // 8-char hash so the cache dir is short but unique per deck content.
43
- // If the deck changes, we want fresh stills; if it doesn't, reuse cache.
44
- return crypto.createHash('sha1').update(JSON.stringify(deck)).digest('hex').slice(0, 8);
45
- }
46
-
47
- function cardMidpointFrame(deck, cardIndex) {
48
- // Composition plays cards sequentially. To get card K's midpoint
49
- // frame we sum previous durations then add half of this one.
50
- let acc = 0;
51
- for (let i = 0; i < cardIndex; i++) {
52
- acc += deck.cards[i].durationSec || 4;
53
- }
54
- const here = deck.cards[cardIndex].durationSec || 4;
55
- return Math.floor((acc + here / 2) * FPS);
56
- }
57
-
58
- async function findFreePort(start) {
59
- return new Promise((resolve) => {
60
- const probe = http.createServer();
61
- probe.unref();
62
- probe.on('error', () => resolve(findFreePort(start + 1)));
63
- probe.listen(start, () => {
64
- const port = probe.address().port;
65
- probe.close(() => resolve(port));
66
- });
67
- });
68
- }
69
-
70
- function escapeHtml(s) {
71
- return String(s ?? '').replace(/[&<>"']/g, (c) => ({
72
- '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
73
- }[c]));
74
- }
75
-
76
- function renderHtml(deck, port) {
77
- const title = escapeHtml(deck.seriesTitle || deck.header || 'Preview');
78
- const meta = `${deck.cards.length} cards · theme: ${escapeHtml(deck.theme)} · 1080×1920`;
79
- const cards = deck.cards.map((c, i) => {
80
- const label = c.kind === 'title'
81
- ? (Array.isArray(c.title) ? c.title.join(' / ') : '')
82
- : (c.caption || '');
83
- return `<figure class="card">
84
- <img src="/still/${i}.png" alt="Card ${i + 1}" loading="lazy">
85
- <figcaption><span class="idx">${i + 1}</span> <span class="kind">${escapeHtml(c.kind)}</span> ${escapeHtml(label)}</figcaption>
86
- </figure>`;
87
- }).join('\n ');
88
-
89
- return `<!DOCTYPE html>
90
- <html lang="zh">
91
- <head>
92
- <meta charset="utf-8">
93
- <title>VoxFlow Slice · ${title}</title>
94
- <style>
95
- :root { color-scheme: light; }
96
- body { font-family: ui-sans-serif, system-ui, -apple-system, "PingFang SC", sans-serif; margin: 0; padding: 40px 48px 64px; background: #faf6ee; color: #1b1815; }
97
- h1 { font-size: 28px; font-weight: 500; letter-spacing: -0.01em; margin: 0 0 6px; }
98
- .meta { color: #7a6f5f; font-size: 13px; margin-bottom: 28px; }
99
- .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(216px, 1fr)); gap: 20px 16px; }
100
- .card { margin: 0; display: flex; flex-direction: column; gap: 8px; }
101
- .card img { width: 100%; aspect-ratio: 9/16; background: #000; border-radius: 6px; box-shadow: 0 1px 2px rgba(0,0,0,0.08), 0 4px 14px rgba(60,40,20,0.07); display: block; }
102
- .card figcaption { font-size: 12px; line-height: 1.45; color: #4d4339; display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px; }
103
- .idx { font-variant-numeric: tabular-nums; color: #b8a994; font-weight: 600; }
104
- .kind { display: inline-block; padding: 1px 6px; border-radius: 3px; background: #efe7d6; color: #6e5f49; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }
105
- .cmd { font-family: ui-monospace, "SF Mono", monospace; background: #2b2520; color: #faf6ee; padding: 14px 18px; border-radius: 6px; font-size: 13px; margin-top: 32px; user-select: all; }
106
- .cmd .hint { color: #9c8e75; font-size: 11px; display: block; margin-bottom: 6px; }
107
- </style>
108
- </head>
109
- <body>
110
- <h1>${title}</h1>
111
- <div class="meta">${meta}</div>
112
- <div class="grid">
113
- ${cards}
114
- </div>
115
- <div class="cmd">
116
- <span class="hint">Satisfied? Render to mp4:</span>
117
- voxflow slice render &lt;your-deck.json&gt; --output out.mp4
118
- </div>
119
- </body>
120
- </html>`;
121
- }
122
-
123
- async function preview(opts) {
124
- const deckPath = path.resolve(opts.deckPath);
125
- if (!fs.existsSync(deckPath)) {
126
- throw new Error(`Deck file not found: ${deckPath}`);
127
- }
128
- const raw = fs.readFileSync(deckPath, 'utf8');
129
- let deck;
130
- try {
131
- deck = JSON.parse(raw);
132
- } catch (err) {
133
- throw new Error(`Could not parse deck JSON: ${err.message}`);
134
- }
135
-
136
- if (isV2LayoutTreeDeck(deck)) {
137
- throw new Error(
138
- 'V2 LayoutTree decks are not yet supported by offline preview. ' +
139
- 'Use voxflow.studio/apps/slice for V2 decks or downgrade to V1 shape.'
140
- );
141
- }
142
- validatePaperSlideDeck(deck);
143
-
144
- const theme = deck.theme || DEFAULT_THEME;
145
- const compId = THEME_TO_DECK_ID[theme];
146
- if (!compId) {
147
- throw new Error(
148
- `Unknown theme "${theme}". Valid: ${Object.keys(THEME_TO_DECK_ID).join(', ')}`
149
- );
150
- }
151
-
152
- const serveUrl = resolveServeUrl();
153
- const inputProps = buildInputProps(deck);
154
- const cacheDir = path.join(os.tmpdir(), `voxflow-preview-${deckHash(deck)}`);
155
- fs.mkdirSync(cacheDir, { recursive: true });
156
-
157
- // Lazy require — mirrors slice-render.js — keeps `voxflow login` etc. fast
158
- // for users who never trigger Remotion.
159
- const { selectComposition, renderStill } = require('@remotion/renderer');
160
-
161
- const coldStart = !chromeBinaryExists();
162
- if (coldStart) {
163
- console.log(' First-run: Remotion will download chrome-headless-shell (~90 MB).');
164
- }
165
-
166
- console.log(`[slice preview] theme: ${theme}`);
167
- console.log(`[slice preview] composition: ${compId}`);
168
- console.log(`[slice preview] cards: ${deck.cards.length}`);
169
-
170
- const composition = await selectComposition({
171
- serveUrl,
172
- id: compId,
173
- inputProps,
174
- });
175
-
176
- const t0 = Date.now();
177
- const cachedCount = deck.cards.filter((_, i) =>
178
- fs.existsSync(path.join(cacheDir, `card-${i}.png`))
179
- ).length;
180
- if (cachedCount === deck.cards.length) {
181
- console.log(`[slice preview] all ${cachedCount} stills cached — skipping render`);
182
- } else {
183
- for (let i = 0; i < deck.cards.length; i++) {
184
- const outPath = path.join(cacheDir, `card-${i}.png`);
185
- if (fs.existsSync(outPath)) continue;
186
- const frame = cardMidpointFrame(deck, i);
187
- process.stdout.write(`\r[slice preview] rendering still ${i + 1}/${deck.cards.length}...`);
188
- await renderStill({
189
- composition,
190
- serveUrl,
191
- inputProps,
192
- frame,
193
- output: outPath,
194
- imageFormat: 'png',
195
- scale: PREVIEW_WIDTH / 1080, // halve the resolution for snappier renders
196
- });
197
- }
198
- const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
199
- process.stdout.write(`\r[slice preview] ${deck.cards.length} stills rendered in ${elapsed}s\n`);
200
- }
201
-
202
- const port = await findFreePort(DEFAULT_PORT);
203
- const html = renderHtml(deck, port);
204
-
205
- const server = http.createServer((req, res) => {
206
- const urlPath = (req.url || '/').split('?')[0];
207
- if (urlPath === '/' || urlPath === '/index.html') {
208
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
209
- res.end(html);
210
- return;
211
- }
212
- const m = /^\/still\/(\d+)\.png$/.exec(urlPath);
213
- if (m) {
214
- const idx = Number(m[1]);
215
- const file = path.join(cacheDir, `card-${idx}.png`);
216
- if (!fs.existsSync(file)) {
217
- res.writeHead(404);
218
- res.end('still not found');
219
- return;
220
- }
221
- res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=600' });
222
- fs.createReadStream(file).pipe(res);
223
- return;
224
- }
225
- res.writeHead(404);
226
- res.end('Not found');
227
- });
228
-
229
- await new Promise((resolve) => server.listen(port, '127.0.0.1', resolve));
230
- const url = `http://localhost:${port}`;
231
- console.log(`[slice preview] http://localhost:${port} (Ctrl+C to stop)`);
232
-
233
- try {
234
- await open(url);
235
- } catch {
236
- // open failed — user can paste the URL manually
237
- }
238
-
239
- process.stdin.resume();
240
- process.on('SIGINT', () => {
241
- console.log('\n[slice preview] stopped');
242
- server.close(() => process.exit(0));
243
- });
244
- }
245
-
246
- async function handle(args) {
247
- const { parseFlag } = require('../core/args');
248
- const positional = args.find((a) => !a.startsWith('-'));
249
- parseFlag(args, '--output', '-o'); // parsed-but-ignored for symmetry with `slice render`
250
- if (!positional) {
251
- console.error('Usage: voxflow slice preview <deck.json>');
252
- process.exit(1);
253
- }
254
- try {
255
- await preview({ deckPath: positional });
256
- } catch (err) {
257
- console.error(`\nslice preview failed: ${err.message}`);
258
- if (process.env.VOXFLOW_DEBUG) console.error(err.stack);
259
- process.exit(1);
260
- }
261
- }
262
-
263
- module.exports = {
264
- preview,
265
- handle,
266
- };