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,629 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import fsSync from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import { post, getServer } from "../client.js";
|
|
6
|
+
import { waitForTask } from "../utils/task-waiter.js";
|
|
7
|
+
import { downloadTo } from "../utils/download.js";
|
|
8
|
+
import { formatUsd, humanDuration, info, print, success, warn } from "../utils/output.js";
|
|
9
|
+
const LAST_CREATE_PATH = path.join(os.homedir(), ".reelforge", "last-create.json");
|
|
10
|
+
const EST_SCENE_PLAN_USD = 0.0015;
|
|
11
|
+
const EST_TTS_USD_PER_CHAR = 1.575 / 1e6;
|
|
12
|
+
const EST_ASR_USD_PER_SEC = 0.0319 / 60;
|
|
13
|
+
const EST_IMAGE_USD = 0.003;
|
|
14
|
+
const CHARS_PER_SEC_ZH = 5;
|
|
15
|
+
const TARGET_SEC_PER_SCENE = 8;
|
|
16
|
+
function estimateCostUsd(body) {
|
|
17
|
+
let durSec;
|
|
18
|
+
let chars;
|
|
19
|
+
if (body.script) {
|
|
20
|
+
durSec = body.script.length / CHARS_PER_SEC_ZH;
|
|
21
|
+
chars = body.script.length;
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
durSec = body.duration ?? 45;
|
|
25
|
+
chars = Math.round(durSec * CHARS_PER_SEC_ZH);
|
|
26
|
+
}
|
|
27
|
+
const scenes = Math.max(2, Math.round(durSec / TARGET_SEC_PER_SCENE));
|
|
28
|
+
const cost = EST_SCENE_PLAN_USD +
|
|
29
|
+
chars * EST_TTS_USD_PER_CHAR +
|
|
30
|
+
durSec * EST_ASR_USD_PER_SEC +
|
|
31
|
+
scenes * EST_IMAGE_USD;
|
|
32
|
+
return cost;
|
|
33
|
+
}
|
|
34
|
+
async function resolveTextOrFile(input) {
|
|
35
|
+
if (input.startsWith("@")) {
|
|
36
|
+
const file = input.slice(1);
|
|
37
|
+
return (await fs.readFile(file, "utf-8")).trim();
|
|
38
|
+
}
|
|
39
|
+
return input;
|
|
40
|
+
}
|
|
41
|
+
async function resolveRefImage(input, flagName) {
|
|
42
|
+
if (input === undefined)
|
|
43
|
+
return undefined;
|
|
44
|
+
const t = input.trim();
|
|
45
|
+
if (!t)
|
|
46
|
+
return undefined;
|
|
47
|
+
if (/^https?:\/\//i.test(t) || t.startsWith("data:"))
|
|
48
|
+
return t;
|
|
49
|
+
const abs = path.resolve(t);
|
|
50
|
+
if (!fsSync.existsSync(abs)) {
|
|
51
|
+
throw new Error(`${flagName}: local file not found: ${abs}`);
|
|
52
|
+
}
|
|
53
|
+
const ext = path.extname(abs).toLowerCase();
|
|
54
|
+
const mime = ext === ".jpg" || ext === ".jpeg" ? "image/jpeg" :
|
|
55
|
+
ext === ".webp" ? "image/webp" :
|
|
56
|
+
ext === ".png" ? "image/png" :
|
|
57
|
+
null;
|
|
58
|
+
if (!mime) {
|
|
59
|
+
throw new Error(`${flagName}: unsupported extension ${ext} (use png/jpg/jpeg/webp)`);
|
|
60
|
+
}
|
|
61
|
+
const buf = await fs.readFile(abs);
|
|
62
|
+
return `data:${mime};base64,${buf.toString("base64")}`;
|
|
63
|
+
}
|
|
64
|
+
async function loadRecipe(recipePath) {
|
|
65
|
+
const raw = await fs.readFile(recipePath, "utf-8");
|
|
66
|
+
const parsed = JSON.parse(raw);
|
|
67
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
68
|
+
throw new Error(`Recipe ${recipePath}: must be a JSON object`);
|
|
69
|
+
}
|
|
70
|
+
return parsed;
|
|
71
|
+
}
|
|
72
|
+
async function loadLastCreate() {
|
|
73
|
+
try {
|
|
74
|
+
const raw = await fs.readFile(LAST_CREATE_PATH, "utf-8");
|
|
75
|
+
return JSON.parse(raw);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
async function saveLastCreate(body) {
|
|
82
|
+
await fs.mkdir(path.dirname(LAST_CREATE_PATH), { recursive: true });
|
|
83
|
+
await fs.writeFile(LAST_CREATE_PATH, JSON.stringify(body, null, 2) + "\n", "utf-8");
|
|
84
|
+
}
|
|
85
|
+
const FILENAME_MAX_CHARS = 40;
|
|
86
|
+
function sanitizeFilename(name) {
|
|
87
|
+
const cleaned = name
|
|
88
|
+
.replace(/[\/\\:*?"<>|\r\n\t]+/g, "-")
|
|
89
|
+
.replace(/\s+/g, " ")
|
|
90
|
+
.trim()
|
|
91
|
+
.replace(/^[-.\s]+|[-.\s]+$/g, "");
|
|
92
|
+
const chars = Array.from(cleaned);
|
|
93
|
+
if (chars.length <= FILENAME_MAX_CHARS)
|
|
94
|
+
return cleaned;
|
|
95
|
+
return chars.slice(0, FILENAME_MAX_CHARS).join("").replace(/[-.\s]+$/g, "");
|
|
96
|
+
}
|
|
97
|
+
function computeDefaultFilename(args) {
|
|
98
|
+
const ext = args.ext || "mp4";
|
|
99
|
+
const shortId = args.taskId.slice(0, 8);
|
|
100
|
+
let base;
|
|
101
|
+
if (args.resultTitle && args.resultTitle.trim()) {
|
|
102
|
+
base = sanitizeFilename(args.resultTitle);
|
|
103
|
+
}
|
|
104
|
+
else if (args.topic && Array.from(args.topic).length <= 60) {
|
|
105
|
+
base = sanitizeFilename(args.topic);
|
|
106
|
+
}
|
|
107
|
+
else if (args.fileStemFromAt) {
|
|
108
|
+
base = sanitizeFilename(args.fileStemFromAt);
|
|
109
|
+
}
|
|
110
|
+
return `${base || "reelforge"}-${shortId}.${ext}`;
|
|
111
|
+
}
|
|
112
|
+
async function validateOutputPath(out) {
|
|
113
|
+
if (out.endsWith("/") || out.endsWith("\\")) {
|
|
114
|
+
throw new Error(`-o must include a filename (got directory-only path: ${out}). Example: -o ./videos/space.mp4`);
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
const stat = await fs.stat(out);
|
|
118
|
+
if (stat.isDirectory()) {
|
|
119
|
+
throw new Error(`-o must include a filename (path exists as a directory: ${out}). Example: -o ${out.replace(/[\\/]+$/, "")}/space.mp4`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
catch (e) {
|
|
123
|
+
if (e.code !== "ENOENT")
|
|
124
|
+
throw e;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function mergeWithNested(base, overlay) {
|
|
128
|
+
const merged = { ...base, ...overlay };
|
|
129
|
+
if (base.cover || overlay.cover) {
|
|
130
|
+
merged.cover = { ...base.cover, ...overlay.cover };
|
|
131
|
+
}
|
|
132
|
+
if (base.brand || overlay.brand) {
|
|
133
|
+
merged.brand = { ...base.brand, ...overlay.brand };
|
|
134
|
+
}
|
|
135
|
+
return merged;
|
|
136
|
+
}
|
|
137
|
+
function optsToBody(opts) {
|
|
138
|
+
const out = {};
|
|
139
|
+
if (opts.topic !== undefined)
|
|
140
|
+
out.topic = opts.topic;
|
|
141
|
+
if (opts.script !== undefined)
|
|
142
|
+
out.script = opts.script;
|
|
143
|
+
if (opts.title !== undefined)
|
|
144
|
+
out.title = opts.title;
|
|
145
|
+
if (opts.duration !== undefined)
|
|
146
|
+
out.duration = opts.duration;
|
|
147
|
+
if (opts.pace !== undefined)
|
|
148
|
+
out.pace = opts.pace;
|
|
149
|
+
if (opts.llmModel !== undefined)
|
|
150
|
+
out.llm_model = opts.llmModel;
|
|
151
|
+
if (opts.ttsModel !== undefined)
|
|
152
|
+
out.tts_model = opts.ttsModel;
|
|
153
|
+
if (opts.asrModel !== undefined)
|
|
154
|
+
out.asr_model = opts.asrModel;
|
|
155
|
+
if (opts.imageModel !== undefined)
|
|
156
|
+
out.image_model = opts.imageModel;
|
|
157
|
+
if (opts.promptPrefix !== undefined)
|
|
158
|
+
out.prompt_prefix = opts.promptPrefix;
|
|
159
|
+
if (opts.style !== undefined)
|
|
160
|
+
out.style = opts.style;
|
|
161
|
+
if (opts.characterRef !== undefined)
|
|
162
|
+
out.character_ref = opts.characterRef;
|
|
163
|
+
if (opts.voiceId !== undefined)
|
|
164
|
+
out.voice_id = opts.voiceId;
|
|
165
|
+
if (opts.ttsSpeed !== undefined)
|
|
166
|
+
out.tts_speed = opts.ttsSpeed;
|
|
167
|
+
if (opts.videoFps !== undefined)
|
|
168
|
+
out.video_fps = opts.videoFps;
|
|
169
|
+
if (opts.quality !== undefined)
|
|
170
|
+
out.quality = opts.quality;
|
|
171
|
+
if (opts.crf !== undefined && Number.isFinite(opts.crf))
|
|
172
|
+
out.crf = opts.crf;
|
|
173
|
+
if (opts.videoBitrate !== undefined)
|
|
174
|
+
out.video_bitrate = opts.videoBitrate;
|
|
175
|
+
if (opts.motion !== undefined)
|
|
176
|
+
out.motion = opts.motion;
|
|
177
|
+
if (opts.layout !== undefined)
|
|
178
|
+
out.layout = opts.layout;
|
|
179
|
+
if (opts.layoutMatteColor !== undefined)
|
|
180
|
+
out.layout_matte_color = opts.layoutMatteColor;
|
|
181
|
+
if (opts.mediaAnchorY !== undefined && Number.isFinite(opts.mediaAnchorY))
|
|
182
|
+
out.media_anchor_y = opts.mediaAnchorY;
|
|
183
|
+
if (opts.noBgm)
|
|
184
|
+
out.bgm_path = "";
|
|
185
|
+
else if (opts.bgm !== undefined)
|
|
186
|
+
out.bgm_path = opts.bgm;
|
|
187
|
+
if (opts.bgmVolume !== undefined && Number.isFinite(opts.bgmVolume))
|
|
188
|
+
out.bgm_volume = opts.bgmVolume;
|
|
189
|
+
if (opts.subtitleStyle !== undefined)
|
|
190
|
+
out.subtitle_style = opts.subtitleStyle;
|
|
191
|
+
if (opts.subtitleColor !== undefined)
|
|
192
|
+
out.subtitle_color = opts.subtitleColor;
|
|
193
|
+
if (opts.subtitleBackground !== undefined)
|
|
194
|
+
out.subtitle_background = opts.subtitleBackground;
|
|
195
|
+
if (opts.subtitleBottom !== undefined && Number.isFinite(opts.subtitleBottom))
|
|
196
|
+
out.subtitle_bottom_px = opts.subtitleBottom;
|
|
197
|
+
if (opts.components !== undefined)
|
|
198
|
+
out.image_only = !opts.components;
|
|
199
|
+
if (opts.theme !== undefined && opts.theme.trim())
|
|
200
|
+
out.theme = opts.theme.trim();
|
|
201
|
+
if (opts.subtitleTranslate !== undefined) {
|
|
202
|
+
const v = opts.subtitleTranslate.trim();
|
|
203
|
+
out.subtitle_translate_to = /^(off|none|no)$/i.test(v) ? "" : v;
|
|
204
|
+
}
|
|
205
|
+
const brand = {};
|
|
206
|
+
if (opts.brandHandle !== undefined)
|
|
207
|
+
brand.handle = opts.brandHandle;
|
|
208
|
+
if (opts.brandSlogan !== undefined)
|
|
209
|
+
brand.slogan = opts.brandSlogan;
|
|
210
|
+
if (opts.brandLogo !== undefined)
|
|
211
|
+
brand.logo_url = opts.brandLogo;
|
|
212
|
+
if (opts.brandPosition !== undefined)
|
|
213
|
+
brand.position = opts.brandPosition;
|
|
214
|
+
if (opts.brandColor !== undefined)
|
|
215
|
+
brand.color = opts.brandColor;
|
|
216
|
+
if (Object.keys(brand).length > 0)
|
|
217
|
+
out.brand = brand;
|
|
218
|
+
const cover = {};
|
|
219
|
+
if (opts.cover === false)
|
|
220
|
+
cover.enabled = false;
|
|
221
|
+
else if (opts.cover === true)
|
|
222
|
+
cover.enabled = true;
|
|
223
|
+
if (opts.coverTemplate !== undefined)
|
|
224
|
+
cover.template = opts.coverTemplate;
|
|
225
|
+
if (opts.coverTitle !== undefined)
|
|
226
|
+
cover.title = opts.coverTitle;
|
|
227
|
+
if (opts.coverSubtitle !== undefined)
|
|
228
|
+
cover.subtitle = opts.coverSubtitle;
|
|
229
|
+
if (opts.coverBadge !== undefined)
|
|
230
|
+
cover.badge = opts.coverBadge;
|
|
231
|
+
if (Object.keys(cover).length > 0)
|
|
232
|
+
out.cover = cover;
|
|
233
|
+
if (opts.segmentMinChars !== undefined)
|
|
234
|
+
out.segment_min_chars = opts.segmentMinChars;
|
|
235
|
+
if (opts.segmentMaxChars !== undefined)
|
|
236
|
+
out.segment_max_chars = opts.segmentMaxChars;
|
|
237
|
+
if (opts.previewOnly)
|
|
238
|
+
out.preview_only = true;
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
const STYLE_PRESET_META = [
|
|
242
|
+
{ id: "cinematic", label: "电影感", scene: "故事 / 旅行 / 通用百搭" },
|
|
243
|
+
{ id: "photorealistic", label: "写实棚拍", scene: "产品 / 人像 / 美食" },
|
|
244
|
+
{ id: "documentary", label: "纪实新闻", scene: "历史 / 人物 / 纪录" },
|
|
245
|
+
{ id: "flat", label: "扁平商业插画", scene: "财经 / 商业 / 数据科普" },
|
|
246
|
+
{ id: "anime", label: "日漫", scene: "故事 / 二次元 / 年轻向" },
|
|
247
|
+
{ id: "comic", label: "美漫", scene: "动作 / 英雄向 / 段子" },
|
|
248
|
+
{ id: "watercolor", label: "水彩", scene: "治愈 / 情感 / 小红书风" },
|
|
249
|
+
{ id: "oil-painting", label: "油画", scene: "文艺 / 古典 / 艺术史" },
|
|
250
|
+
{ id: "ink", label: "中国水墨", scene: "中式叙事 / 古诗词 / 国风" },
|
|
251
|
+
{ id: "3d-render", label: "三维渲染", scene: "科技 / 产品 / 未来 / 概念图" },
|
|
252
|
+
{ id: "claymation", label: "黏土定格", scene: "儿童 / 萌系 / 治愈段子" },
|
|
253
|
+
{ id: "low-poly", label: "低多边形", scene: "科技 / 概念 / 设计感" },
|
|
254
|
+
{ id: "pixel", label: "像素", scene: "游戏 / 怀旧 / 段子" },
|
|
255
|
+
{ id: "cyberpunk", label: "赛博朋克", scene: "科技未来 / 都市夜景" },
|
|
256
|
+
{ id: "vaporwave", label: "蒸汽波", scene: "网络梗 / 怀旧 / 抽象审美" },
|
|
257
|
+
{ id: "art-deco", label: "装饰艺术", scene: "奢华品牌 / 复古优雅" },
|
|
258
|
+
{ id: "matchstick", label: "极简黑白火柴人", scene: "知识科普 / 哲思 / 段子" },
|
|
259
|
+
];
|
|
260
|
+
function displayWidth(s) {
|
|
261
|
+
let w = 0;
|
|
262
|
+
for (const c of s)
|
|
263
|
+
w += c.charCodeAt(0) > 0x7f ? 2 : 1;
|
|
264
|
+
return w;
|
|
265
|
+
}
|
|
266
|
+
function padDisplay(s, width) {
|
|
267
|
+
const pad = Math.max(0, width - displayWidth(s));
|
|
268
|
+
return s + " ".repeat(pad);
|
|
269
|
+
}
|
|
270
|
+
function formatStylePresetsList() {
|
|
271
|
+
const keyW = Math.max(...STYLE_PRESET_META.map((p) => p.id.length));
|
|
272
|
+
const labelW = Math.max(...STYLE_PRESET_META.map((p) => displayWidth(p.label)));
|
|
273
|
+
return STYLE_PRESET_META.map((p) => ` ${padDisplay(p.id, keyW)} ${padDisplay(p.label, labelW)} ${p.scene}`).join("\n");
|
|
274
|
+
}
|
|
275
|
+
export function registerCreate(program) {
|
|
276
|
+
program
|
|
277
|
+
.command("create [topic]")
|
|
278
|
+
.description("一键生成视频: 主题或自己的脚本 → 完整 MP4")
|
|
279
|
+
.helpOption("-h, --help", "show help")
|
|
280
|
+
.option("-t, --topic <text>", "video topic; AI writes the script (mode=generate). Prefix with @file to read from disk.")
|
|
281
|
+
.option("--script <text>", "your own master script text; AI just plans scenes + visuals (mode=fixed). Prefix with @file to read from disk.")
|
|
282
|
+
.option("--title <text>", "hard-override video title; LLM will NOT auto-summarize. Useful for compliance-sensitive content (e.g. financial). Prefix with @file to read from disk. Pass --title '' (empty string) to explicitly suppress title rendering. Omit to keep LLM auto-title.")
|
|
283
|
+
.option("-d, --duration <sec>", "target video duration in seconds (generate mode only; default 45). LLM aims for ~duration × 5 chars of narration.", (v) => parseInt(v, 10))
|
|
284
|
+
.option("-p, --pace <pace>", "visual rhythm hint passed to the LLM: slow | normal | fast (default normal). LLM still decides the actual scene count from semantic structure.")
|
|
285
|
+
.option("--image-model <id>", "image model (rx-image-z | rx-image-flux | rx-image-qwen | rx-image-qwen-edit). Auto-switches to the edit-capable model when --character-ref is set.")
|
|
286
|
+
.option("--prompt-prefix <text>", "raw style prefix prepended to every image prompt (overrides --style)")
|
|
287
|
+
.option("--style <preset>", "image style preset id — server-expanded. 用户不知道选哪个时,agent 应该先跑 `rf styles list --json` 把每个风格的 preview_url(同一灯塔在 17 种风格下的对比图)给用户看,让用户照图选。仅看 id 列表用户和你都猜不准实际出图长啥样")
|
|
288
|
+
.option("--character-ref <urlOrPath>", "reference image of the main character — locks identity across scenes. URL, data: URI, or local png/jpg/webp path (auto-encoded). Auto-enables the edit-capable image model.")
|
|
289
|
+
.option("--motion <preset>", "per-scene image animation intensity. See 'Motion presets' below. Default: lite.")
|
|
290
|
+
.option("--layout <preset>", "image layout within canvas: full (default) | blur-bg | letterbox. See 'Layout presets' below.")
|
|
291
|
+
.option("--layout-matte-color <css>", "letterbox matte color (CSS string, e.g. 'black', '#1a1a1a', '#2d3748'). Ignored unless --layout letterbox. Default: black.")
|
|
292
|
+
.option("--media-anchor-y <fraction>", "vertical anchor of the inner image square (blur-bg/letterbox only), 0..1. 0.5=centered (default); lower values push the square up to grow the bottom matte. Subtitle position follows the image automatically. See `rf platform <name>` for safe ranges per platform (抖音 / TikTok / 视频号). Ignored for --layout full.", (v) => Number(v))
|
|
293
|
+
.option("--bgm <path>", "background music file (relative or absolute). Omit to use the server's default (config.bgm.default_path → bgm/default.mp3).")
|
|
294
|
+
.option("--bgm-volume <0..1>", "BGM mix volume (0=silent, 1=full). Default 0.15 keeps narration intelligible.", (v) => Number(v))
|
|
295
|
+
.option("--no-bgm", "disable background music for this render")
|
|
296
|
+
.option("--subtitle-style <preset>", "subtitle visual style. See 'Subtitle styles' below. Default: plate.")
|
|
297
|
+
.option("--subtitle-color <css>", "override subtitle text color, e.g. '#ffeb3b'. Omit for preset default.")
|
|
298
|
+
.option("--subtitle-background <css>", "override plate-preset background, e.g. 'rgba(20,30,80,0.75)'. Other presets ignore.")
|
|
299
|
+
.option("--subtitle-bottom <px>", "override the subtitle bottom offset in px (default 400, the lower-third position). blur-bg/letterbox images move up to clear it.", (v) => parseInt(v, 10))
|
|
300
|
+
.option("--components", "render data COMPONENTS (charts / cards / terminals / kinetic numbers) for suitable scenes instead of only AI images (default: image-only). Pair with --theme to pick the look.")
|
|
301
|
+
.option("--theme <id>", "single visual theme for the whole video: neo-brutal | bloomberg | editorial | terminal | swiss | liquid-glass | y2k | neon | bauhaus | ios. Only used with --components. Omit → inferred from the script.")
|
|
302
|
+
.option("--subtitle-translate <lang>", "双语字幕:把每条字幕翻译成指定语言,以小一号文字显示在主字幕下方,如 'en'(英文)/ 'ja'(日文)。开启后字幕分段自动变短(主字幕保持单行)。默认:开(en);传 'off' 关闭(也可覆盖 recipe 设置)")
|
|
303
|
+
.option("--cover", "给视频加一张设计封面图作为首帧。抖音/TikTok/视频号默认抓取首帧当 9 宫格缩略图——加了 cover 在 feed 里更出挑;不加则首帧是 scene-01 + 字幕。也可由 recipe 自动启用(cover.enabled=true)。")
|
|
304
|
+
.option("--no-cover", "强制关闭封面,无视 recipe 设置")
|
|
305
|
+
.option("--cover-template <id>", "封面风格,共 9 个: paper-band(财经) | carbon-copy(哲思,默认) | chalkboard(教育) | book-spine(书单) | polaroid(生活/vlog) | recipe-card(美食) | spec-sheet(数码/汽车) | vintage-stamp(历史) | magazine(时尚/娱乐)。" +
|
|
306
|
+
"用户不确定选哪个时,agent 应该先跑 `rf cover templates --json` 把每个模板的 preview_url 给用户看,看完图再选")
|
|
307
|
+
.option("--cover-title <text>", "封面大标题(短句更佳,12 字内最稳)。不指定则用 --title 或 AI 自动生成的视频标题")
|
|
308
|
+
.option("--cover-subtitle <text>", "副标题/一句话标语。不是所有模板都显示 — 看 `rf cover templates --id <id>` 的 supports.subtitle 字段确认 (= true 才会渲染,否则静默忽略)")
|
|
309
|
+
.option("--cover-badge <text>", "角标/期号/紧急标,如 'EP.012' / '突发' / 'FLASH'。同样看 `rf cover templates --id <id>` 的 supports.badge 字段确认")
|
|
310
|
+
.option("--brand-handle <text>", "creator handle shown in corner, e.g. '@大灰狼'")
|
|
311
|
+
.option("--brand-slogan <text>", "creator slogan/tagline shown under handle")
|
|
312
|
+
.option("--brand-logo <urlOrPath>", "logo/avatar URL or local file path (PNG/JPG/WebP)")
|
|
313
|
+
.option("--brand-position <pos>", "where to render brand chrome: top-left | top-right | bottom-left | bottom-right")
|
|
314
|
+
.option("--brand-color <css>", "brand text color, e.g. '#ffffff' (default)")
|
|
315
|
+
.option("--voice-id <id>", "RelayX TTS voice id (default 专业解说); see `rf tts voices`")
|
|
316
|
+
.option("--tts-speed <n>", "speech speed 0.5..2 (default 1.0). Only honored by some TTS backends; the default cloud model ignores it.", parseFloat)
|
|
317
|
+
.option("--llm-model <id>", "override the LLM model used for scene-plan")
|
|
318
|
+
.option("--tts-model <id>", "override the TTS model (defaults to the server's)")
|
|
319
|
+
.option("--asr-model <id>", "override the ASR model (defaults to the server's)")
|
|
320
|
+
.option("--segment-min-chars <N>", "subtitle SEGMENT (分段) min chars (default 10). A segment is one on-screen subtitle unit, not a visual line — visual wrapping is automatic.", (v) => parseInt(v, 10))
|
|
321
|
+
.option("--segment-max-chars <N>", "subtitle SEGMENT (分段) max chars (default 28 ≈ 2 visual lines at 820px safe width). HTML clamps display to 2 lines + ellipsis.", (v) => parseInt(v, 10))
|
|
322
|
+
.option("--video-fps <n>", "output video fps (default 24 — cinema-standard, ~20% faster render vs 30; pass 30 if you want smoother motion)", (v) => parseInt(v, 10))
|
|
323
|
+
.option("--quality <preset>", "encoder quality preset: draft | standard (default) | high. draft shrinks output ~3-4× vs standard (~3 Mbps) at slight visible quality cost; high is the opposite. Mutually exclusive with --crf and --video-bitrate.")
|
|
324
|
+
.option("--crf <N>", "constant rate factor 0-51 (lower = higher quality). Bypasses --quality preset's bitrate choice. Mutually exclusive with --video-bitrate.", (v) => parseInt(v, 10))
|
|
325
|
+
.option("--video-bitrate <rate>", "目标视频码率,如 '750k' / '1M' / '2M'。用于命中文件大小预算。和 --crf 二选一。")
|
|
326
|
+
.option("--recipe <file>", "load defaults from a JSON recipe file (CLI flags still override)")
|
|
327
|
+
.option("--redo", "replay last successful create from ~/.reelforge/last-create.json")
|
|
328
|
+
.option("--dry-run", "print the final request body + estimated units; do NOT submit")
|
|
329
|
+
.option("--no-wait", "submit and return task_id immediately (do not poll)")
|
|
330
|
+
.option("-o, --output <file>", "save the final video to this exact path (must include filename, e.g. ./out/space.mp4).")
|
|
331
|
+
.option("--no-download", "do not save the video locally — just print JSON with video_url")
|
|
332
|
+
.option("--poll-ms <ms>", "poll interval while waiting", (v) => parseInt(v, 10), 1500)
|
|
333
|
+
.option("--timeout-ms <ms>", "max wait time before aborting (default unlimited)", (v) => parseInt(v, 10))
|
|
334
|
+
.option("--preview-only", "跳过 MP4 渲染,只出可预览的网页版并打印 URL — 最快的迭代回路。后续可用 `rf render <task-id>` 出 MP4。")
|
|
335
|
+
.addHelpText("after", [
|
|
336
|
+
"",
|
|
337
|
+
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
|
|
338
|
+
"何时用 rf create (AGENT 必读 — 选错命令是这个 CLI 最常见的失败模式)",
|
|
339
|
+
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
|
|
340
|
+
"✓ 用户给的是『主题 / 想法 / 一句话需求』 → -t \"<topic>\" (AI 全权出片)",
|
|
341
|
+
"✓ 用户给的是『自己写好的文案脚本』 → --script @file.txt",
|
|
342
|
+
"✓ 用户参考别人的视频/链接 → 先 `rf extract <url>` 拿到原文,agent 帮用户",
|
|
343
|
+
" 写好脚本,再 --script 喂给 create",
|
|
344
|
+
"",
|
|
345
|
+
"✗ 用户给的是『一堆现成视频/图片要拼接』 → 不要走这条!",
|
|
346
|
+
" → `rf compositions concat ...` (低层 ffmpeg)",
|
|
347
|
+
"✗ Agent 想自定义每个 scene 的素材 → `rf compose <spec.json>` (EXPERIMENTAL)",
|
|
348
|
+
"",
|
|
349
|
+
"Two content modes (one is required):",
|
|
350
|
+
" generate AI writes the script. --topic / -t <text> + optional --duration -d",
|
|
351
|
+
" fixed You supply the script. --script <text-or-@file>",
|
|
352
|
+
"",
|
|
353
|
+
" Need to base a script on external material (PDF / 抖音 / web URL / GitHub / etc.)?",
|
|
354
|
+
" Use `rf extract <source>` to parse it into text, compose your script in your agent /",
|
|
355
|
+
" external LLM, then pass the result via --script. ReelForge does not compose scripts",
|
|
356
|
+
" from references — that's an agent's job.",
|
|
357
|
+
"",
|
|
358
|
+
"Pace (visual rhythm hint to the LLM):",
|
|
359
|
+
" slow fewer scenes, glued to semantic boundaries",
|
|
360
|
+
" normal balance semantic edges with visual variety (default)",
|
|
361
|
+
" fast split long semantic chunks into multiple shots for variety",
|
|
362
|
+
"",
|
|
363
|
+
"Defaults:",
|
|
364
|
+
" duration=45s · pace=normal · motion=lite · layout=blur-bg · subtitle-style=plate · tts-speed=1.0",
|
|
365
|
+
"",
|
|
366
|
+
"Motion presets (--motion <preset>) — per-scene image animation intensity:",
|
|
367
|
+
" off no motion, hard cuts between scenes — PPT / slideshow mode",
|
|
368
|
+
" lite 6% zoom + 0.3s crossfade, 4 sub-anims (default; safe + tasteful)",
|
|
369
|
+
" max 20% zoom/pan + 0.5s crossfade, 10 sub-anims (cinematic, more dramatic)",
|
|
370
|
+
" · sub-animations are Fisher-Yates shuffled per task_id, so every video",
|
|
371
|
+
" cycles a different order — no two videos feel identical.",
|
|
372
|
+
"",
|
|
373
|
+
"Layout presets (--layout <preset>) — how the AI image sits in the 1080×1920 canvas:",
|
|
374
|
+
" full image fills the whole canvas (default; high-impact, attention-grabbing).",
|
|
375
|
+
" best for human portraits, landscapes, 9:16-native content.",
|
|
376
|
+
" blur-bg image at 1080×1080 centered, top/bottom is a gaussian-blurred copy",
|
|
377
|
+
" of the same image — moves in sync with the foreground (小红书/抖音 style).",
|
|
378
|
+
" best for charts, screenshots, infographics, non-9:16 source content.",
|
|
379
|
+
" letterbox image at 1080×1080 centered, top/bottom is a solid matte (cinematic).",
|
|
380
|
+
" use --layout-matte-color to change (default 'black'; try '#1a1a1a' for less harsh).",
|
|
381
|
+
" · The image generator is asked for the actual on-screen size (1080×1920 for full,",
|
|
382
|
+
" 1080×1080 for blur-bg/letterbox), so you don't pay for pixels that get cropped.",
|
|
383
|
+
"",
|
|
384
|
+
"Subtitle styles (--subtitle-style <preset>):",
|
|
385
|
+
" plate semi-transparent black plate + white text (CapCut default; safest readability)",
|
|
386
|
+
" stroke bold white text with black stroke + shadow, no plate (抖音网红风)",
|
|
387
|
+
" cinema bottom black gradient backdrop + lighter text (film / documentary look)",
|
|
388
|
+
" · use --subtitle-color / --subtitle-background to override the preset's colors",
|
|
389
|
+
" e.g. --subtitle-color '#ffeb3b' --subtitle-background 'rgba(20,30,80,0.75)'",
|
|
390
|
+
"",
|
|
391
|
+
"Brand chrome (--brand-* flags or config.video.default_brand):",
|
|
392
|
+
" Adds a constant @handle / slogan / logo block in a frame corner.",
|
|
393
|
+
" --brand-position bottom-right --brand-handle '@大灰狼' --brand-slogan '财经科普'",
|
|
394
|
+
" --brand-logo accepts URL / data: URI / local png/jpg/webp path.",
|
|
395
|
+
" All fields optional; missing handle/slogan/logo are individually hidden.",
|
|
396
|
+
" Per-request flags merge over config defaults field by field.",
|
|
397
|
+
"",
|
|
398
|
+
"Preview-only 模式 (--preview-only):",
|
|
399
|
+
" 跳过 MP4 渲染,只出网页可预览版。CLI 会打印 preview URL(studio 页 + 纯",
|
|
400
|
+
" 播放器页),自己在浏览器里打开看效果迭代,定型再用",
|
|
401
|
+
" `rf render <task-id>` 出 MP4 — 比每次都等 MP4 出来快得多。",
|
|
402
|
+
"",
|
|
403
|
+
"Image style presets (--style <preset>) — server expands id → prompt prefix:",
|
|
404
|
+
formatStylePresetsList(),
|
|
405
|
+
" · Pass --prompt-prefix to override with a custom string (always wins).",
|
|
406
|
+
" · Omit both to let the LLM pick a per-video style automatically.",
|
|
407
|
+
" · Run `rf styles list` to query the server's live catalog.",
|
|
408
|
+
"",
|
|
409
|
+
"Output behavior:",
|
|
410
|
+
" No flag → saves to ./<title>-<task_id>.mp4 in current directory, prints the path",
|
|
411
|
+
" -o <path> → saves to that exact path (must include filename)",
|
|
412
|
+
" --no-download → skips local save, just prints JSON result with video_url",
|
|
413
|
+
" (when stdout is piped, --no-download is implied automatically)",
|
|
414
|
+
"",
|
|
415
|
+
"Examples (`rf` is the short alias):",
|
|
416
|
+
" # Minimum — AI writes a 45s script",
|
|
417
|
+
' rf create "为什么我们还没找到外星文明?"',
|
|
418
|
+
"",
|
|
419
|
+
" # 60-second video with slow visual pace",
|
|
420
|
+
' rf create "..." -d 60 -p slow',
|
|
421
|
+
"",
|
|
422
|
+
" # Your own script, you decide the wording",
|
|
423
|
+
" rf create --script @./script.txt",
|
|
424
|
+
' rf create --script "整段文案文本..."',
|
|
425
|
+
"",
|
|
426
|
+
" # Reference-based scripts — extract first, then create:",
|
|
427
|
+
" rf extract https://v.douyin.com/XXXX/ -o /tmp/ref.txt # parse anything → text",
|
|
428
|
+
" # (agent / your LLM composes the script using /tmp/ref.txt as raw material)",
|
|
429
|
+
" rf create --script @final.txt -d 60 # render the result",
|
|
430
|
+
"",
|
|
431
|
+
" # Pick a built-in image style preset",
|
|
432
|
+
' rf create "..." --style cinematic',
|
|
433
|
+
"",
|
|
434
|
+
" # Cross-scene character consistency (auto-switches image model)",
|
|
435
|
+
' rf create "主角小女孩的一天" --character-ref ./hero.png',
|
|
436
|
+
' rf create "..." --character-ref https://example.com/hero.png',
|
|
437
|
+
"",
|
|
438
|
+
" # Motion + subtitle style combos",
|
|
439
|
+
' rf create "..." --motion max --subtitle-style stroke # 抖音网红风',
|
|
440
|
+
' rf create "..." --motion lite --subtitle-style cinema # 文艺纪录片',
|
|
441
|
+
' rf create "..." --motion off # PPT 模式(旧行为)',
|
|
442
|
+
"",
|
|
443
|
+
" # Layout combos",
|
|
444
|
+
' rf create "财经日报" --layout blur-bg # 小红书 / 抖音 风(图表内容首选)',
|
|
445
|
+
' rf create "纪录片片段" --layout letterbox --motion max # 电影感',
|
|
446
|
+
' rf create "..." --layout letterbox --layout-matte-color "#1a1a1a" # 柔和黑',
|
|
447
|
+
"",
|
|
448
|
+
" # Preview ladder",
|
|
449
|
+
' rf create "..." --preview-only # ~2min, ~$0.05 — prints preview URL, no MP4',
|
|
450
|
+
' rf create "..." # ~5min, ~$0.20 — full MP4',
|
|
451
|
+
" rf render <task-id> # finalize MP4 from a preview-only task",
|
|
452
|
+
"",
|
|
453
|
+
" # Recipe + replay last",
|
|
454
|
+
" rf create --recipe ./space.recipe.json",
|
|
455
|
+
" rf create --redo # replay last successful create",
|
|
456
|
+
" rf create --redo -p fast # replay with one knob tweaked",
|
|
457
|
+
"",
|
|
458
|
+
" # See exactly what would be sent (no submission)",
|
|
459
|
+
' rf create "..." -d 60 --dry-run',
|
|
460
|
+
"",
|
|
461
|
+
" # Pipe-friendly",
|
|
462
|
+
' rf create "..." --no-download --json | jq -r .video_url',
|
|
463
|
+
].join("\n"))
|
|
464
|
+
.action(async (topicArg, opts) => {
|
|
465
|
+
if (opts.output) {
|
|
466
|
+
await validateOutputPath(opts.output);
|
|
467
|
+
}
|
|
468
|
+
if (opts.style && !STYLE_PRESET_META.some((p) => p.id === opts.style)) {
|
|
469
|
+
throw new Error(`Unknown --style: ${opts.style}\n` +
|
|
470
|
+
`Available presets (this CLI):\n${formatStylePresetsList()}\n` +
|
|
471
|
+
`For the server's live catalog: rf styles list`);
|
|
472
|
+
}
|
|
473
|
+
if (opts.pace && !["slow", "normal", "fast"].includes(opts.pace)) {
|
|
474
|
+
throw new Error(`--pace must be one of slow|normal|fast (got: ${opts.pace})`);
|
|
475
|
+
}
|
|
476
|
+
if (opts.motion && !["off", "lite", "max"].includes(opts.motion)) {
|
|
477
|
+
throw new Error(`--motion must be one of off|lite|max (got: ${opts.motion})`);
|
|
478
|
+
}
|
|
479
|
+
if (opts.layout && !["full", "blur-bg", "letterbox"].includes(opts.layout)) {
|
|
480
|
+
throw new Error(`--layout must be one of full|blur-bg|letterbox (got: ${opts.layout})`);
|
|
481
|
+
}
|
|
482
|
+
if (opts.subtitleStyle && !["plate", "stroke", "cinema"].includes(opts.subtitleStyle)) {
|
|
483
|
+
throw new Error(`--subtitle-style must be one of plate|stroke|cinema (got: ${opts.subtitleStyle})`);
|
|
484
|
+
}
|
|
485
|
+
if (opts.brandPosition && !["top-left", "top-right", "bottom-left", "bottom-right"].includes(opts.brandPosition)) {
|
|
486
|
+
throw new Error(`--brand-position must be one of top-left|top-right|bottom-left|bottom-right (got: ${opts.brandPosition})`);
|
|
487
|
+
}
|
|
488
|
+
if (opts.ttsSpeed !== undefined &&
|
|
489
|
+
(!opts.ttsModel || opts.ttsModel.startsWith("vox/"))) {
|
|
490
|
+
const m = opts.ttsModel || "default";
|
|
491
|
+
warn(`--tts-speed=${opts.ttsSpeed} will be ignored by TTS model "${m}". Speed control is only honored by Edge-TTS-backed models.`);
|
|
492
|
+
}
|
|
493
|
+
let body = {};
|
|
494
|
+
if (opts.redo) {
|
|
495
|
+
const last = await loadLastCreate();
|
|
496
|
+
if (!last) {
|
|
497
|
+
throw new Error(`--redo: no previous create found at ${LAST_CREATE_PATH}. Run at least one successful create first.`);
|
|
498
|
+
}
|
|
499
|
+
body = { ...last };
|
|
500
|
+
info(`Loaded last create from ${LAST_CREATE_PATH}`);
|
|
501
|
+
}
|
|
502
|
+
if (opts.recipe) {
|
|
503
|
+
const recipe = await loadRecipe(opts.recipe);
|
|
504
|
+
body = mergeWithNested(body, recipe);
|
|
505
|
+
info(`Loaded recipe from ${opts.recipe}`);
|
|
506
|
+
}
|
|
507
|
+
const fromOpts = optsToBody(opts);
|
|
508
|
+
body = mergeWithNested(body, fromOpts);
|
|
509
|
+
const rawTopicInput = topicArg ?? (typeof body.topic === "string" ? body.topic : undefined);
|
|
510
|
+
const fileStemFromAt = rawTopicInput?.startsWith("@") ? path.parse(rawTopicInput.slice(1)).name :
|
|
511
|
+
body.script?.startsWith("@") ? path.parse(body.script.slice(1)).name :
|
|
512
|
+
undefined;
|
|
513
|
+
if (topicArg) {
|
|
514
|
+
body.topic = await resolveTextOrFile(topicArg);
|
|
515
|
+
}
|
|
516
|
+
else if (typeof body.topic === "string") {
|
|
517
|
+
body.topic = await resolveTextOrFile(body.topic);
|
|
518
|
+
}
|
|
519
|
+
if (typeof body.script === "string") {
|
|
520
|
+
body.script = await resolveTextOrFile(body.script);
|
|
521
|
+
}
|
|
522
|
+
if (typeof body.title === "string" && body.title.startsWith("@")) {
|
|
523
|
+
body.title = await resolveTextOrFile(body.title);
|
|
524
|
+
}
|
|
525
|
+
const legacy = body;
|
|
526
|
+
const legacyKeys = ["reference", "reference_url", "instruct", "script_only"]
|
|
527
|
+
.filter((k) => legacy[k] !== undefined);
|
|
528
|
+
if (legacyKeys.length > 0) {
|
|
529
|
+
throw new Error(`Removed in 1.6.0: ${legacyKeys.join(", ")}. ` +
|
|
530
|
+
"Script composition from external material now lives in your agent / LLM.\n" +
|
|
531
|
+
"Migration:\n" +
|
|
532
|
+
" rf extract <source> -o /tmp/ref.txt # parse PDF / URL / 抖音 / GitHub / ...\n" +
|
|
533
|
+
" # (compose your script using /tmp/ref.txt as raw material in your agent)\n" +
|
|
534
|
+
" rf create --script @final.txt -d 60 # render it");
|
|
535
|
+
}
|
|
536
|
+
const resolvedChar = await resolveRefImage(body.character_ref, "--character-ref");
|
|
537
|
+
if (resolvedChar !== undefined)
|
|
538
|
+
body.character_ref = resolvedChar;
|
|
539
|
+
else
|
|
540
|
+
delete body.character_ref;
|
|
541
|
+
if (body.brand?.logo_url) {
|
|
542
|
+
const resolvedLogo = await resolveRefImage(body.brand.logo_url, "--brand-logo");
|
|
543
|
+
if (resolvedLogo !== undefined)
|
|
544
|
+
body.brand.logo_url = resolvedLogo;
|
|
545
|
+
else
|
|
546
|
+
delete body.brand.logo_url;
|
|
547
|
+
}
|
|
548
|
+
const hasTopic = typeof body.topic === "string" && body.topic.trim().length > 0;
|
|
549
|
+
const hasScript = typeof body.script === "string" && body.script.trim().length > 0;
|
|
550
|
+
const contentCount = (hasTopic ? 1 : 0) + (hasScript ? 1 : 0);
|
|
551
|
+
if (contentCount === 0) {
|
|
552
|
+
throw new Error("one of --topic (or positional arg) or --script is required.");
|
|
553
|
+
}
|
|
554
|
+
if (contentCount > 1) {
|
|
555
|
+
throw new Error("--topic and --script are mutually exclusive (pick one).");
|
|
556
|
+
}
|
|
557
|
+
const finalBody = { ...body };
|
|
558
|
+
const estimate = estimateCostUsd(finalBody);
|
|
559
|
+
if (opts.dryRun) {
|
|
560
|
+
info("--- DRY RUN ---");
|
|
561
|
+
info("Final request body:");
|
|
562
|
+
print(finalBody);
|
|
563
|
+
info(`Estimated cost: ≈ ${formatUsd(estimate)}`);
|
|
564
|
+
info("(use without --dry-run to actually submit)");
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
info(`Submitting create task (≈ ${formatUsd(estimate)})...`);
|
|
568
|
+
const submitted = await post("/api/v1/pipelines/standard", finalBody);
|
|
569
|
+
await saveLastCreate(finalBody).catch((e) => {
|
|
570
|
+
warn(`Could not save last-create.json: ${e.message}`);
|
|
571
|
+
});
|
|
572
|
+
if (opts.wait === false) {
|
|
573
|
+
print({ task_id: submitted.task_id, status: submitted.status });
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
info(`Task submitted: ${submitted.task_id}`);
|
|
577
|
+
const t = await waitForTask(submitted.task_id, {
|
|
578
|
+
pollMs: opts.pollMs,
|
|
579
|
+
timeoutMs: opts.timeoutMs,
|
|
580
|
+
});
|
|
581
|
+
if (t.status !== "completed") {
|
|
582
|
+
throw new Error(t.error || `Task ended with status ${t.status}`);
|
|
583
|
+
}
|
|
584
|
+
const result = t.result;
|
|
585
|
+
const serverBase = getServer().replace(/\/+$/, "");
|
|
586
|
+
const previewPageAbs = result?.preview_urls?.page ? serverBase + result.preview_urls.page : undefined;
|
|
587
|
+
const previewPlayerAbs = result?.preview_urls?.player ? serverBase + result.preview_urls.player : undefined;
|
|
588
|
+
if (previewPageAbs) {
|
|
589
|
+
info(`预览页 (全功能): ${previewPageAbs}`);
|
|
590
|
+
}
|
|
591
|
+
if (previewPlayerAbs) {
|
|
592
|
+
info(`纯播放器: ${previewPlayerAbs}`);
|
|
593
|
+
}
|
|
594
|
+
if (result?.render_pending) {
|
|
595
|
+
info(`MP4 渲染未触发 (preview-only)。完成后用 \`rf render ${t.id}\` 生成。`);
|
|
596
|
+
}
|
|
597
|
+
else if (result?.video_url) {
|
|
598
|
+
const stdoutIsPipe = !process.stdout.isTTY;
|
|
599
|
+
const skipDownload = !!opts.noDownload || (stdoutIsPipe && !opts.output);
|
|
600
|
+
let savedPath;
|
|
601
|
+
if (opts.output) {
|
|
602
|
+
savedPath = opts.output;
|
|
603
|
+
}
|
|
604
|
+
else if (!skipDownload) {
|
|
605
|
+
const topicForFilename = hasTopic && finalBody.topic ? finalBody.topic : undefined;
|
|
606
|
+
savedPath = computeDefaultFilename({
|
|
607
|
+
resultTitle: result.title,
|
|
608
|
+
topic: topicForFilename,
|
|
609
|
+
fileStemFromAt,
|
|
610
|
+
taskId: t.id,
|
|
611
|
+
ext: "mp4",
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
if (savedPath) {
|
|
615
|
+
await downloadTo(result.video_url, savedPath);
|
|
616
|
+
success(`Saved → ${savedPath}`);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
if (result?.generation_ms != null) {
|
|
620
|
+
const gen = humanDuration(result.generation_ms);
|
|
621
|
+
const playback = result.duration != null ? `,视频时长 ${result.duration.toFixed(1)}s` : "";
|
|
622
|
+
info(`本次生成耗时 ${gen}${playback}`);
|
|
623
|
+
}
|
|
624
|
+
if (result?.price_usd != null) {
|
|
625
|
+
info(`本次扣费 ${formatUsd(result.price_usd)}`);
|
|
626
|
+
}
|
|
627
|
+
print({ task_id: t.id, status: t.status, ...result });
|
|
628
|
+
});
|
|
629
|
+
}
|