reelforge 1.24.2 → 1.25.0

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.
Files changed (45) hide show
  1. package/README.md +8 -295
  2. package/package.json +7 -44
  3. package/bin/reelforge.js +0 -8
  4. package/dist/client.js +0 -120
  5. package/dist/commands/assets-workflow-text.js +0 -22
  6. package/dist/commands/assets.js +0 -243
  7. package/dist/commands/audio.js +0 -91
  8. package/dist/commands/auth.js +0 -170
  9. package/dist/commands/bgm.js +0 -59
  10. package/dist/commands/clip.js +0 -139
  11. package/dist/commands/compose.js +0 -298
  12. package/dist/commands/compositions.js +0 -148
  13. package/dist/commands/config.js +0 -62
  14. package/dist/commands/content.js +0 -66
  15. package/dist/commands/cover.js +0 -421
  16. package/dist/commands/create.js +0 -638
  17. package/dist/commands/dub.js +0 -220
  18. package/dist/commands/extract.js +0 -115
  19. package/dist/commands/fetch.js +0 -129
  20. package/dist/commands/files.js +0 -70
  21. package/dist/commands/health.js +0 -12
  22. package/dist/commands/history.js +0 -44
  23. package/dist/commands/images.js +0 -107
  24. package/dist/commands/llm.js +0 -67
  25. package/dist/commands/media.js +0 -136
  26. package/dist/commands/models.js +0 -36
  27. package/dist/commands/pipelines.js +0 -142
  28. package/dist/commands/platform.js +0 -218
  29. package/dist/commands/regen.js +0 -134
  30. package/dist/commands/render.js +0 -82
  31. package/dist/commands/script.js +0 -128
  32. package/dist/commands/styles.js +0 -113
  33. package/dist/commands/subtitles.js +0 -246
  34. package/dist/commands/tasks.js +0 -59
  35. package/dist/commands/tts.js +0 -134
  36. package/dist/commands/vlog.js +0 -91
  37. package/dist/data/vlog/general.md +0 -129
  38. package/dist/data/vlog/pet.md +0 -160
  39. package/dist/index.js +0 -183
  40. package/dist/utils/config-file.js +0 -37
  41. package/dist/utils/download.js +0 -13
  42. package/dist/utils/file-upload.js +0 -102
  43. package/dist/utils/oss-upload.js +0 -44
  44. package/dist/utils/output.js +0 -91
  45. package/dist/utils/task-waiter.js +0 -40
