reelforge 1.24.3 → 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.
- package/README.md +4 -291
- package/package.json +4 -41
- package/bin/reelforge.js +0 -8
- package/dist/client.js +0 -120
- package/dist/commands/assets-workflow-text.js +0 -22
- package/dist/commands/assets.js +0 -243
- package/dist/commands/audio.js +0 -91
- package/dist/commands/auth.js +0 -170
- package/dist/commands/bgm.js +0 -59
- package/dist/commands/clip.js +0 -139
- package/dist/commands/compose.js +0 -298
- package/dist/commands/compositions.js +0 -148
- package/dist/commands/config.js +0 -62
- package/dist/commands/content.js +0 -66
- package/dist/commands/cover.js +0 -421
- package/dist/commands/create.js +0 -638
- package/dist/commands/dub.js +0 -220
- package/dist/commands/extract.js +0 -115
- package/dist/commands/fetch.js +0 -129
- package/dist/commands/files.js +0 -70
- package/dist/commands/health.js +0 -12
- package/dist/commands/history.js +0 -44
- package/dist/commands/images.js +0 -107
- package/dist/commands/llm.js +0 -67
- package/dist/commands/media.js +0 -136
- package/dist/commands/models.js +0 -36
- package/dist/commands/pipelines.js +0 -142
- package/dist/commands/platform.js +0 -218
- package/dist/commands/regen.js +0 -134
- package/dist/commands/render.js +0 -82
- package/dist/commands/script.js +0 -128
- package/dist/commands/styles.js +0 -113
- package/dist/commands/subtitles.js +0 -246
- package/dist/commands/tasks.js +0 -59
- package/dist/commands/tts.js +0 -134
- package/dist/commands/vlog.js +0 -91
- package/dist/data/vlog/general.md +0 -129
- package/dist/data/vlog/pet.md +0 -160
- package/dist/index.js +0 -183
- package/dist/utils/config-file.js +0 -37
- package/dist/utils/download.js +0 -13
- package/dist/utils/file-upload.js +0 -102
- package/dist/utils/oss-upload.js +0 -44
- package/dist/utils/output.js +0 -91
- package/dist/utils/task-waiter.js +0 -40
|
@@ -1,218 +0,0 @@
|
|
|
1
|
-
import kleur from "kleur";
|
|
2
|
-
import { isJson, print, table } from "../utils/output.js";
|
|
3
|
-
const CANVAS_W = 1080;
|
|
4
|
-
const CANVAS_H = 1920;
|
|
5
|
-
const IMAGE_SIDE = 1080;
|
|
6
|
-
const SLACK = CANVAS_H - IMAGE_SIDE;
|
|
7
|
-
const PLATFORMS = {
|
|
8
|
-
douyin: {
|
|
9
|
-
label: "Douyin (抖音)",
|
|
10
|
-
top: 250,
|
|
11
|
-
bottom: 400,
|
|
12
|
-
left: 150,
|
|
13
|
-
right: 250,
|
|
14
|
-
centerMax: 820,
|
|
15
|
-
reason: "Cover-crop on tall phones cuts ~96-180px per side; status bar ~250px; bottom caption + progress + buttons ~400px; right action-button column ~150px from the visible edge.",
|
|
16
|
-
recommendedAnchor: 0.40,
|
|
17
|
-
anchorVariants: [
|
|
18
|
-
{ value: 0.50, case: "短描述 / 无评论弹幕(默认)" },
|
|
19
|
-
{ value: 0.40, case: "长描述,留更多底部 matte(推荐)" },
|
|
20
|
-
{ value: 0.30, case: "很长描述 / 评论弹幕活跃" },
|
|
21
|
-
],
|
|
22
|
-
},
|
|
23
|
-
tiktok: {
|
|
24
|
-
label: "TikTok",
|
|
25
|
-
top: 220,
|
|
26
|
-
bottom: 380,
|
|
27
|
-
left: 150,
|
|
28
|
-
right: 240,
|
|
29
|
-
centerMax: 820,
|
|
30
|
-
reason: "Same cover-crop behaviour as Douyin; slightly smaller top tab area and bottom caption.",
|
|
31
|
-
recommendedAnchor: 0.42,
|
|
32
|
-
anchorVariants: [
|
|
33
|
-
{ value: 0.50, case: "short caption(default)" },
|
|
34
|
-
{ value: 0.42, case: "long caption / hashtags(recommended)" },
|
|
35
|
-
{ value: 0.30, case: "verbose caption + many hashtags" },
|
|
36
|
-
],
|
|
37
|
-
},
|
|
38
|
-
wechat: {
|
|
39
|
-
label: "WeChat Channels (视频号)",
|
|
40
|
-
top: 200,
|
|
41
|
-
bottom: 350,
|
|
42
|
-
left: 120,
|
|
43
|
-
right: 120,
|
|
44
|
-
centerMax: 880,
|
|
45
|
-
reason: "Cover-crop similar; right-side action buttons less obtrusive than 抖音/TikTok, so right padding can be smaller.",
|
|
46
|
-
recommendedAnchor: 0.45,
|
|
47
|
-
anchorVariants: [
|
|
48
|
-
{ value: 0.50, case: "短描述(默认)" },
|
|
49
|
-
{ value: 0.45, case: "长描述(推荐)" },
|
|
50
|
-
{ value: 0.30, case: "评论弹幕活跃" },
|
|
51
|
-
],
|
|
52
|
-
},
|
|
53
|
-
};
|
|
54
|
-
const ALIASES = {
|
|
55
|
-
抖音: "douyin",
|
|
56
|
-
dy: "douyin",
|
|
57
|
-
tt: "tiktok",
|
|
58
|
-
视频号: "wechat",
|
|
59
|
-
weixin: "wechat",
|
|
60
|
-
wx: "wechat",
|
|
61
|
-
};
|
|
62
|
-
function resolveKey(input) {
|
|
63
|
-
const lower = input.toLowerCase();
|
|
64
|
-
if (PLATFORMS[lower])
|
|
65
|
-
return lower;
|
|
66
|
-
if (ALIASES[input])
|
|
67
|
-
return ALIASES[input];
|
|
68
|
-
if (ALIASES[lower])
|
|
69
|
-
return ALIASES[lower];
|
|
70
|
-
return null;
|
|
71
|
-
}
|
|
72
|
-
function anchorRange(z) {
|
|
73
|
-
const minAnchor = z.top / SLACK;
|
|
74
|
-
const maxAnchor = (SLACK - z.bottom) / SLACK;
|
|
75
|
-
return [Math.max(0, minAnchor), Math.min(1, maxAnchor)];
|
|
76
|
-
}
|
|
77
|
-
function imageYRange(anchor) {
|
|
78
|
-
const top = Math.round(anchor * SLACK);
|
|
79
|
-
const bottom = top + IMAGE_SIDE;
|
|
80
|
-
return { top, bottom, bottomMatte: CANVAS_H - bottom };
|
|
81
|
-
}
|
|
82
|
-
function printOverview() {
|
|
83
|
-
process.stdout.write(kleur.bold("Reference safe zones for short-video platforms (1080×1920)\n") +
|
|
84
|
-
kleur.dim("Numbers cover ~90% of mainstream phones — verify on your own target devices.\n\n"));
|
|
85
|
-
const rows = Object.entries(PLATFORMS).map(([key, z]) => {
|
|
86
|
-
const [lo, hi] = anchorRange(z);
|
|
87
|
-
return {
|
|
88
|
-
platform: key,
|
|
89
|
-
label: z.label,
|
|
90
|
-
top: z.top,
|
|
91
|
-
bottom: z.bottom,
|
|
92
|
-
left: z.left,
|
|
93
|
-
right: z.right,
|
|
94
|
-
"anchor-range": `[${lo.toFixed(2)}, ${hi.toFixed(2)}]`,
|
|
95
|
-
recommended: z.recommendedAnchor.toFixed(2),
|
|
96
|
-
};
|
|
97
|
-
});
|
|
98
|
-
table(rows);
|
|
99
|
-
process.stdout.write("\n" +
|
|
100
|
-
kleur.dim("Detail + anchor variants: ") +
|
|
101
|
-
kleur.cyan("rf platform <name>") +
|
|
102
|
-
"\n" +
|
|
103
|
-
kleur.dim("Aliases: 抖音=douyin · 视频号=wechat · tt=tiktok\n"));
|
|
104
|
-
}
|
|
105
|
-
function printDetail(key, z) {
|
|
106
|
-
const [lo, hi] = anchorRange(z);
|
|
107
|
-
const lines = [];
|
|
108
|
-
lines.push(kleur.bold(`${z.label} — safe zones for 1080×1920 vertical video.`));
|
|
109
|
-
lines.push("");
|
|
110
|
-
lines.push(kleur.dim("Why: ") + z.reason);
|
|
111
|
-
lines.push("");
|
|
112
|
-
lines.push(kleur.bold("Safe zones (padding from canvas edge, pixels):"));
|
|
113
|
-
lines.push(` top ${String(z.top).padStart(4)} (status bar / Dynamic Island)`);
|
|
114
|
-
lines.push(` bottom ${String(z.bottom).padStart(4)} (description + progress + buttons)`);
|
|
115
|
-
lines.push(` left ${String(z.left).padStart(4)} (cover-crop buffer)`);
|
|
116
|
-
lines.push(` right ${String(z.right).padStart(4)} (cover-crop + right action column)`);
|
|
117
|
-
lines.push(` center-max width ${String(z.centerMax).padStart(4)} (max safe horizontal width for centred elements)`);
|
|
118
|
-
lines.push("");
|
|
119
|
-
lines.push(kleur.bold("For blur-bg / letterbox layouts (--media-anchor-y):"));
|
|
120
|
-
lines.push(` Safe range: ${kleur.cyan(`[${lo.toFixed(2)}, ${hi.toFixed(2)}]`)}` +
|
|
121
|
-
kleur.dim(` (image top ≥ ${z.top}, image bottom ≤ ${CANVAS_H - z.bottom})`));
|
|
122
|
-
lines.push("");
|
|
123
|
-
lines.push(" Variants:");
|
|
124
|
-
for (const v of z.anchorVariants) {
|
|
125
|
-
const r = imageYRange(v.value);
|
|
126
|
-
const inRange = v.value >= lo && v.value <= hi;
|
|
127
|
-
const tag = inRange ? kleur.green("✓") : kleur.red("⚠");
|
|
128
|
-
const recommended = v.value === z.recommendedAnchor ? kleur.cyan(" (recommended)") : "";
|
|
129
|
-
lines.push(` ${tag} ${kleur.bold(v.value.toFixed(2))} ${v.case}${recommended}`);
|
|
130
|
-
lines.push(kleur.dim(` image y=${r.top}..${r.bottom}, bottom matte ${r.bottomMatte}px`));
|
|
131
|
-
}
|
|
132
|
-
lines.push("");
|
|
133
|
-
const outsideVariants = z.anchorVariants.filter((v) => v.value < lo || v.value > hi);
|
|
134
|
-
if (outsideVariants.length) {
|
|
135
|
-
lines.push(kleur.yellow("⚠ Caveats:"));
|
|
136
|
-
for (const v of outsideVariants) {
|
|
137
|
-
if (v.value < lo) {
|
|
138
|
-
lines.push(kleur.dim(` anchor=${v.value.toFixed(2)} pushes image top to y=${Math.round(v.value * SLACK)}, below ${z.label} safe top (${z.top}px) — may be clipped by Dynamic Island / status bar on some devices.`));
|
|
139
|
-
}
|
|
140
|
-
else {
|
|
141
|
-
lines.push(kleur.dim(` anchor=${v.value.toFixed(2)} pushes image bottom to y=${Math.round(v.value * SLACK) + IMAGE_SIDE}, beyond ${z.label} safe bottom (${CANVAS_H - z.bottom}) — content may be covered by platform UI.`));
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
lines.push("");
|
|
145
|
-
}
|
|
146
|
-
lines.push(kleur.bold("Quick start:"));
|
|
147
|
-
lines.push(" " +
|
|
148
|
-
kleur.cyan(`rf create "..." --layout blur-bg --media-anchor-y ${z.recommendedAnchor.toFixed(2)}`));
|
|
149
|
-
lines.push("");
|
|
150
|
-
lines.push(kleur.dim(`Note: 'full' layout fills the canvas and ignores --media-anchor-y. For platforms with heavy bottom UI like ${z.label}, prefer blur-bg or letterbox so you have a matte band to lift the image into.`));
|
|
151
|
-
process.stdout.write(lines.join("\n") + "\n");
|
|
152
|
-
}
|
|
153
|
-
export function registerPlatform(program) {
|
|
154
|
-
program
|
|
155
|
-
.command("platform [name]")
|
|
156
|
-
.description("查询竖屏平台 (抖音 / TikTok / 视频号) 的安全区参数与推荐 --media-anchor-y 值")
|
|
157
|
-
.helpOption("-h, --help", "show help")
|
|
158
|
-
.addHelpText("after", [
|
|
159
|
-
"",
|
|
160
|
-
"Why this exists:",
|
|
161
|
-
" Different short-video apps overlay their own UI (status bar, caption,",
|
|
162
|
-
" progress bar, action buttons) on top of your video. The",
|
|
163
|
-
" --media-anchor-y knob lifts the central 1080×1080 image up or down",
|
|
164
|
-
" inside the 1920-high canvas to keep important content out of those",
|
|
165
|
-
" UI zones. This command tells you what anchor value to dial for each",
|
|
166
|
-
" platform.",
|
|
167
|
-
"",
|
|
168
|
-
"Examples:",
|
|
169
|
-
" rf platform # overview table for all platforms",
|
|
170
|
-
" rf platform 抖音 # detail (alias of `douyin`)",
|
|
171
|
-
" rf platform douyin",
|
|
172
|
-
" rf platform tiktok",
|
|
173
|
-
" rf platform wechat # 视频号",
|
|
174
|
-
" rf platform douyin --json # machine-readable",
|
|
175
|
-
].join("\n"))
|
|
176
|
-
.action((name) => {
|
|
177
|
-
if (isJson()) {
|
|
178
|
-
if (name) {
|
|
179
|
-
const key = resolveKey(name);
|
|
180
|
-
if (!key) {
|
|
181
|
-
print({ ok: false, error: `unknown platform: ${name}`, known: Object.keys(PLATFORMS) });
|
|
182
|
-
process.exit(1);
|
|
183
|
-
}
|
|
184
|
-
const z = PLATFORMS[key];
|
|
185
|
-
const [lo, hi] = anchorRange(z);
|
|
186
|
-
print({
|
|
187
|
-
ok: true,
|
|
188
|
-
platform: key,
|
|
189
|
-
...z,
|
|
190
|
-
anchorRange: { min: lo, max: hi },
|
|
191
|
-
canvas: { width: CANVAS_W, height: CANVAS_H, imageSide: IMAGE_SIDE },
|
|
192
|
-
});
|
|
193
|
-
}
|
|
194
|
-
else {
|
|
195
|
-
const out = {};
|
|
196
|
-
for (const [k, z] of Object.entries(PLATFORMS)) {
|
|
197
|
-
const [lo, hi] = anchorRange(z);
|
|
198
|
-
out[k] = { ...z, anchorRange: { min: lo, max: hi } };
|
|
199
|
-
}
|
|
200
|
-
print({ ok: true, canvas: { width: CANVAS_W, height: CANVAS_H, imageSide: IMAGE_SIDE }, platforms: out });
|
|
201
|
-
}
|
|
202
|
-
return;
|
|
203
|
-
}
|
|
204
|
-
if (name) {
|
|
205
|
-
const key = resolveKey(name);
|
|
206
|
-
if (!key) {
|
|
207
|
-
process.stderr.write(kleur.red(`✗ unknown platform: ${name}\n`) +
|
|
208
|
-
` known: ${Object.keys(PLATFORMS).join(", ")}\n` +
|
|
209
|
-
` aliases: ${Object.keys(ALIASES).join(", ")}\n`);
|
|
210
|
-
process.exit(1);
|
|
211
|
-
}
|
|
212
|
-
printDetail(key, PLATFORMS[key]);
|
|
213
|
-
}
|
|
214
|
-
else {
|
|
215
|
-
printOverview();
|
|
216
|
-
}
|
|
217
|
-
});
|
|
218
|
-
}
|
package/dist/commands/regen.js
DELETED
|
@@ -1,134 +0,0 @@
|
|
|
1
|
-
import { post, get, getServer } from "../client.js";
|
|
2
|
-
import { waitForTask } from "../utils/task-waiter.js";
|
|
3
|
-
import { downloadTo } from "../utils/download.js";
|
|
4
|
-
import { info, print, success, warn } from "../utils/output.js";
|
|
5
|
-
const VALID_KINDS = [
|
|
6
|
-
"image",
|
|
7
|
-
"chart-bar",
|
|
8
|
-
"chart-line",
|
|
9
|
-
"chart-donut",
|
|
10
|
-
"kpi-grid",
|
|
11
|
-
"stat-counter",
|
|
12
|
-
"comparison-vs",
|
|
13
|
-
"tweet-card",
|
|
14
|
-
"ios-messages",
|
|
15
|
-
"notification",
|
|
16
|
-
"quote-card",
|
|
17
|
-
"terminal",
|
|
18
|
-
"code-editor",
|
|
19
|
-
"kinetic-text",
|
|
20
|
-
"bullet-list",
|
|
21
|
-
];
|
|
22
|
-
export function registerRegen(program) {
|
|
23
|
-
program
|
|
24
|
-
.command("regen")
|
|
25
|
-
.description("重生成已有任务里某个 scene 的画面 (音频和时间轴保持不变)")
|
|
26
|
-
.helpOption("-h, --help", "show help")
|
|
27
|
-
.argument("<task-id>", "task id from `rf create` or `rf create --preview-only`")
|
|
28
|
-
.option("--scene <n>", "1-based scene index to regenerate (REQUIRED)", (v) => parseInt(v, 10))
|
|
29
|
-
.option("--kind <kind>", `force a specific visual kind (one of: ${VALID_KINDS.join(", ")})`, (v) => {
|
|
30
|
-
if (!VALID_KINDS.includes(v)) {
|
|
31
|
-
throw new Error(`--kind must be one of: ${VALID_KINDS.join(", ")} (got "${v}")`);
|
|
32
|
-
}
|
|
33
|
-
return v;
|
|
34
|
-
})
|
|
35
|
-
.option("--hint <text>", "natural-language guidance to steer the regen (e.g. \"数字要大一点 / 用左右两栏不是上下\")")
|
|
36
|
-
.option("-o, --output <file>", "save the re-rendered MP4 to this exact path (completed-task path only)")
|
|
37
|
-
.option("--no-download", "skip downloading the re-rendered MP4 (completed-task path only)")
|
|
38
|
-
.option("--no-wait", "submit and return job id immediately (do not poll)")
|
|
39
|
-
.option("--poll-ms <ms>", "poll interval while waiting", (v) => parseInt(v, 10), 1500)
|
|
40
|
-
.option("--timeout-ms <ms>", "max wait time (default: unlimited)", (v) => parseInt(v, 10))
|
|
41
|
-
.addHelpText("after", [
|
|
42
|
-
"",
|
|
43
|
-
"What happens:",
|
|
44
|
-
" • LLM picks a fresh { kind, spec } for the target scene only.",
|
|
45
|
-
" • Image scenes also re-call image gen (~5-15s, ~$0.003).",
|
|
46
|
-
" • Composition is rebuilt — opens in /preview/<task-id> after refresh.",
|
|
47
|
-
" • If the task was already MP4-rendered, MP4 is re-rendered too.",
|
|
48
|
-
"",
|
|
49
|
-
"Behavior matrix:",
|
|
50
|
-
" preview-only task → composition only, ~5-30s, no MP4 in response",
|
|
51
|
-
" completed task → composition + MP4, ~45-90s, MP4 downloaded",
|
|
52
|
-
"",
|
|
53
|
-
"Examples:",
|
|
54
|
-
" rf regen 2026-05-28T12-00-00-000Z_abcd --scene 3",
|
|
55
|
-
" rf regen 2026-05-28T12-00-00-000Z_abcd --scene 3 --kind kinetic-text",
|
|
56
|
-
" rf regen 2026-05-28T12-00-00-000Z_abcd --scene 5 --kind chart-bar -o out.mp4",
|
|
57
|
-
].join("\n"))
|
|
58
|
-
.action(async (taskId, opts) => {
|
|
59
|
-
if (opts.scene === undefined) {
|
|
60
|
-
throw new Error("--scene <n> is required (1-based scene index)");
|
|
61
|
-
}
|
|
62
|
-
if (!Number.isFinite(opts.scene) || opts.scene < 1) {
|
|
63
|
-
throw new Error("--scene must be a positive integer (1-based)");
|
|
64
|
-
}
|
|
65
|
-
info(`Regenerating scene ${opts.scene} on task ${taskId}` +
|
|
66
|
-
(opts.kind ? ` (kind=${opts.kind})` : " (free pick)") +
|
|
67
|
-
"…");
|
|
68
|
-
const body = { scene_index: opts.scene };
|
|
69
|
-
if (opts.kind)
|
|
70
|
-
body.kind = opts.kind;
|
|
71
|
-
if (opts.hint)
|
|
72
|
-
body.hint = opts.hint;
|
|
73
|
-
const submitted = await post(`/api/v1/pipelines/standard/${encodeURIComponent(taskId)}/regenerate-scene`, body);
|
|
74
|
-
if (opts.wait === false) {
|
|
75
|
-
print({ task_id: submitted.task_id, status: submitted.status });
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
|
-
info(`Regen job: ${submitted.task_id} — waiting…`);
|
|
79
|
-
const t = await waitForTask(submitted.task_id, {
|
|
80
|
-
pollMs: opts.pollMs,
|
|
81
|
-
timeoutMs: opts.timeoutMs,
|
|
82
|
-
});
|
|
83
|
-
if (t.status !== "completed") {
|
|
84
|
-
throw new Error(t.error || `Regen ended with status ${t.status}`);
|
|
85
|
-
}
|
|
86
|
-
const result = t.result;
|
|
87
|
-
const newKind = result?.new_kind ?? "(unknown)";
|
|
88
|
-
const rerendered = !!result?.rerendered_mp4;
|
|
89
|
-
success(`Scene ${opts.scene} → kind=${newKind}, ` +
|
|
90
|
-
(rerendered ? "MP4 re-rendered" : "composition only"));
|
|
91
|
-
if (result?.price_usd !== undefined) {
|
|
92
|
-
info(`Cost: $${result.price_usd.toFixed(4)}`);
|
|
93
|
-
}
|
|
94
|
-
const serverBase = getServer().replace(/\/+$/, "");
|
|
95
|
-
info(`预览页 (刷新即可看到新画面): ${serverBase}/preview/${taskId}`);
|
|
96
|
-
if (rerendered && result?.video_url) {
|
|
97
|
-
const stdoutIsPipe = !process.stdout.isTTY;
|
|
98
|
-
const skipDownload = opts.download === false || (stdoutIsPipe && !opts.output);
|
|
99
|
-
let savedPath = opts.output;
|
|
100
|
-
if (!savedPath && !skipDownload) {
|
|
101
|
-
let title;
|
|
102
|
-
try {
|
|
103
|
-
const list = await get("/api/v1/history?limit=200");
|
|
104
|
-
title = list.items?.find((it) => it.task_id === taskId)?.title;
|
|
105
|
-
}
|
|
106
|
-
catch {
|
|
107
|
-
}
|
|
108
|
-
const safeTitle = (title || "video")
|
|
109
|
-
.replace(/[\\/:*?"<>|]/g, "_")
|
|
110
|
-
.slice(0, 80);
|
|
111
|
-
savedPath = `./${safeTitle}-${taskId}.mp4`;
|
|
112
|
-
}
|
|
113
|
-
if (savedPath) {
|
|
114
|
-
await downloadTo(result.video_url, savedPath);
|
|
115
|
-
success(`MP4 saved → ${savedPath}`);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
else if (!rerendered) {
|
|
119
|
-
warn("Task is in preview state — no MP4 produced. Run `rf render " +
|
|
120
|
-
taskId +
|
|
121
|
-
"` when you're happy with the preview.");
|
|
122
|
-
}
|
|
123
|
-
print({
|
|
124
|
-
task_id: taskId,
|
|
125
|
-
scene_index: opts.scene,
|
|
126
|
-
new_kind: newKind,
|
|
127
|
-
rerendered_mp4: rerendered,
|
|
128
|
-
video_url: result?.video_url ?? null,
|
|
129
|
-
file_size: result?.file_size ?? null,
|
|
130
|
-
price_usd: result?.price_usd ?? null,
|
|
131
|
-
preview_urls: result?.preview_urls,
|
|
132
|
-
});
|
|
133
|
-
});
|
|
134
|
-
}
|
package/dist/commands/render.js
DELETED
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
import { post, get, getServer } from "../client.js";
|
|
2
|
-
import { waitForTask } from "../utils/task-waiter.js";
|
|
3
|
-
import { downloadTo } from "../utils/download.js";
|
|
4
|
-
import { humanDuration, info, print, success } from "../utils/output.js";
|
|
5
|
-
export function registerRender(program) {
|
|
6
|
-
program
|
|
7
|
-
.command("render")
|
|
8
|
-
.description("把先前已生成预览的任务最终渲染成 MP4")
|
|
9
|
-
.helpOption("-h, --help", "show help")
|
|
10
|
-
.argument("<task-id>", "task id from `rf create --preview-only`")
|
|
11
|
-
.option("-o, --output <file>", "save the MP4 to this exact path (must include filename, e.g. ./out/x.mp4)")
|
|
12
|
-
.option("--no-download", "do not save the video locally — just print JSON with video_url")
|
|
13
|
-
.option("--fps <n>", "override FPS for this render (default: storyboard.config.videoFps)", (v) => parseInt(v, 10))
|
|
14
|
-
.option("--no-wait", "submit and return task_id immediately (do not poll)")
|
|
15
|
-
.option("--poll-ms <ms>", "poll interval while waiting", (v) => parseInt(v, 10), 1500)
|
|
16
|
-
.option("--timeout-ms <ms>", "max wait time before aborting (default unlimited)", (v) => parseInt(v, 10))
|
|
17
|
-
.addHelpText("after", [
|
|
18
|
-
"",
|
|
19
|
-
"Two-step workflow:",
|
|
20
|
-
' 1. rf create "topic" --preview-only # builds composition, opens browser preview',
|
|
21
|
-
" 2. rf render <task-id> # produces the MP4",
|
|
22
|
-
"",
|
|
23
|
-
"Notes:",
|
|
24
|
-
" • The task must already exist and have a preview-able composition",
|
|
25
|
-
" built (via `rf create --preview-only`).",
|
|
26
|
-
" • This re-renders from the existing composition only — it does NOT",
|
|
27
|
-
" re-run scene-plan / TTS / ASR / image generation.",
|
|
28
|
-
" • If you've already rendered once, calling render again will overwrite.",
|
|
29
|
-
"",
|
|
30
|
-
"Examples:",
|
|
31
|
-
" rf render 2026-05-28T12-00-00-000Z_abcd",
|
|
32
|
-
" rf render 2026-05-28T12-00-00-000Z_abcd -o ./final/space.mp4",
|
|
33
|
-
" rf render 2026-05-28T12-00-00-000Z_abcd --fps 60",
|
|
34
|
-
].join("\n"))
|
|
35
|
-
.action(async (taskId, opts) => {
|
|
36
|
-
info(`Submitting render for ${taskId}…`);
|
|
37
|
-
const body = {};
|
|
38
|
-
if (opts.fps !== undefined)
|
|
39
|
-
body.fps = opts.fps;
|
|
40
|
-
const submitted = await post(`/api/v1/tasks/${encodeURIComponent(taskId)}/render`, body);
|
|
41
|
-
if (opts.wait === false) {
|
|
42
|
-
print({ task_id: submitted.task_id, status: submitted.status });
|
|
43
|
-
return;
|
|
44
|
-
}
|
|
45
|
-
info(`Render task: ${submitted.task_id} — waiting for completion…`);
|
|
46
|
-
const t = await waitForTask(submitted.task_id, {
|
|
47
|
-
pollMs: opts.pollMs,
|
|
48
|
-
timeoutMs: opts.timeoutMs,
|
|
49
|
-
});
|
|
50
|
-
if (t.status !== "completed") {
|
|
51
|
-
throw new Error(t.error || `Render ended with status ${t.status}`);
|
|
52
|
-
}
|
|
53
|
-
const result = t.result;
|
|
54
|
-
if (result?.video_url) {
|
|
55
|
-
const stdoutIsPipe = !process.stdout.isTTY;
|
|
56
|
-
const skipDownload = !!opts.noDownload || (stdoutIsPipe && !opts.output);
|
|
57
|
-
let savedPath = opts.output;
|
|
58
|
-
if (!savedPath && !skipDownload) {
|
|
59
|
-
let title;
|
|
60
|
-
try {
|
|
61
|
-
const list = await get("/api/v1/history?limit=200");
|
|
62
|
-
title = list.items?.find((it) => it.task_id === taskId)?.title;
|
|
63
|
-
}
|
|
64
|
-
catch { }
|
|
65
|
-
const safeTitle = (title || "video").replace(/[\\/:*?"<>|]/g, "_").slice(0, 80);
|
|
66
|
-
savedPath = `./${safeTitle}-${taskId}.mp4`;
|
|
67
|
-
}
|
|
68
|
-
if (savedPath) {
|
|
69
|
-
await downloadTo(result.video_url, savedPath);
|
|
70
|
-
success(`Saved → ${savedPath}`);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
if (result?.file_size) {
|
|
74
|
-
info(`MP4 size: ${(result.file_size / 1024 / 1024).toFixed(1)} MB`);
|
|
75
|
-
}
|
|
76
|
-
void humanDuration;
|
|
77
|
-
const serverBase = getServer().replace(/\/+$/, "");
|
|
78
|
-
info(`预览页 (全功能): ${serverBase}/preview/${taskId}`);
|
|
79
|
-
info(`纯播放器: ${serverBase}/preview/${taskId}/player`);
|
|
80
|
-
print({ task_id: taskId, ...result });
|
|
81
|
-
});
|
|
82
|
-
}
|
package/dist/commands/script.js
DELETED
|
@@ -1,128 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs/promises";
|
|
2
|
-
import { info, isJson, print } from "../utils/output.js";
|
|
3
|
-
const CHARS_PER_SEC_ZH = 5;
|
|
4
|
-
const WORDS_PER_SEC_EN = 2.7;
|
|
5
|
-
const TTS_PRICES_USD_PER_CHAR = {
|
|
6
|
-
"vox/index-tts-2": 1.575 / 1e6,
|
|
7
|
-
"edge": 0,
|
|
8
|
-
};
|
|
9
|
-
function countCjk(s) {
|
|
10
|
-
const re = /[一-鿿㐀-䶿豈- -〿-]/gu;
|
|
11
|
-
return (s.match(re) || []).length;
|
|
12
|
-
}
|
|
13
|
-
function countAscii(s) {
|
|
14
|
-
const re = /[A-Za-z0-9]/g;
|
|
15
|
-
return (s.match(re) || []).length;
|
|
16
|
-
}
|
|
17
|
-
function countWords(s) {
|
|
18
|
-
return s.split(/\s+/).filter(Boolean).length;
|
|
19
|
-
}
|
|
20
|
-
function detectLang(text) {
|
|
21
|
-
const cjk = countCjk(text);
|
|
22
|
-
const ascii = countAscii(text);
|
|
23
|
-
return cjk * 5 >= ascii ? "zh" : "en";
|
|
24
|
-
}
|
|
25
|
-
function estimate(text, langOverride) {
|
|
26
|
-
const lang = langOverride ?? detectLang(text);
|
|
27
|
-
const cjk = countCjk(text);
|
|
28
|
-
const ascii = countAscii(text);
|
|
29
|
-
const words = countWords(text);
|
|
30
|
-
const chars = cjk + ascii;
|
|
31
|
-
const seconds = lang === "zh"
|
|
32
|
-
? chars / CHARS_PER_SEC_ZH
|
|
33
|
-
: words > 0 ? words / WORDS_PER_SEC_EN : chars / 12;
|
|
34
|
-
const billedChars = text.length;
|
|
35
|
-
const tts = {};
|
|
36
|
-
for (const [model, pricePerChar] of Object.entries(TTS_PRICES_USD_PER_CHAR)) {
|
|
37
|
-
tts[model] = Number((billedChars * pricePerChar).toFixed(6));
|
|
38
|
-
}
|
|
39
|
-
const totalSec = Math.round(seconds);
|
|
40
|
-
const min = Math.floor(totalSec / 60);
|
|
41
|
-
const sec = totalSec % 60;
|
|
42
|
-
const hhmmss = `${min}:${String(sec).padStart(2, "0")}`;
|
|
43
|
-
const notes = lang === "zh"
|
|
44
|
-
? `中文口播按 ${CHARS_PER_SEC_ZH} 字 / 秒估算 (普通播报速度)`
|
|
45
|
-
: `English narration estimated at ${WORDS_PER_SEC_EN} words / sec`;
|
|
46
|
-
return {
|
|
47
|
-
lang,
|
|
48
|
-
chars,
|
|
49
|
-
ascii_chars: ascii,
|
|
50
|
-
cjk_chars: cjk,
|
|
51
|
-
words,
|
|
52
|
-
seconds: Number(seconds.toFixed(2)),
|
|
53
|
-
hhmmss,
|
|
54
|
-
tts_cost_usd_by_model: tts,
|
|
55
|
-
notes,
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
async function loadText(input) {
|
|
59
|
-
if (input.startsWith("@")) {
|
|
60
|
-
const file = input.slice(1);
|
|
61
|
-
return (await fs.readFile(file, "utf-8")).trim();
|
|
62
|
-
}
|
|
63
|
-
return input;
|
|
64
|
-
}
|
|
65
|
-
export function registerScript(program) {
|
|
66
|
-
const scriptCmd = program
|
|
67
|
-
.command("script")
|
|
68
|
-
.description("脚本相关原子能力 (零 LLM / 零网络的本地工具)。")
|
|
69
|
-
.helpOption("-h, --help", "show help");
|
|
70
|
-
scriptCmd
|
|
71
|
-
.command("estimate <text-or-@file>")
|
|
72
|
-
.description("估算脚本字数 / 口播时长 / TTS 成本。零网络, 零调用 — 在送 TTS 或 scene-plan 之前快速判断 \"这段念出来多久 / 多少钱\"。")
|
|
73
|
-
.helpOption("-h, --help", "show help")
|
|
74
|
-
.option("--lang <zh|en>", "强制语言 (默认按 CJK 比例自动判断)")
|
|
75
|
-
.addHelpText("after", [
|
|
76
|
-
"",
|
|
77
|
-
"Examples:",
|
|
78
|
-
" # 字符串直传",
|
|
79
|
-
" rf script estimate '为什么我们还没有找到外星文明?我们在听, 也在被听。'",
|
|
80
|
-
"",
|
|
81
|
-
" # 从文件读 (适合长脚本)",
|
|
82
|
-
" rf script estimate @./draft.txt",
|
|
83
|
-
"",
|
|
84
|
-
" # JSON 模式 (agent 解析)",
|
|
85
|
-
" rf script estimate @./draft.txt --json",
|
|
86
|
-
"",
|
|
87
|
-
" # 强制按英文估时",
|
|
88
|
-
" rf script estimate @./english.txt --lang en",
|
|
89
|
-
"",
|
|
90
|
-
"速率假设:",
|
|
91
|
-
" 中文: 5 字 / 秒 (普通抖音口播速度)",
|
|
92
|
-
" 英文: 2.7 words / sec (~ 160 wpm)",
|
|
93
|
-
"",
|
|
94
|
-
"TTS 成本估算 (按云端 list price):",
|
|
95
|
-
" 云端 TTS: $1.575 / 1M chars",
|
|
96
|
-
" 本地 TTS: $0",
|
|
97
|
-
"",
|
|
98
|
-
"成本: $0 (纯本地计算, 无服务端调用)",
|
|
99
|
-
].join("\n"))
|
|
100
|
-
.action(async (input, opts) => {
|
|
101
|
-
const text = await loadText(input);
|
|
102
|
-
if (!text)
|
|
103
|
-
throw new Error("script is empty");
|
|
104
|
-
let langOverride;
|
|
105
|
-
if (opts.lang !== undefined) {
|
|
106
|
-
if (opts.lang !== "zh" && opts.lang !== "en") {
|
|
107
|
-
throw new Error(`--lang must be zh or en (got: ${opts.lang})`);
|
|
108
|
-
}
|
|
109
|
-
langOverride = opts.lang;
|
|
110
|
-
}
|
|
111
|
-
const r = estimate(text, langOverride);
|
|
112
|
-
if (isJson()) {
|
|
113
|
-
print(r);
|
|
114
|
-
return;
|
|
115
|
-
}
|
|
116
|
-
info(`lang: ${r.lang}`);
|
|
117
|
-
info(`chars: ${r.chars} (CJK ${r.cjk_chars} + ASCII letters/digits ${r.ascii_chars})`);
|
|
118
|
-
if (r.lang === "en")
|
|
119
|
-
info(`words: ${r.words}`);
|
|
120
|
-
info(`narration: ${r.seconds.toFixed(1)}s (${r.hhmmss})`);
|
|
121
|
-
info("TTS cost estimate:");
|
|
122
|
-
for (const [model, cost] of Object.entries(r.tts_cost_usd_by_model)) {
|
|
123
|
-
const costStr = cost === 0 ? "$0 (free)" : `$${cost.toFixed(6)}`;
|
|
124
|
-
info(` ${model.padEnd(20)} ${costStr}`);
|
|
125
|
-
}
|
|
126
|
-
info(`note: ${r.notes}`);
|
|
127
|
-
});
|
|
128
|
-
}
|
package/dist/commands/styles.js
DELETED
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
import { get } from "../client.js";
|
|
2
|
-
import { isJson, print } from "../utils/output.js";
|
|
3
|
-
import kleur from "kleur";
|
|
4
|
-
function displayWidth(s) {
|
|
5
|
-
let w = 0;
|
|
6
|
-
for (const c of s)
|
|
7
|
-
w += c.charCodeAt(0) > 0x7f ? 2 : 1;
|
|
8
|
-
return w;
|
|
9
|
-
}
|
|
10
|
-
function padDisplay(s, width) {
|
|
11
|
-
const pad = Math.max(0, width - displayWidth(s));
|
|
12
|
-
return s + " ".repeat(pad);
|
|
13
|
-
}
|
|
14
|
-
function renderTable(presets) {
|
|
15
|
-
if (!presets.length) {
|
|
16
|
-
process.stdout.write("(empty)\n");
|
|
17
|
-
return;
|
|
18
|
-
}
|
|
19
|
-
const idW = Math.max(2, ...presets.map((p) => p.id.length));
|
|
20
|
-
const labelW = Math.max(5, ...presets.map((p) => displayWidth(p.label)));
|
|
21
|
-
const sceneW = Math.max(5, ...presets.map((p) => displayWidth(p.scene)));
|
|
22
|
-
const hasPreview = presets.some((p) => p.preview_url);
|
|
23
|
-
const header = hasPreview
|
|
24
|
-
? `${padDisplay("id", idW)} ${padDisplay("label", labelW)} ${padDisplay("scene", sceneW)} preview`
|
|
25
|
-
: `${padDisplay("id", idW)} ${padDisplay("label", labelW)} ${padDisplay("scene", sceneW)} prefix`;
|
|
26
|
-
process.stdout.write(kleur.bold(header) + "\n");
|
|
27
|
-
process.stdout.write(`${"-".repeat(idW)} ${"-".repeat(labelW)} ${"-".repeat(sceneW)} ${"-".repeat(7)}\n`);
|
|
28
|
-
for (const p of presets) {
|
|
29
|
-
const last = hasPreview ? p.preview_url || "" : p.prefix;
|
|
30
|
-
process.stdout.write(`${padDisplay(p.id, idW)} ${padDisplay(p.label, labelW)} ${padDisplay(p.scene, sceneW)} ${last}\n`);
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
function renderDetail(p, preview) {
|
|
34
|
-
process.stdout.write(`\n● ${p.id} ${p.label}\n`);
|
|
35
|
-
if (p.preview_url) {
|
|
36
|
-
process.stdout.write(` 预览 (直接浏览器打开): ${p.preview_url}\n`);
|
|
37
|
-
}
|
|
38
|
-
if (preview) {
|
|
39
|
-
process.stdout.write(` 样图基线 → subject="${preview.subject}"\n`);
|
|
40
|
-
if (preview.model || preview.size) {
|
|
41
|
-
process.stdout.write(` model=${preview.model || "?"} · size=${preview.size || "?"}\n`);
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
process.stdout.write(` scene: ${p.scene}\n`);
|
|
45
|
-
process.stdout.write(` prefix: ${p.prefix}\n`);
|
|
46
|
-
}
|
|
47
|
-
export function registerStyles(program) {
|
|
48
|
-
const group = program
|
|
49
|
-
.command("styles")
|
|
50
|
-
.description("图像风格预设清单 (供 --style / body.style 使用)")
|
|
51
|
-
.helpOption("-h, --help", "show help");
|
|
52
|
-
group
|
|
53
|
-
.command("list")
|
|
54
|
-
.description("List all image style presets from the server (with preview URLs).")
|
|
55
|
-
.helpOption("-h, --help", "show help")
|
|
56
|
-
.option("--id <styleId>", "只看某个风格的详细信息 (id, label, scene, prefix, preview_url)")
|
|
57
|
-
.option("--json", "JSON 输出 (默认人读表格); 适合 agent / 脚本消费")
|
|
58
|
-
.addHelpText("after", [
|
|
59
|
-
"",
|
|
60
|
-
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
|
|
61
|
-
"AGENT 帮用户选风格的标准流程 (LLM 必读)",
|
|
62
|
-
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
|
|
63
|
-
" 当用户没明确指定 --style,或说『我不知道选哪个』时,不要替用户拍板,",
|
|
64
|
-
" 也不要按 prefix 文字给用户脑补描述 — 直接走这条路径:",
|
|
65
|
-
"",
|
|
66
|
-
" Step 1. 拉 catalog (含 preview_url):",
|
|
67
|
-
" rf styles list --json",
|
|
68
|
-
"",
|
|
69
|
-
" Step 2. 把 17 张 preview_url 列表丢给用户 (markdown 图片格式效果最好,",
|
|
70
|
-
" 用户在你的聊天界面里能直接看到缩略图):",
|
|
71
|
-
" ",
|
|
72
|
-
" ",
|
|
73
|
-
" ... (其余 15 个同样格式)",
|
|
74
|
-
"",
|
|
75
|
-
" Step 3. 用户照图选,把 id 喂给 `rf create --style <id>`.",
|
|
76
|
-
"",
|
|
77
|
-
" 17 张 preview 都是同一个 subject (一座黄昏悬崖上的灯塔) 在不同风格下的",
|
|
78
|
-
" 渲染样本,只有风格变量在动 — 用户看一眼立刻知道每个风格实际长啥样,",
|
|
79
|
-
" 不需要现场跑图.",
|
|
80
|
-
"",
|
|
81
|
-
"Use the id with `rf create --style <id>` or write it into a recipe:",
|
|
82
|
-
" rf create --script @x.txt --style flat",
|
|
83
|
-
' { "voice_id": "熊小二", "style": "flat" } # recipe',
|
|
84
|
-
"",
|
|
85
|
-
"Examples:",
|
|
86
|
-
" rf styles list # 全部 17 个风格 + preview URL 表格",
|
|
87
|
-
" rf styles list --id watercolor # 单看 watercolor 的详情 (含 prefix)",
|
|
88
|
-
" rf styles list --json # JSON 输出 (agent 必跑这条)",
|
|
89
|
-
].join("\n"))
|
|
90
|
-
.action(async (opts) => {
|
|
91
|
-
const data = await get("/api/v1/styles");
|
|
92
|
-
const presets = Array.isArray(data.styles) ? data.styles : [];
|
|
93
|
-
const filtered = opts.id ? presets.filter((s) => s.id === opts.id) : presets;
|
|
94
|
-
if (opts.id && filtered.length === 0) {
|
|
95
|
-
throw new Error(`unknown style id "${opts.id}". Known: ${presets.map((s) => s.id).join(", ")}`);
|
|
96
|
-
}
|
|
97
|
-
if (opts.json || isJson()) {
|
|
98
|
-
const payload = opts.id ? filtered[0] : { preview: data.preview, styles: filtered };
|
|
99
|
-
print(payload);
|
|
100
|
-
return;
|
|
101
|
-
}
|
|
102
|
-
if (opts.id) {
|
|
103
|
-
renderDetail(filtered[0], data.preview);
|
|
104
|
-
}
|
|
105
|
-
else {
|
|
106
|
-
if (data.preview?.subject) {
|
|
107
|
-
process.stdout.write(`\n样图基线 (preview_url 都是按这个 subject 渲的):\n ${data.preview.subject}\n\n`);
|
|
108
|
-
}
|
|
109
|
-
renderTable(filtered);
|
|
110
|
-
process.stdout.write(`\n(用 \`rf styles list --id <id>\` 看单个风格的详情,或 --json 拿结构化输出)\n\n`);
|
|
111
|
-
}
|
|
112
|
-
});
|
|
113
|
-
}
|