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