@@ -1,66 +0,0 @@
1
- import fs from "node:fs/promises";
2
- import { post } from "../client.js";
3
- import { print } from "../utils/output.js";
4
- export function registerContent(program) {
5
- const content = program
6
- .command("content")
7
- .description("内容原子能力: scene-plan (脚本 + 时长 → 分镜 + 每镜图像 prompt)")
8
- .helpOption("-h, --help", "show help");
9
- content
10
- .command("scene-plan")
11
- .description("Generate a master script + per-scene image prompts (replaces narration/image-prompts/title)")
12
- .helpOption("-h, --help", "show help")
13
- .option("-t, --topic <text>", "video topic; AI writes the script (generate mode). Use @file for disk input.")
14
- .option("--script <text>", "your own master script text (fixed mode). Use @file for disk input.")
15
- .option("-d, --duration <sec>", "target video duration in seconds (generate mode; default 45)", (v) => parseInt(v, 10))
16
- .option("-p, --pace <pace>", "visual rhythm hint: slow | normal | fast (default normal)")
17
- .option("-m, --model <id>", "override LLM model")
18
- .addHelpText("after", [
19
- "",
20
- "Two modes (exactly one required):",
21
- " generate -t / --topic <text> LLM writes both script and image prompts",
22
- " fixed --script @file or text LLM only segments + writes image prompts; text unchanged verbatim",
23
- "",
24
- "Examples:",
25
- " rf content scene-plan -t '深夜便利店' -d 60 -p slow",
26
- " rf content scene-plan --script @./my-script.txt -p fast",
27
- " rf content scene-plan -t '雨天的玻璃窗' --json | jq .scenes",
28
- ].join("\n"))
29
- .action(async (opts) => {
30
- const hasTopic = typeof opts.topic === "string" && opts.topic.length > 0;
31
- const hasScript = typeof opts.script === "string" && opts.script.length > 0;
32
- if (!hasTopic && !hasScript) {
33
- throw new Error("either --topic / -t or --script is required");
34
- }
35
- if (hasTopic && hasScript) {
36
- throw new Error("--topic and --script are mutually exclusive");
37
- }
38
- if (opts.pace && !["slow", "normal", "fast"].includes(opts.pace)) {
39
- throw new Error(`--pace must be one of slow|normal|fast (got: ${opts.pace})`);
40
- }
41
- let topic = opts.topic;
42
- let script = opts.script;
43
- if (topic?.startsWith("@"))
44
- topic = (await fs.readFile(topic.slice(1), "utf-8")).trim();
45
- if (script?.startsWith("@"))
46
- script = (await fs.readFile(script.slice(1), "utf-8")).trim();
47
- const body = {};
48
- if (topic)
49
- body.topic = topic;
50
- if (script)
51
- body.script = script;
52
- if (opts.duration !== undefined)
53
- body.duration = opts.duration;
54
- if (opts.pace)
55
- body.pace = opts.pace;
56
- if (opts.model)
57
- body.model = opts.model;
58
- const r = await post("/api/v1/content/scene-plan", body);
59
- print({
60
- mode: r.mode,
61
- title: r.title,
62
- n_scenes: r.scenes.length,
63
- scenes: r.scenes,
64
- });
65
- });
66
- }
@@ -1,421 +0,0 @@
1
- import fs from "node:fs/promises";
2
- import fsSync from "node:fs";
3
- import path from "node:path";
4
- import { getServer, getApiKey } from "../client.js";
5
- import { uploadToOss } from "../utils/oss-upload.js";
6
- import { info, success, warn } from "../utils/output.js";
7
- const MAX_BYTES = 50 * 1024 * 1024;
8
- const TEMPLATES = [
9
- { id: "paper-band", label: "牛皮纸条 横卡", supports: "title, subtitle, badge", useFor: "财经 / 干货 / 商业分析" },
10
- { id: "carbon-copy", label: "复写纸 暗调", supports: "title, subtitle", useFor: "情感 / 哲思 / 治愈(默认)" },
11
- { id: "chalkboard", label: "黑板 粉笔", supports: "title, subtitle, badge", useFor: "教育 / 科普 / 课程" },
12
- { id: "book-spine", label: "书脊 + 卡片", supports: "title, subtitle, badge", useFor: "书单 / 读书笔记" },
13
- { id: "polaroid", label: "拍立得 相纸", supports: "title, subtitle", useFor: "生活 / vlog / 日常" },
14
- { id: "recipe-card", label: "食谱 米色卡", supports: "title, subtitle, badge", useFor: "美食 / 菜谱" },
15
- { id: "spec-sheet", label: "工业 规格表", supports: "title, subtitle, badge", useFor: "数码 / 汽车 / 评测" },
16
- { id: "vintage-stamp", label: "牛皮纸 + 红印章", supports: "title, subtitle, badge", useFor: "历史 / 经典 / 怀旧" },
17
- { id: "magazine", label: "杂志 italic 衬线", supports: "title, subtitle, badge", useFor: "时尚 / 娱乐 / 人物特写" },
18
- ];
19
- const TEMPLATE_IDS = new Set(TEMPLATES.map((t) => t.id));
20
- const DEFAULT_TEMPLATE = "carbon-copy";
21
- const SMALL_IMAGE_WARN_BYTES = 100 * 1024;
22
- async function uploadOrInline(fileAbs, fileMime, label) {
23
- const bytes = (await fs.stat(fileAbs)).size;
24
- try {
25
- const key = await uploadToOss(fileAbs);
26
- return { key, bytes };
27
- }
28
- catch (e) {
29
- if (bytes > MAX_BYTES) {
30
- throw new Error(`${label} 直传 OSS 失败 (${e instanceof Error ? e.message : e}),且文件 ${fmtSize(bytes)} 超过内联回退上限 ${fmtSize(MAX_BYTES)}。`);
31
- }
32
- warn(`${label}: OSS 直传不可用,回退内联上传 (${e instanceof Error ? e.message : e})`);
33
- const buf = await fs.readFile(fileAbs);
34
- return { uri: `data:${fileMime};base64,${buf.toString("base64")}`, bytes };
35
- }
36
- }
37
- async function resolveImage(input) {
38
- const t = input.trim();
39
- if (!t)
40
- throw new Error("--image: empty value");
41
- if (/^https?:\/\//i.test(t))
42
- return { url: t };
43
- if (t.startsWith("data:"))
44
- return { url: t };
45
- const abs = path.resolve(t);
46
- if (!fsSync.existsSync(abs)) {
47
- throw new Error(`--image: local file not found: ${abs}`);
48
- }
49
- const ext = path.extname(abs).toLowerCase();
50
- const mime = ext === ".png" ? "image/png" :
51
- ext === ".jpg" || ext === ".jpeg" ? "image/jpeg" :
52
- ext === ".webp" ? "image/webp" :
53
- null;
54
- if (!mime) {
55
- throw new Error(`--image: unsupported extension ${ext} (use png/jpg/jpeg/webp)`);
56
- }
57
- const up = await uploadOrInline(abs, mime, "--image");
58
- return { key: up.key, url: up.uri, bytes: up.bytes };
59
- }
60
- function fmtSize(b) {
61
- if (b < 1024)
62
- return `${b} B`;
63
- if (b < 1024 * 1024)
64
- return `${(b / 1024).toFixed(1)} KB`;
65
- return `${(b / 1024 / 1024).toFixed(1)} MB`;
66
- }
67
- function buildParamsPayload(pairs, bulkJson) {
68
- if ((!pairs || pairs.length === 0) && !bulkJson)
69
- return undefined;
70
- const out = {};
71
- if (bulkJson) {
72
- let parsed;
73
- try {
74
- parsed = JSON.parse(bulkJson);
75
- }
76
- catch (e) {
77
- throw new Error(`--params: not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
78
- }
79
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
80
- throw new Error("--params: must be a JSON object (not array or scalar)");
81
- }
82
- Object.assign(out, parsed);
83
- }
84
- for (const pair of pairs ?? []) {
85
- const eq = pair.indexOf("=");
86
- if (eq <= 0) {
87
- throw new Error(`--param: expected "key=value" form, got "${pair}"`);
88
- }
89
- const key = pair.slice(0, eq).trim();
90
- const rawVal = pair.slice(eq + 1);
91
- if (!key)
92
- throw new Error(`--param: empty key in "${pair}"`);
93
- out[key] = coerceParamValue(rawVal);
94
- }
95
- return out;
96
- }
97
- function coerceParamValue(raw) {
98
- const t = raw.trim();
99
- if (t === "true")
100
- return true;
101
- if (t === "false")
102
- return false;
103
- if (t === "null")
104
- return null;
105
- if (/^-?\d+(\.\d+)?$/.test(t))
106
- return Number(t);
107
- if ((t.startsWith("{") && t.endsWith("}")) || (t.startsWith("[") && t.endsWith("]"))) {
108
- try {
109
- return JSON.parse(t);
110
- }
111
- catch {
112
- }
113
- }
114
- return raw;
115
- }
116
- export function registerCover(program) {
117
- const templateList = TEMPLATES
118
- .map((t) => ` ${t.id.padEnd(13)} ${t.label.padEnd(26)} 适合: ${t.useFor}\n ${"".padEnd(13)} 支持字段: ${t.supports}`)
119
- .join("\n\n");
120
- const coverCmd = program
121
- .command("cover")
122
- .description("渲染 1080×1920 竖屏封面 PNG (9 个模板可选, 每个模板有自己的字号/配色/装饰参数)")
123
- .helpOption("-h, --help", "show help")
124
- .option("-i, --image <pathOrUrl>", "封面背景图 — 本地文件路径 / http(s) URL / data: URI")
125
- .option("-t, --title <text>", "封面大标题(必填,短句更佳,12 字内最稳;长标题模板会自动选小一档字号,不会断行错乱)")
126
- .option("--subtitle <text>", "副标题 / 一句话标语。不是所有模板都显示 — 具体看 `rf cover templates --id <id>` 列出的 supports.subtitle 字段(=true 才会渲染,否则静默忽略)")
127
- .option("--badge <text>", "角标 / 期号 / 紧急标,如 'EP.042' / '突发' / 'FLASH'。不是所有模板都显示 — 同样看 `rf cover templates --id <id>` 的 supports.badge 字段")
128
- .option("--template <id>", `封面风格,共 9 个(default: ${DEFAULT_TEMPLATE})。用户不确定选哪个时,agent 先跑 \`rf cover templates --json\` 把 9 张 preview_url 给用户看,看完图再传 id。详见下面"封面模板"段的清单`)
129
- .option("--param <key=value>", "模板特有参数,如 --param titleSize=M。可多次传; --param 优先级高于 --params 的同名键。运行 `rf cover templates --id <id>` 查看某模板支持哪些 params", (val, prev = []) => [...prev, val], [])
130
- .option("--params <json>", "模板特有参数,一次性传一个 JSON 对象,如 --params '{\"titleSize\":\"M\",\"dividerChar\":\"···\"}'。会与 --param 合并,后者优先")
131
- .option("-o, --output <file>", "输出 PNG 文件路径(如 cover.png)")
132
- .addHelpText("after", [
133
- "",
134
- "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
135
- "AGENT 三步上手 (LLM / 脚本调用必读)",
136
- "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
137
- " Step 1. 看清单选模板:",
138
- " rf cover templates --json # 9 个模板的 id/label/supports/params 全在 JSON 里",
139
- "",
140
- " Step 2. 看选中模板的特有 params (字号档 / 颜色 / 装饰元素 等):",
141
- " rf cover templates --id carbon-copy --json",
142
- "",
143
- " Step 3. 按 params 描述里的 options / default 调用渲染:",
144
- " rf cover -i ./scene.png -t '今天的咖啡冷得很慢' --subtitle '清晨手记' \\",
145
- " --template carbon-copy --param titleSize=M --param 'dividerChar=···' \\",
146
- " -o cover.png",
147
- "",
148
- " 字号档兜底: --param titleSize=auto (默认) 会按标题字数自动选档,大概率不必显式传。",
149
- " 传错 param 时 server 返回 400 + 具体 issues + 提示再跑 `rf cover templates --id <id>`,",
150
- " 按提示纠正即可,不要盲猜。",
151
- "",
152
- "封面模板清单 (--template):",
153
- templateList,
154
- "",
155
- " 上面 \"支持字段\" 仅说 subtitle/badge 是否生效;每个模板的额外 params 见 step 2。",
156
- "",
157
- "模板特有参数 (--param / --params):",
158
- " 每个模板可以额外接受自己专属的参数 (字号档 / 颜色 / 装饰元素 等),",
159
- " 这些参数因模板而异。运行 `rf cover templates --id <id>` 可以列出某个模板",
160
- " 支持的全部 params 及其默认值。",
161
- "",
162
- " 例: --param titleSize=M --param dividerChar='···'",
163
- " --params '{\"titleSize\":\"M\",\"accentColor\":\"#C9D9E8\"}'",
164
- "",
165
- "Examples:",
166
- " rf cover -i ./scene.png -t '巴菲特又出手了' --badge 'EP.042' \\",
167
- " --subtitle 'MAGE · 美股科普' -o ./out/cover.png",
168
- "",
169
- " rf cover -i https://example.com/bg.jpg -t '美联储再降息' \\",
170
- " --template magazine --badge '突发' -o cover.png",
171
- "",
172
- " # 用模板特有参数压字号档 + 换分隔符:",
173
- " rf cover -i ./scene.png -t '今天的咖啡冷得很慢' \\",
174
- " --template carbon-copy --param titleSize=S --param dividerChar='···' \\",
175
- " --subtitle '清晨手记' -o cover.png",
176
- "",
177
- " # Use \\n in the title to force a line break:",
178
- " rf cover -i scene.png -t '美股科技股\\n急跌' --template magazine \\",
179
- " --badge 'FLASH' -o cover.png",
180
- "",
181
- "Output is a 1080×1920 PNG. Background image is auto-scaled with cover-fit;",
182
- "any aspect ratio works. Very small inputs (under 100 KB) will look blurry —",
183
- "recommended source size is 1080×1920 or larger.",
184
- ].join("\n"))
185
- .action(async (opts) => {
186
- const missing = [];
187
- if (!opts.image)
188
- missing.push("--image / -i");
189
- if (!opts.title)
190
- missing.push("--title / -t");
191
- if (!opts.output)
192
- missing.push("--output / -o");
193
- if (missing.length > 0) {
194
- throw new Error(`required: ${missing.join(", ")}`);
195
- }
196
- const { key: imageKey, url: imageUrl, bytes } = await resolveImage(opts.image);
197
- if (typeof bytes === "number" && bytes < SMALL_IMAGE_WARN_BYTES) {
198
- warn(`--image is ${fmtSize(bytes)} — that's quite small, the cover may look blurry. ` +
199
- `For best results use a source image of at least 1080×1920.`);
200
- }
201
- const template = (opts.template ?? DEFAULT_TEMPLATE);
202
- if (!TEMPLATE_IDS.has(template)) {
203
- throw new Error(`--template must be one of ${TEMPLATES.map((t) => t.id).join(" | ")} (got: ${opts.template})`);
204
- }
205
- const params = buildParamsPayload(opts.param, opts.params);
206
- const body = {
207
- template,
208
- title: opts.title,
209
- };
210
- if (imageKey)
211
- body.image_key = imageKey;
212
- else
213
- body.image_url = imageUrl;
214
- if (opts.subtitle)
215
- body.subtitle = opts.subtitle;
216
- if (opts.badge)
217
- body.badge = opts.badge;
218
- if (params)
219
- body.params = params;
220
- info(`Rendering cover (template=${template})...`);
221
- const server = getServer().replace(/\/+$/, "");
222
- const apiKey = getApiKey();
223
- const headers = { "content-type": "application/json" };
224
- if (apiKey)
225
- headers["authorization"] = `Bearer ${apiKey}`;
226
- const resp = await fetch(`${server}/api/v1/cover`, {
227
- method: "POST",
228
- headers,
229
- body: JSON.stringify(body),
230
- });
231
- if (!resp.ok) {
232
- const text = await resp.text().catch(() => "");
233
- throw new Error(`Cover render failed: HTTP ${resp.status}\n${text.slice(0, 500)}`);
234
- }
235
- const outAbs = path.resolve(opts.output);
236
- await fs.mkdir(path.dirname(outAbs), { recursive: true });
237
- const buf = Buffer.from(await resp.arrayBuffer());
238
- await fs.writeFile(outAbs, buf);
239
- success(`Saved → ${outAbs} (${fmtSize(buf.byteLength)})`);
240
- });
241
- coverCmd
242
- .command("prepend <video> <cover>")
243
- .description("把一张封面 PNG 作为首帧贴到已有 MP4 上。无重编码 (~1s 完成), 适合 A/B 测封面或后期换封面。")
244
- .helpOption("-h, --help", "show help")
245
- .option("--out <file>", "输出 MP4 路径 (默认覆盖 <video> 输入文件). 注意: 父命令 `rf cover` 占用了 -o, 这里只用长 flag 避免冲突")
246
- .option("--fps <n>", "目标 fps (默认 24 — 跟主线 pipeline 默认一致, 与原视频 fps 不一致时会按目标 fps 改写时间戳)", (v) => parseInt(v, 10))
247
- .addHelpText("after", [
248
- "",
249
- "Examples:",
250
- " # 默认: 覆盖原文件",
251
- " rf cover prepend ./video.mp4 ./cover.png",
252
- "",
253
- " # 写到新文件 (注意: --out 不是 -o, 因为父命令 rf cover 占用了 -o)",
254
- " rf cover prepend ./video.mp4 ./cover.png --out ./video-with-cover.mp4",
255
- "",
256
- " # 自定义 fps",
257
- " rf cover prepend ./video.mp4 ./cover.png --fps 30",
258
- "",
259
- "性能:",
260
- " 典型 40s 视频处理 ~1s (TS-remux, 无重编码)。",
261
- " 抖音 / TikTok / 视频号在 9 宫格 feed 抓首帧或近首帧作为封面缩略图,",
262
- " 贴一张设计封面可以控制 feed 里出现什么画面。",
263
- ].join("\n"))
264
- .action(async (videoArg, coverArg, opts) => {
265
- const videoAbs = path.resolve(videoArg);
266
- const coverAbs = path.resolve(coverArg);
267
- if (!fsSync.existsSync(videoAbs)) {
268
- throw new Error(`video not found: ${videoAbs}`);
269
- }
270
- if (!fsSync.existsSync(coverAbs)) {
271
- throw new Error(`cover not found: ${coverAbs}`);
272
- }
273
- const coverExt = path.extname(coverAbs).toLowerCase();
274
- const coverMime = coverExt === ".jpg" || coverExt === ".jpeg" ? "image/jpeg" : "image/png";
275
- const coverUp = await uploadOrInline(coverAbs, coverMime, "cover");
276
- const videoUp = await uploadOrInline(videoAbs, "video/mp4", "video");
277
- const body = {};
278
- if (coverUp.key)
279
- body.cover_key = coverUp.key;
280
- else
281
- body.cover_data_uri = coverUp.uri;
282
- if (videoUp.key)
283
- body.video_key = videoUp.key;
284
- else
285
- body.video_data_uri = videoUp.uri;
286
- if (typeof opts.fps === "number")
287
- body.fps = opts.fps;
288
- info(`Prepending cover (${fmtSize(coverUp.bytes)}) → video (${fmtSize(videoUp.bytes)})...`);
289
- const server = getServer().replace(/\/+$/, "");
290
- const apiKey = getApiKey();
291
- const headers = { "content-type": "application/json" };
292
- if (apiKey)
293
- headers["authorization"] = `Bearer ${apiKey}`;
294
- const resp = await fetch(`${server}/api/v1/cover/prepend`, {
295
- method: "POST",
296
- headers,
297
- body: JSON.stringify(body),
298
- });
299
- if (!resp.ok) {
300
- const text = await resp.text().catch(() => "");
301
- throw new Error(`Cover prepend failed: HTTP ${resp.status}\n${text.slice(0, 500)}`);
302
- }
303
- const outAbs = path.resolve(opts.out ?? videoArg);
304
- await fs.mkdir(path.dirname(outAbs), { recursive: true });
305
- const out = Buffer.from(await resp.arrayBuffer());
306
- await fs.writeFile(outAbs, out);
307
- success(`Saved → ${outAbs} (${fmtSize(out.byteLength)})`);
308
- });
309
- coverCmd
310
- .command("templates")
311
- .description("【 discovery / 发现命令 】列出所有封面模板 + 各自的特有 params (字号档 / 颜色 / 装饰元素 等)。" +
312
- "Agent/LLM 在生成 rf cover 命令前必须先跑这个,否则只能盲猜参数。--json 模式直接拿结构化 catalog 喂给上游模型。")
313
- .helpOption("-h, --help", "show help")
314
- .option("--id <templateId>", "只看某个模板的详细信息 (e.g. carbon-copy)")
315
- .option("--json", "输出原始 JSON,适合 agent / 脚本消费 (默认人读表格)")
316
- .addHelpText("after", [
317
- "",
318
- "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
319
- "AGENT 帮用户选封面的标准流程 (LLM 必读)",
320
- "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
321
- " 当用户没明确指定 --cover-template,或说『我不知道选哪个』时,不要替用户",
322
- " 拍板,也不要按 label 文字给用户脑补描述 — 直接走这条路径:",
323
- "",
324
- " Step 1. 拉 catalog (含 preview_url):",
325
- " rf cover templates --json",
326
- "",
327
- " Step 2. 把 9 张 preview_url 用 markdown 图片格式丢给用户 (聊天界面会",
328
- " 直接渲染成缩略图):",
329
- " ![carbon-copy](https://rf-cdn.oss-cn-hangzhou.aliyuncs.com/cover-samples/carbon-copy.png)",
330
- " ![magazine](https://rf-cdn.oss-cn-hangzhou.aliyuncs.com/cover-samples/magazine.png)",
331
- " ... (其余 7 个同样格式)",
332
- "",
333
- " Step 3. 用户照图选,把 id 喂给 `rf create --cover-template <id>` 或",
334
- " `rf cover --template <id>`.",
335
- "",
336
- " 9 张 preview 都是同一个 title=今天的咖啡冷得很慢 / subtitle=清晨手记 /",
337
- " badge=EP.042 渲染,只有模板变量在动,横向对比一目了然.",
338
- "",
339
- "Examples:",
340
- " # 列出所有模板的简要清单",
341
- " rf cover templates",
342
- "",
343
- " # 看 carbon-copy 模板支持哪些特有 params",
344
- " rf cover templates --id carbon-copy",
345
- "",
346
- " # JSON 输出 (agent 必跑这条)",
347
- " rf cover templates --json",
348
- " rf cover templates --id magazine --json",
349
- ].join("\n"))
350
- .action(async (opts) => {
351
- const server = getServer().replace(/\/+$/, "");
352
- const apiKey = getApiKey();
353
- const headers = {};
354
- if (apiKey)
355
- headers["authorization"] = `Bearer ${apiKey}`;
356
- const resp = await fetch(`${server}/api/v1/cover/templates`, { headers });
357
- if (!resp.ok) {
358
- const text = await resp.text().catch(() => "");
359
- throw new Error(`Cover templates fetch failed: HTTP ${resp.status}\n${text.slice(0, 500)}`);
360
- }
361
- const catalog = (await resp.json());
362
- const filtered = opts.id
363
- ? catalog.templates.filter((t) => t.id === opts.id)
364
- : catalog.templates;
365
- if (opts.id && filtered.length === 0) {
366
- throw new Error(`unknown template "${opts.id}". Known: ${catalog.templates.map((t) => t.id).join(", ")}`);
367
- }
368
- if (opts.json) {
369
- const payload = opts.id ? filtered[0] : { default: catalog.default, templates: filtered };
370
- console.log(JSON.stringify(payload, null, 2));
371
- return;
372
- }
373
- if (catalog.preview && !opts.id) {
374
- console.log(`\n样图基线 (preview_url 都是按这组输入渲的):` +
375
- `\n title="${catalog.preview.title}" · subtitle="${catalog.preview.subtitle}" · badge="${catalog.preview.badge}"`);
376
- }
377
- for (const t of filtered) {
378
- console.log(`\n● ${t.id} ${t.label}${t.id === catalog.default ? " (默认)" : ""}`);
379
- if (t.preview_url) {
380
- console.log(` 预览 (直接浏览器打开): ${t.preview_url}`);
381
- }
382
- console.log(` 支持槽位: title (必填) · subtitle (${t.supports.subtitle ? "✓" : "—"}) · badge (${t.supports.badge ? "✓" : "—"})`);
383
- if (t.params.length === 0) {
384
- console.log(` 模板特有 params: 无 (只用 title/subtitle/badge 即可)`);
385
- }
386
- else {
387
- console.log(` 模板特有 params:`);
388
- for (const p of t.params) {
389
- const opts = p.options ? ` [${p.options.join(" | ")}]` : "";
390
- const def = p.default !== undefined ? ` default=${JSON.stringify(p.default)}` : "";
391
- console.log(` - ${p.name} <${p.type}>${opts}${def}`);
392
- console.log(` ${p.desc}`);
393
- }
394
- }
395
- }
396
- if (!opts.id) {
397
- console.log([
398
- "",
399
- "下一步:",
400
- " 1. 挑一个模板 (上面的 ● <id> 行) — 按内容场景选,如教育→chalkboard / 财经→paper-band。",
401
- " 2. 运行 `rf cover templates --id <id>` 看那个模板的特有 params 详细描述 + default。",
402
- " 3. 运行 `rf cover -i <bg> -t <title> --template <id> [--param k=v ...] -o out.png` 出图。",
403
- "",
404
- "Agent / 脚本: 加 --json 拿结构化 catalog 直接喂给上游模型。",
405
- "",
406
- ].join("\n"));
407
- }
408
- else {
409
- console.log([
410
- "",
411
- "示例调用:",
412
- ` rf cover -i ./bg.png -t '示例标题' --template ${opts.id} \\`,
413
- " --param titleSize=M # 其余 params 同样 --param k=v 形式",
414
- " -o out.png",
415
- "",
416
- "传错 param 时 server 返回 400 + Zod issues,按提示纠正即可。",
417
- "",
418
- ].join("\n"));
419
- }
420
- });
421
- }