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.
- 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 +74 -31
- package/lib/commands/slice-stage.js +31 -0
- package/lib/commands/slice.js +9 -0
- package/lib/internal/deck-validator.js +103 -8
- package/lib/stage-core/image-gen.js +233 -0
- package/lib/stage-core/local-render.js +50 -15
- package/lib/stage-core/server.js +55 -2
- package/lib/stage-core/voiceover-mux.js +137 -30
- package/lib/stage-ui/slice/template.js +162 -0
- package/package.json +1 -1
- package/skills/voxflow-slice/SKILL.md +74 -3
- 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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"templates": [
|
|
4
|
+
{
|
|
5
|
+
"id": "product-launch",
|
|
6
|
+
"name": "Product launch story",
|
|
7
|
+
"theme": "paper-slide",
|
|
8
|
+
"description": "Five-card narrative arc for a startup launch — pain → insight → demo → result → CTA. Best for 抖音 / 视频号 / 小红书 knowledge-card content.",
|
|
9
|
+
"lang": "zh",
|
|
10
|
+
"tags": ["startup", "launch", "narrative"]
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"id": "founder-lesson",
|
|
14
|
+
"name": "Founder lesson (look-back)",
|
|
15
|
+
"theme": "editorial-mag",
|
|
16
|
+
"description": "Quiet hindsight from a solo founder. Magazine-style editorial fit for 知乎 / 公众号 / LinkedIn long-form readers.",
|
|
17
|
+
"lang": "zh",
|
|
18
|
+
"tags": ["founder", "hindsight", "essay"]
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"id": "data-finding",
|
|
22
|
+
"name": "Surprising stat",
|
|
23
|
+
"theme": "bold-poster",
|
|
24
|
+
"description": "Bold-poster card that opens on a surprising number, then breaks it down. Best for X / Threads / LinkedIn where a single visual carries the room.",
|
|
25
|
+
"lang": "zh",
|
|
26
|
+
"tags": ["data", "number", "headline"]
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"id": "incident-review",
|
|
30
|
+
"name": "Incident post-mortem",
|
|
31
|
+
"theme": "brutalist",
|
|
32
|
+
"description": "Brutalist black-and-white deck for an honest post-mortem — what happened, why, what changes. Best for engineering team channels / 即刻 / Mastodon.",
|
|
33
|
+
"lang": "zh",
|
|
34
|
+
"tags": ["postmortem", "engineering", "candor"]
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"id": "quiet-essay",
|
|
38
|
+
"name": "Late-night reflection",
|
|
39
|
+
"theme": "atmospheric",
|
|
40
|
+
"description": "Atmospheric one-light-source vibe for personal essay. Best for 即刻 / 微博 / 播客 closing thoughts.",
|
|
41
|
+
"lang": "zh",
|
|
42
|
+
"tags": ["essay", "reflection", "mood"]
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"header": "产品发布",
|
|
3
|
+
"seriesTitle": "我们做了一年的东西",
|
|
4
|
+
"seriesTagline": "上线第一天的真心话",
|
|
5
|
+
"theme": "paper-slide",
|
|
6
|
+
"cards": [
|
|
7
|
+
{
|
|
8
|
+
"kind": "title",
|
|
9
|
+
"title": ["十年的痛", "今天有个答案"],
|
|
10
|
+
"narration": "我们做了一整年的东西,今天上线。先讲讲为什么要做。"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"kind": "body",
|
|
14
|
+
"caption": "重复的活,每天都在做",
|
|
15
|
+
"figureKeyword": "stuck",
|
|
16
|
+
"narration": "团队里有 30% 的时间花在同一件事上:把素材塞进同一个模板。"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"kind": "body",
|
|
20
|
+
"caption": "工具早就有,但用起来割裂",
|
|
21
|
+
"figureKeyword": "decision-fork",
|
|
22
|
+
"narration": "市面上有十几个工具能做这个分工,但每多一个工具,团队学习成本翻倍。"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"kind": "body",
|
|
26
|
+
"caption": "我们把它合并到一个对话",
|
|
27
|
+
"figureKeyword": "team-alignment",
|
|
28
|
+
"narration": "今天发布的版本,把这些动作统一在一个 Claude Code 会话里。"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"kind": "body",
|
|
32
|
+
"caption": "30 分钟出片,0 模板锁定",
|
|
33
|
+
"figureKeyword": "growth-system",
|
|
34
|
+
"narration": "首批用户的反馈是平均 30 分钟从想法到成片,比之前快五倍。"
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"header": "深夜 · 散文",
|
|
3
|
+
"seriesTitle": "为什么周末也很累",
|
|
4
|
+
"seriesTagline": "写给一直没歇的你",
|
|
5
|
+
"theme": "atmospheric",
|
|
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": "thinking",
|
|
22
|
+
"narration": "心理学叫蔡格尼克效应,未完成的小事比已完成的大事更占内存。"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"kind": "body",
|
|
26
|
+
"caption": "睡前花五分钟把它们写下来",
|
|
27
|
+
"figureKeyword": "evidence-board",
|
|
28
|
+
"narration": "解法很轻:睡前花五分钟把脑子里悬着的事写下来,写完它们就先安心地等着。"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"kind": "body",
|
|
32
|
+
"caption": "不是认真就行,是让它们不再追你",
|
|
33
|
+
"figureKeyword": "growth-system",
|
|
34
|
+
"narration": "你不是要认真处理每件小事,你是要让它们停止半夜还在追你。"
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `voxflow slice fork <template-id>` — copy a curated Slice template's
|
|
5
|
+
* deck.json into the current working directory.
|
|
6
|
+
*
|
|
7
|
+
* Templates live under `cli/skills/voxflow-slice/templates/<id>/deck.json`
|
|
8
|
+
* with a sibling `manifest.json` describing the gallery. The CLI ships
|
|
9
|
+
* them verbatim (the `files` whitelist in package.json includes `skills/`),
|
|
10
|
+
* so `npm install voxflow` is enough — no separate download.
|
|
11
|
+
*
|
|
12
|
+
* No `meta` export — reached only via `slice.js` dispatch (`voxflow slice
|
|
13
|
+
* fork <id>`), so it stays out of the registry parity test (same pattern
|
|
14
|
+
* as slice-stage.js / slice-render.js).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
|
|
20
|
+
const { validatePaperSlideDeck } = require('../internal/deck-validator');
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resolve the on-disk path to the templates directory. Walks the candidate
|
|
24
|
+
* layouts so the fork command works in both `node bin/voxflow.js` (source
|
|
25
|
+
* tree at cli/skills/...) and the published npm tarball (ncc dist beside
|
|
26
|
+
* skills/).
|
|
27
|
+
*/
|
|
28
|
+
function resolveTemplatesDir() {
|
|
29
|
+
const candidates = [
|
|
30
|
+
// Source / dev: cli/lib/commands/slice-fork.js → cli/skills/...
|
|
31
|
+
path.resolve(__dirname, '../../skills/voxflow-slice/templates'),
|
|
32
|
+
// Published: <pkg>/dist/index.js (bundled) sits beside <pkg>/skills/...
|
|
33
|
+
path.resolve(__dirname, '../skills/voxflow-slice/templates'),
|
|
34
|
+
// Alt published layout (some build configs flatten skills/ into dist/)
|
|
35
|
+
path.resolve(__dirname, 'skills/voxflow-slice/templates'),
|
|
36
|
+
];
|
|
37
|
+
for (const c of candidates) {
|
|
38
|
+
try {
|
|
39
|
+
if (fs.statSync(path.join(c, 'manifest.json')).isFile()) return c;
|
|
40
|
+
} catch { /* try next */ }
|
|
41
|
+
}
|
|
42
|
+
throw new Error(
|
|
43
|
+
'Could not locate slice templates — expected manifest.json at one of:\n ' +
|
|
44
|
+
candidates.join('\n ')
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function loadManifest() {
|
|
49
|
+
const dir = resolveTemplatesDir();
|
|
50
|
+
const raw = fs.readFileSync(path.join(dir, 'manifest.json'), 'utf8');
|
|
51
|
+
let manifest;
|
|
52
|
+
try { manifest = JSON.parse(raw); }
|
|
53
|
+
catch (err) { throw new Error(`Could not parse manifest.json: ${err.message}`); }
|
|
54
|
+
if (!manifest || !Array.isArray(manifest.templates)) {
|
|
55
|
+
throw new Error('manifest.json missing or has invalid `templates` array');
|
|
56
|
+
}
|
|
57
|
+
return { dir, manifest };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function listTemplates({ quiet = false } = {}) {
|
|
61
|
+
const { manifest } = loadManifest();
|
|
62
|
+
if (quiet) return manifest.templates;
|
|
63
|
+
console.log('');
|
|
64
|
+
console.log('\x1b[36m=== VoxFlow Slice — Curated templates ===\x1b[0m');
|
|
65
|
+
console.log('');
|
|
66
|
+
for (const t of manifest.templates) {
|
|
67
|
+
console.log(` \x1b[36m${t.id}\x1b[0m \x1b[2m[${t.theme}]\x1b[0m`);
|
|
68
|
+
console.log(` ${t.name}`);
|
|
69
|
+
console.log(` \x1b[2m${t.description}\x1b[0m`);
|
|
70
|
+
console.log('');
|
|
71
|
+
}
|
|
72
|
+
console.log('Fork with: \x1b[1mvoxflow slice fork <id>\x1b[0m');
|
|
73
|
+
console.log('');
|
|
74
|
+
return manifest.templates;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Copy one template's deck.json to the user's chosen output path.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} templateId Must match `manifest.templates[*].id`.
|
|
81
|
+
* @param {string} [outputPath] Absolute path; default = `<cwd>/<id>-deck.json`.
|
|
82
|
+
* @returns {{ templateId, theme, outputPath }}
|
|
83
|
+
*/
|
|
84
|
+
function forkTemplate(templateId, outputPath) {
|
|
85
|
+
const { dir, manifest } = loadManifest();
|
|
86
|
+
const tpl = manifest.templates.find((it) => it && it.id === templateId);
|
|
87
|
+
if (!tpl) {
|
|
88
|
+
const valid = manifest.templates.map((it) => it.id).join(', ');
|
|
89
|
+
throw new Error(`Unknown template "${templateId}". Valid: ${valid}`);
|
|
90
|
+
}
|
|
91
|
+
const srcPath = path.join(dir, templateId, 'deck.json');
|
|
92
|
+
let raw;
|
|
93
|
+
try { raw = fs.readFileSync(srcPath, 'utf8'); }
|
|
94
|
+
catch (err) { throw new Error(`Template asset missing or unreadable: ${srcPath} (${err.message})`); }
|
|
95
|
+
let deck;
|
|
96
|
+
try { deck = JSON.parse(raw); }
|
|
97
|
+
catch (err) { throw new Error(`Template ${templateId} has invalid JSON: ${err.message}`); }
|
|
98
|
+
// Validate before copying so a corrupted template fails fast rather than
|
|
99
|
+
// landing on disk and confusing the user later.
|
|
100
|
+
validatePaperSlideDeck(deck);
|
|
101
|
+
// Build the default filename outside `path.resolve` so ncc's static
|
|
102
|
+
// asset analyzer doesn't rewrite the templated literal as a bundled
|
|
103
|
+
// asset path (it would land at node_modules/voxflow/dist/cli/<id>-deck.json
|
|
104
|
+
// instead of the user's cwd). See scripts/check-no-asset-rewrite.js
|
|
105
|
+
// for the rule. Same fix pattern as resolveServeUrl in slice-render.js.
|
|
106
|
+
const defaultName = templateId + '-deck.json';
|
|
107
|
+
const out = outputPath || path.resolve(process.cwd(), defaultName);
|
|
108
|
+
const outDir = path.dirname(out);
|
|
109
|
+
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
|
|
110
|
+
fs.writeFileSync(out, raw, 'utf8');
|
|
111
|
+
return { templateId, theme: tpl.theme, outputPath: out };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function handle(args) {
|
|
115
|
+
const { parseFlag } = require('../core/args');
|
|
116
|
+
if (args.includes('--list') || args.includes('-l')) {
|
|
117
|
+
listTemplates();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const output = parseFlag(args, '--output', '-o');
|
|
121
|
+
const positional = args.find((a) => !a.startsWith('-') && !a.startsWith('--'));
|
|
122
|
+
if (!positional) {
|
|
123
|
+
console.error('Usage: voxflow slice fork <template-id> [--output <out.json>]');
|
|
124
|
+
console.error(' voxflow slice fork --list');
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
let result;
|
|
128
|
+
try {
|
|
129
|
+
result = forkTemplate(positional, output ? path.resolve(output) : null);
|
|
130
|
+
} catch (err) {
|
|
131
|
+
console.error(`\nslice fork failed: ${err.message}`);
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
console.log('');
|
|
135
|
+
console.log('\x1b[36m=== VoxFlow Slice — Fork ===\x1b[0m');
|
|
136
|
+
console.log(`Template: ${result.templateId} (${result.theme})`);
|
|
137
|
+
console.log(`Wrote: ${result.outputPath}`);
|
|
138
|
+
console.log('');
|
|
139
|
+
console.log('Next:');
|
|
140
|
+
console.log(` voxflow slice preview ${result.outputPath} # browser preview + audition + render`);
|
|
141
|
+
console.log(` voxflow slice render ${result.outputPath} # render directly to mp4`);
|
|
142
|
+
console.log('');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
module.exports = {
|
|
146
|
+
handle,
|
|
147
|
+
forkTemplate,
|
|
148
|
+
listTemplates,
|
|
149
|
+
loadManifest,
|
|
150
|
+
resolveTemplatesDir,
|
|
151
|
+
};
|
|
@@ -44,6 +44,10 @@ function buildInputProps(deck, opts = {}) {
|
|
|
44
44
|
// .voiceoverSrc so the composition's <Audio> element fetches it during
|
|
45
45
|
// Remotion's headless render.
|
|
46
46
|
const voiceoverByIdx = opts.voiceoverByIdx || {};
|
|
47
|
+
// Map of cardIdx → image URL produced by prepareImages (or empty when no
|
|
48
|
+
// images are registered + no card.imageUrl). Themes that consume slide
|
|
49
|
+
// .imageUrl (photo-feature / atmospheric) pick it up; others ignore.
|
|
50
|
+
const imageByIdx = opts.imageByIdx || {};
|
|
47
51
|
const numberBadge = null;
|
|
48
52
|
const cards = deck.cards.map((card, i) => {
|
|
49
53
|
const slide = {
|
|
@@ -56,7 +60,12 @@ function buildInputProps(deck, opts = {}) {
|
|
|
56
60
|
seriesTagline: deck.seriesTagline,
|
|
57
61
|
voiceoverSrc: voiceoverByIdx[i] || null,
|
|
58
62
|
numberBadge,
|
|
59
|
-
|
|
63
|
+
// Prefer generated/cached AI image over the deck's flat
|
|
64
|
+
// card.imageUrl when both are present, so a card that registers
|
|
65
|
+
// `images: [{...}]` always renders the AI variant. External
|
|
66
|
+
// imageUrl is the fallback for themes that haven't been wired
|
|
67
|
+
// to the generation path yet.
|
|
68
|
+
imageUrl: imageByIdx[i] || card.imageUrl,
|
|
60
69
|
};
|
|
61
70
|
if (deck.variantId) slide.variantId = deck.variantId;
|
|
62
71
|
return { slide, durationSec: card.durationSec || DEFAULT_CARD_SEC };
|
|
@@ -186,42 +195,46 @@ async function render(opts) {
|
|
|
186
195
|
const outputDir = path.dirname(outputPath);
|
|
187
196
|
if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true });
|
|
188
197
|
|
|
189
|
-
// ─── Voiceover prep (Phase 1)
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
// Skip the whole pass on --no-audio (back-compat with Phase 0 silent).
|
|
198
|
+
// ─── Voiceover + Image prep (Phase 1 + Phase B) ─────────────────────
|
|
199
|
+
// One localhost media server proxies both TTS audition cache (/audio/)
|
|
200
|
+
// and image-gen cache (/image/) to Remotion's headless Chromium during
|
|
201
|
+
// render. Shared so we only burn one port + tear-down hook.
|
|
194
202
|
const includeAudio = opts.noAudio !== true;
|
|
203
|
+
const includeImages = opts.noImages !== true;
|
|
195
204
|
let voiceoverByIdx = {};
|
|
205
|
+
let imageByIdx = {};
|
|
196
206
|
let voiceoverServer = null;
|
|
197
207
|
let voiceoverSkipped = [];
|
|
198
|
-
|
|
208
|
+
let imageSkipped = [];
|
|
209
|
+
if (includeAudio || includeImages) {
|
|
199
210
|
const { createTtsAuditionClient } = require('../stage-core/tts-audition');
|
|
200
|
-
const {
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
auditionClient: audClient,
|
|
208
|
-
baseUrl: voiceoverServer.url,
|
|
209
|
-
onProgress: (p) => {
|
|
210
|
-
if (p.fromCache) cacheCount += 1; else synthCount += 1;
|
|
211
|
-
process.stdout.write(
|
|
212
|
-
`\r[slice render] voiceover ${p.cardIdx + 1}/${p.total} ` +
|
|
213
|
-
`(${p.fromCache ? 'cache' : 'synth'}) `
|
|
214
|
-
);
|
|
215
|
-
},
|
|
211
|
+
const { createImageGenClient } = require('../stage-core/image-gen');
|
|
212
|
+
const { startVoiceoverServer, prepareVoiceovers, prepareImages } = require('../stage-core/voiceover-mux');
|
|
213
|
+
const audClient = includeAudio ? createTtsAuditionClient() : null;
|
|
214
|
+
const imgClient = includeImages ? createImageGenClient() : null;
|
|
215
|
+
voiceoverServer = await startVoiceoverServer({
|
|
216
|
+
cacheDir: audClient ? audClient.cacheDir : require('os').tmpdir(),
|
|
217
|
+
imageCacheDir: imgClient ? imgClient.cacheDir : null,
|
|
216
218
|
});
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
219
|
+
if (includeAudio) {
|
|
220
|
+
const prep = await prepareVoiceovers({
|
|
221
|
+
deck,
|
|
222
|
+
auditionClient: audClient,
|
|
223
|
+
baseUrl: voiceoverServer.url,
|
|
224
|
+
onProgress: (p) => {
|
|
225
|
+
process.stdout.write(
|
|
226
|
+
`\r[slice render] voiceover ${p.cardIdx + 1}/${p.total} ` +
|
|
227
|
+
`(${p.fromCache ? 'cache' : 'synth'}) `
|
|
228
|
+
);
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
voiceoverByIdx = prep.byIdx;
|
|
232
|
+
voiceoverSkipped = prep.skipped;
|
|
233
|
+
if (Object.keys(voiceoverByIdx).length > 0) process.stdout.write('\n');
|
|
221
234
|
const fatal = voiceoverSkipped.find(
|
|
222
235
|
(s) => s.reason === 'not_logged_in' || s.reason === 'quota_exceeded'
|
|
223
236
|
);
|
|
224
|
-
if (fatal) {
|
|
237
|
+
if (fatal && Object.keys(voiceoverByIdx).length === 0) {
|
|
225
238
|
console.warn(
|
|
226
239
|
`[slice render] ⚠ audio skipped — ${fatal.reason}` +
|
|
227
240
|
(fatal.message ? `: ${fatal.message}` : '') +
|
|
@@ -229,10 +242,37 @@ async function render(opts) {
|
|
|
229
242
|
);
|
|
230
243
|
}
|
|
231
244
|
}
|
|
245
|
+
if (includeImages) {
|
|
246
|
+
const prep = await prepareImages({
|
|
247
|
+
deck,
|
|
248
|
+
imgClient,
|
|
249
|
+
baseUrl: voiceoverServer.url,
|
|
250
|
+
onProgress: (p) => {
|
|
251
|
+
process.stdout.write(
|
|
252
|
+
`\r[slice render] image ${p.cardIdx + 1}/${p.total} ` +
|
|
253
|
+
`(${p.fromCache ? 'cache' : 'gen'}) `
|
|
254
|
+
);
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
imageByIdx = prep.byIdx;
|
|
258
|
+
imageSkipped = prep.skipped;
|
|
259
|
+
if (Object.keys(imageByIdx).length > 0) process.stdout.write('\n');
|
|
260
|
+
const fatalImg = imageSkipped.find(
|
|
261
|
+
(s) => s.reason === 'not_logged_in' || s.reason === 'quota_exceeded'
|
|
262
|
+
);
|
|
263
|
+
if (fatalImg && Object.keys(imageByIdx).length === 0
|
|
264
|
+
&& !voiceoverSkipped.some((s) => s.reason === fatalImg.reason)) {
|
|
265
|
+
// Avoid double-warning when audio + image fail the same way.
|
|
266
|
+
console.warn(
|
|
267
|
+
`[slice render] ⚠ images skipped — ${fatalImg.reason}` +
|
|
268
|
+
(fatalImg.message ? `: ${fatalImg.message}` : '')
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
232
272
|
}
|
|
233
273
|
|
|
234
274
|
const serveUrl = resolveServeUrl();
|
|
235
|
-
const inputProps = buildInputProps(deck, { voiceoverByIdx });
|
|
275
|
+
const inputProps = buildInputProps(deck, { voiceoverByIdx, imageByIdx });
|
|
236
276
|
|
|
237
277
|
// Lazy require so users who never run `slice render` don't pay the
|
|
238
278
|
// remotion install cost at CLI startup (renderer pulls in puppeteer-
|
|
@@ -312,6 +352,8 @@ async function render(opts) {
|
|
|
312
352
|
size: stat.size,
|
|
313
353
|
voiceoverCount: Object.keys(voiceoverByIdx).length,
|
|
314
354
|
voiceoverSkipped,
|
|
355
|
+
imageCount: Object.keys(imageByIdx).length,
|
|
356
|
+
imageSkipped,
|
|
315
357
|
};
|
|
316
358
|
}
|
|
317
359
|
|
|
@@ -319,15 +361,16 @@ async function handle(args) {
|
|
|
319
361
|
const { parseFlag } = require('../core/args');
|
|
320
362
|
const output = parseFlag(args, '--output', '-o');
|
|
321
363
|
const noAudio = args.includes('--no-audio');
|
|
364
|
+
const noImages = args.includes('--no-images');
|
|
322
365
|
const positional = args.find(
|
|
323
366
|
(a) => !a.startsWith('-') && !a.startsWith('--')
|
|
324
367
|
);
|
|
325
368
|
if (!positional) {
|
|
326
|
-
console.error('Usage: voxflow slice render <deck.json> [--output out.mp4] [--no-audio]');
|
|
369
|
+
console.error('Usage: voxflow slice render <deck.json> [--output out.mp4] [--no-audio] [--no-images]');
|
|
327
370
|
process.exit(1);
|
|
328
371
|
}
|
|
329
372
|
try {
|
|
330
|
-
await render({ deckPath: positional, output, noAudio });
|
|
373
|
+
await render({ deckPath: positional, output, noAudio, noImages });
|
|
331
374
|
} catch (err) {
|
|
332
375
|
console.error(`\nslice render failed: ${err.message}`);
|
|
333
376
|
if (process.env.VOXFLOW_DEBUG) console.error(err.stack);
|
|
@@ -21,6 +21,7 @@ const { createSnapshotStore } = require('../stage-core/snapshot-store');
|
|
|
21
21
|
const { createCloudRenderClient } = require('../stage-core/cloud-render');
|
|
22
22
|
const { startLocalRender, getJobStatus } = require('../stage-core/local-render');
|
|
23
23
|
const { createTtsAuditionClient } = require('../stage-core/tts-audition');
|
|
24
|
+
const { createImageGenClient } = require('../stage-core/image-gen');
|
|
24
25
|
const { validatePaperSlideDeck, isV2LayoutTreeDeck } = require('../internal/deck-validator');
|
|
25
26
|
const { renderSliceStageHtml } = require('../stage-ui/slice/template');
|
|
26
27
|
const { emit: emitTelemetry } = require('../core/telemetry');
|
|
@@ -221,6 +222,35 @@ async function startSliceStage(opts) {
|
|
|
221
222
|
};
|
|
222
223
|
})();
|
|
223
224
|
|
|
225
|
+
// ─── Image generation bridge (per-card 🎨 on stage UI) ────────────────
|
|
226
|
+
// Resolves `card.images[i]` against the live deck snapshot at request
|
|
227
|
+
// time. Image bytes are cached on disk by content hash so re-renders on
|
|
228
|
+
// the same (prompt, aspect, quality) cost zero quota after first gen.
|
|
229
|
+
const imagineBridge = opts.imagine || (() => {
|
|
230
|
+
const imgClient = opts.imageClient || createImageGenClient(opts.imageClientOpts || {});
|
|
231
|
+
return {
|
|
232
|
+
async play({ cardIndex, imageId }) {
|
|
233
|
+
if (!snapshot.deck) return { code: 'no_deck', message: 'no deck loaded' };
|
|
234
|
+
const cards = Array.isArray(snapshot.deck.cards) ? snapshot.deck.cards : [];
|
|
235
|
+
const card = cards[cardIndex];
|
|
236
|
+
if (!card) return { code: 'card_not_found', message: `no card at index ${cardIndex}` };
|
|
237
|
+
const images = Array.isArray(card.images) ? card.images : [];
|
|
238
|
+
const img = images.find((it) => it && it.id === imageId);
|
|
239
|
+
if (!img) {
|
|
240
|
+
return {
|
|
241
|
+
code: 'image_not_found',
|
|
242
|
+
message: `card ${cardIndex} has no image with id "${imageId}"`,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
return imgClient.imagine({
|
|
246
|
+
prompt: img.prompt,
|
|
247
|
+
aspect: img.aspect,
|
|
248
|
+
quality: img.quality,
|
|
249
|
+
});
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
})();
|
|
253
|
+
|
|
224
254
|
// Boot-time auth probe so the UI can emphasise local vs cloud render.
|
|
225
255
|
// We treat any cached, non-expired token as "logged in"; the actual
|
|
226
256
|
// request flow still revalidates on /api/quota-balance.
|
|
@@ -238,6 +268,7 @@ async function startSliceStage(opts) {
|
|
|
238
268
|
localRender: localRenderBridge,
|
|
239
269
|
deckSaver: deckSaverBridge,
|
|
240
270
|
audition: auditionBridge,
|
|
271
|
+
imagine: imagineBridge,
|
|
241
272
|
publishEvent: bus.publish,
|
|
242
273
|
tokenAvailable,
|
|
243
274
|
preferredPort,
|
package/lib/commands/slice.js
CHANGED
|
@@ -214,6 +214,13 @@ async function handle(args) {
|
|
|
214
214
|
const sliceStage = require('./slice-stage');
|
|
215
215
|
return sliceStage.handle(args.slice(1));
|
|
216
216
|
}
|
|
217
|
+
// `voxflow slice fork <template-id>` — copy a curated template's
|
|
218
|
+
// deck.json into the user's cwd. No quota / network — assets ship in
|
|
219
|
+
// the published `skills/voxflow-slice/templates/` directory.
|
|
220
|
+
if (args.length > 0 && args[0] === 'fork') {
|
|
221
|
+
const sliceFork = require('./slice-fork');
|
|
222
|
+
return sliceFork.handle(args.slice(1));
|
|
223
|
+
}
|
|
217
224
|
|
|
218
225
|
const { parseFlag, runWithRetry } = require('../core/args');
|
|
219
226
|
const { getToken, getTokenInfo } = require('../core/auth');
|
|
@@ -330,6 +337,8 @@ const meta = {
|
|
|
330
337
|
'voxflow slice stage deck.json # local preview, hot-reload',
|
|
331
338
|
'voxflow slice stage deck.json --port 5180 --no-open',
|
|
332
339
|
'voxflow slice render deck.json --output out.mp4 # offline render (no quota, no cloud)',
|
|
340
|
+
'voxflow slice fork --list # browse curated templates',
|
|
341
|
+
'voxflow slice fork product-launch # copy a curated template to cwd',
|
|
333
342
|
],
|
|
334
343
|
},
|
|
335
344
|
};
|