reelforge 0.2.2 → 0.3.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/dist/commands/create.js +295 -18
- package/package.json +1 -1
package/dist/commands/create.js
CHANGED
|
@@ -1,12 +1,190 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
1
4
|
import { post } from "../client.js";
|
|
2
5
|
import { waitForTask } from "../utils/task-waiter.js";
|
|
3
6
|
import { downloadTo } from "../utils/download.js";
|
|
4
|
-
import { print, success,
|
|
7
|
+
import { info, print, success, warn } from "../utils/output.js";
|
|
8
|
+
const LAST_CREATE_PATH = path.join(os.homedir(), ".reelforge", "last-create.json");
|
|
9
|
+
// ── Cost estimation (mirrors server src/lib/billing.ts) ──────────
|
|
10
|
+
function calcWorkflowUnits(wfKey) {
|
|
11
|
+
if (!wfKey)
|
|
12
|
+
return 3;
|
|
13
|
+
const base = (wfKey.split("/").pop() || wfKey).toLowerCase();
|
|
14
|
+
if (base.startsWith("tts_"))
|
|
15
|
+
return 1;
|
|
16
|
+
if (base.startsWith("image_"))
|
|
17
|
+
return 3;
|
|
18
|
+
if (base.startsWith("video_") || base.startsWith("i2v_"))
|
|
19
|
+
return 15;
|
|
20
|
+
if (base.startsWith("analyse_") || base.startsWith("analyze_"))
|
|
21
|
+
return 2;
|
|
22
|
+
if (base.startsWith("af_") || base.startsWith("digital_"))
|
|
23
|
+
return 15;
|
|
24
|
+
return 3;
|
|
25
|
+
}
|
|
26
|
+
function estimateUnits(body) {
|
|
27
|
+
const mode = body.mode || "generate";
|
|
28
|
+
const titleExplicit = !!body.title;
|
|
29
|
+
const N = body.n_scenes ?? 5;
|
|
30
|
+
// Template type from filename prefix
|
|
31
|
+
const tplKey = body.frame_template || "1080x1920/static_default.html";
|
|
32
|
+
const tplBase = (tplKey.split("/").pop() || "").toLowerCase();
|
|
33
|
+
const tplType = tplBase.startsWith("static_")
|
|
34
|
+
? "static"
|
|
35
|
+
: tplBase.startsWith("image_")
|
|
36
|
+
? "image"
|
|
37
|
+
: tplBase.startsWith("video_")
|
|
38
|
+
? "video"
|
|
39
|
+
: tplBase.startsWith("asset_")
|
|
40
|
+
? "asset"
|
|
41
|
+
: "image";
|
|
42
|
+
let mediaPerFrame = 0;
|
|
43
|
+
if (tplType === "image") {
|
|
44
|
+
mediaPerFrame = body.media_workflow ? calcWorkflowUnits(body.media_workflow) : 3;
|
|
45
|
+
}
|
|
46
|
+
else if (tplType === "video") {
|
|
47
|
+
mediaPerFrame = body.media_workflow ? calcWorkflowUnits(body.media_workflow) : 15;
|
|
48
|
+
}
|
|
49
|
+
const ttsMode = body.tts_inference_mode || (body.tts_workflow ? "comfyui" : "local");
|
|
50
|
+
const ttsPerFrame = ttsMode === "comfyui" ? 1 : 0;
|
|
51
|
+
const narrations = mode === "generate" ? 1 : 0;
|
|
52
|
+
const title = titleExplicit ? 0 : 1;
|
|
53
|
+
const imagePrompts = tplType === "static" ? 0 : 1;
|
|
54
|
+
return narrations + title + imagePrompts + N * (ttsPerFrame + mediaPerFrame);
|
|
55
|
+
}
|
|
56
|
+
// ── Helpers ─────────────────────────────────────────────────────
|
|
57
|
+
async function resolveText(input) {
|
|
58
|
+
if (input.startsWith("@")) {
|
|
59
|
+
const file = input.slice(1);
|
|
60
|
+
return (await fs.readFile(file, "utf-8")).trim();
|
|
61
|
+
}
|
|
62
|
+
return input;
|
|
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
|
+
/** Camel-case CLI options → snake_case body, only including provided fields */
|
|
86
|
+
function optsToBody(opts) {
|
|
87
|
+
const out = {};
|
|
88
|
+
if (opts.text !== undefined)
|
|
89
|
+
out.text = opts.text;
|
|
90
|
+
if (opts.mode !== undefined)
|
|
91
|
+
out.mode = opts.mode;
|
|
92
|
+
if (opts.title !== undefined)
|
|
93
|
+
out.title = opts.title;
|
|
94
|
+
if (opts.nScenes !== undefined)
|
|
95
|
+
out.n_scenes = opts.nScenes;
|
|
96
|
+
if (opts.splitMode !== undefined)
|
|
97
|
+
out.split_mode = opts.splitMode;
|
|
98
|
+
if (opts.ttsInferenceMode !== undefined)
|
|
99
|
+
out.tts_inference_mode = opts.ttsInferenceMode;
|
|
100
|
+
if (opts.ttsVoice !== undefined)
|
|
101
|
+
out.tts_voice = opts.ttsVoice;
|
|
102
|
+
if (opts.voiceId !== undefined)
|
|
103
|
+
out.voice_id = opts.voiceId;
|
|
104
|
+
if (opts.ttsWorkflow !== undefined)
|
|
105
|
+
out.tts_workflow = opts.ttsWorkflow;
|
|
106
|
+
if (opts.ttsSpeed !== undefined)
|
|
107
|
+
out.tts_speed = opts.ttsSpeed;
|
|
108
|
+
if (opts.refAudio !== undefined)
|
|
109
|
+
out.ref_audio = opts.refAudio;
|
|
110
|
+
if (opts.mediaWorkflow !== undefined)
|
|
111
|
+
out.media_workflow = opts.mediaWorkflow;
|
|
112
|
+
if (opts.frameTemplate !== undefined)
|
|
113
|
+
out.frame_template = opts.frameTemplate;
|
|
114
|
+
if (opts.promptPrefix !== undefined)
|
|
115
|
+
out.prompt_prefix = opts.promptPrefix;
|
|
116
|
+
if (opts.bgm !== undefined)
|
|
117
|
+
out.bgm_path = opts.bgm;
|
|
118
|
+
if (opts.bgmVolume !== undefined)
|
|
119
|
+
out.bgm_volume = opts.bgmVolume;
|
|
120
|
+
if (opts.bgmMode !== undefined)
|
|
121
|
+
out.bgm_mode = opts.bgmMode;
|
|
122
|
+
if (opts.minNarrationWords !== undefined)
|
|
123
|
+
out.min_narration_words = opts.minNarrationWords;
|
|
124
|
+
if (opts.maxNarrationWords !== undefined)
|
|
125
|
+
out.max_narration_words = opts.maxNarrationWords;
|
|
126
|
+
if (opts.minImagePromptWords !== undefined)
|
|
127
|
+
out.min_image_prompt_words = opts.minImagePromptWords;
|
|
128
|
+
if (opts.maxImagePromptWords !== undefined)
|
|
129
|
+
out.max_image_prompt_words = opts.maxImagePromptWords;
|
|
130
|
+
if (opts.videoFps !== undefined)
|
|
131
|
+
out.video_fps = opts.videoFps;
|
|
132
|
+
if (opts.templateParams !== undefined)
|
|
133
|
+
out.template_params = opts.templateParams;
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
const DEFAULTS = {
|
|
137
|
+
mode: "generate",
|
|
138
|
+
n_scenes: 5,
|
|
139
|
+
frame_template: "1080x1920/image_default.html",
|
|
140
|
+
tts_voice: "zh-CN-YunjianNeural",
|
|
141
|
+
tts_speed: 1.2,
|
|
142
|
+
};
|
|
143
|
+
// ── Command registration ────────────────────────────────────────
|
|
5
144
|
export function registerCreate(program) {
|
|
6
145
|
program
|
|
7
|
-
.command("create
|
|
8
|
-
.description("One-click: topic → fully-generated MP4
|
|
146
|
+
.command("create [topic]")
|
|
147
|
+
.description("One-click: topic → fully-generated MP4. 23 tunable params + recipe files.")
|
|
9
148
|
.helpOption("-h, --help", "show help")
|
|
149
|
+
// --- Content ---
|
|
150
|
+
.option("-t, --text <text>", "topic (mode=generate) or fixed script (mode=fixed). Prefix with @ to read from a file (e.g. @script.txt).")
|
|
151
|
+
.option("--mode <mode>", "generate | fixed (default: generate)")
|
|
152
|
+
.option("--title <text>", "explicit video title (default: LLM-generated from topic)")
|
|
153
|
+
.option("-n, --n-scenes <N>", "number of scenes", (v) => parseInt(v, 10))
|
|
154
|
+
.option("--split-mode <mode>", "paragraph | line | sentence (mode=fixed only)")
|
|
155
|
+
.option("--min-narration-words <N>", "narration min words per scene", (v) => parseInt(v, 10))
|
|
156
|
+
.option("--max-narration-words <N>", "narration max words per scene", (v) => parseInt(v, 10))
|
|
157
|
+
.option("--min-image-prompt-words <N>", "image prompt min words", (v) => parseInt(v, 10))
|
|
158
|
+
.option("--max-image-prompt-words <N>", "image prompt max words", (v) => parseInt(v, 10))
|
|
159
|
+
// --- Visual ---
|
|
160
|
+
.option("--frame-template <key>", "HTML frame template, e.g. 1080x1920/image_default.html")
|
|
161
|
+
.option("--media-workflow <key>", "AI image/video workflow, e.g. runninghub/image_flux.json")
|
|
162
|
+
.option("--prompt-prefix <text>", "style prefix prepended to every image prompt")
|
|
163
|
+
// --- Audio (TTS) ---
|
|
164
|
+
.option("--tts-voice <id>", "Edge TTS voice id, e.g. zh-CN-YunjianNeural / en-US-AriaNeural")
|
|
165
|
+
.option("--tts-speed <n>", "speech speed 0.5..2", parseFloat)
|
|
166
|
+
.option("--tts-inference-mode <mode>", "local | comfyui")
|
|
167
|
+
.option("--tts-workflow <key>", "ComfyUI TTS workflow (forces inference-mode=comfyui)")
|
|
168
|
+
.option("--voice-id <id>", "alias of --tts-voice (legacy compat)")
|
|
169
|
+
.option("--ref-audio <path>", "reference audio for voice-cloning TTS workflows")
|
|
170
|
+
// --- Audio (BGM) ---
|
|
171
|
+
.option("--bgm <path>", "background music file path (server-side relative to bgm/)")
|
|
172
|
+
.option("--bgm-volume <n>", "BGM volume 0..1", parseFloat)
|
|
173
|
+
.option("--bgm-mode <mode>", "loop | once")
|
|
174
|
+
// --- Output / extra ---
|
|
175
|
+
.option("--video-fps <n>", "output video fps", (v) => parseInt(v, 10))
|
|
176
|
+
.option("--template-params <json>", "extra template placeholders as JSON string", (v) => {
|
|
177
|
+
try {
|
|
178
|
+
return JSON.parse(v);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
throw new Error(`--template-params: invalid JSON: ${v}`);
|
|
182
|
+
}
|
|
183
|
+
})
|
|
184
|
+
// --- Runtime / workflow ---
|
|
185
|
+
.option("--recipe <file>", "load defaults from a JSON recipe file (CLI flags still override)")
|
|
186
|
+
.option("--redo", "replay last successful create from ~/.reelforge/last-create.json")
|
|
187
|
+
.option("--dry-run", "print the final request body + estimated units; do NOT submit")
|
|
10
188
|
.option("--no-wait", "submit and return task_id immediately (do not poll)")
|
|
11
189
|
.option("-o, --output <file>", "save the final video to this path (only when waiting)")
|
|
12
190
|
.option("--poll-ms <ms>", "poll interval while waiting", (v) => parseInt(v, 10), 1500)
|
|
@@ -14,25 +192,124 @@ export function registerCreate(program) {
|
|
|
14
192
|
.addHelpText("after", [
|
|
15
193
|
"",
|
|
16
194
|
"Defaults match the /create web page:",
|
|
17
|
-
" - template
|
|
18
|
-
" -
|
|
19
|
-
" - voice: zh-CN-YunjianNeural @ 1.2x speed",
|
|
195
|
+
" mode=generate · n-scenes=5 · frame-template=1080x1920/image_default.html",
|
|
196
|
+
" tts-voice=zh-CN-YunjianNeural · tts-speed=1.2",
|
|
20
197
|
"",
|
|
21
|
-
"
|
|
198
|
+
"Param groups:",
|
|
199
|
+
" Content : --mode --title -n --split-mode --min/max-narration-words --min/max-image-prompt-words",
|
|
200
|
+
" Visual : --frame-template --media-workflow --prompt-prefix",
|
|
201
|
+
" TTS : --tts-voice --tts-speed --tts-inference-mode --tts-workflow --voice-id --ref-audio",
|
|
202
|
+
" BGM : --bgm --bgm-volume --bgm-mode",
|
|
203
|
+
" Output : --video-fps --template-params -o --no-wait --poll-ms --timeout-ms",
|
|
204
|
+
" Workflow: --recipe --redo --dry-run",
|
|
205
|
+
"",
|
|
206
|
+
"Explore available resources (separate commands):",
|
|
207
|
+
" reelforge templates list # all HTML templates",
|
|
208
|
+
" reelforge workflows list --kind image # all AI image workflows",
|
|
209
|
+
" reelforge workflows list --kind video # all AI video workflows",
|
|
210
|
+
" reelforge tts voices --locale zh # Edge TTS voice ids",
|
|
211
|
+
" reelforge bgm list # built-in BGM files",
|
|
22
212
|
"",
|
|
23
213
|
"Examples:",
|
|
24
|
-
"
|
|
25
|
-
|
|
26
|
-
"
|
|
214
|
+
" # Minimum — defaults out 5 scenes, image background, Yunjian voice",
|
|
215
|
+
' reelforge create "为什么我们还没找到外星文明?"',
|
|
216
|
+
"",
|
|
217
|
+
" # Long script from a file, fixed mode (no LLM scriptwriting)",
|
|
218
|
+
" reelforge create @./script.txt --mode fixed --split-mode paragraph",
|
|
219
|
+
"",
|
|
220
|
+
" # Landscape (1920x1080)",
|
|
221
|
+
' reelforge create "..." --frame-template 1920x1080/image_default.html',
|
|
222
|
+
"",
|
|
223
|
+
" # AI-generated video background instead of still image",
|
|
224
|
+
' reelforge create "..." \\',
|
|
225
|
+
" --frame-template 1080x1920/video_default.html \\",
|
|
226
|
+
" --media-workflow runninghub/video_wan2.2.json",
|
|
227
|
+
"",
|
|
228
|
+
" # Add BGM",
|
|
229
|
+
' reelforge create "..." --bgm bgm/Echoes.mp3 --bgm-volume 0.3 --bgm-mode loop',
|
|
230
|
+
"",
|
|
231
|
+
" # Change voice + speed",
|
|
232
|
+
' reelforge create "..." --tts-voice zh-CN-XiaoxiaoNeural --tts-speed 1.0',
|
|
233
|
+
"",
|
|
234
|
+
" # Full recipe in one file",
|
|
235
|
+
" reelforge create --recipe ./space.recipe.json",
|
|
236
|
+
"",
|
|
237
|
+
" # Override a field on top of a recipe",
|
|
238
|
+
' reelforge create --recipe ./space.recipe.json --text "新主题" -n 8',
|
|
239
|
+
"",
|
|
240
|
+
" # Replay last successful create",
|
|
241
|
+
" reelforge create --redo",
|
|
242
|
+
"",
|
|
243
|
+
" # Replay last but tweak one knob",
|
|
244
|
+
" reelforge create --redo --tts-speed 1.0",
|
|
245
|
+
"",
|
|
246
|
+
" # See exactly what would be sent (no submission)",
|
|
247
|
+
' reelforge create "..." -n 7 --bgm bgm/Echoes.mp3 --dry-run',
|
|
248
|
+
"",
|
|
249
|
+
"Recipe file format (every field is optional; all keys match the REST API body):",
|
|
250
|
+
" {",
|
|
251
|
+
' "text": "为什么我们还没找到外星文明?",',
|
|
252
|
+
' "n_scenes": 7,',
|
|
253
|
+
' "frame_template": "1080x1920/image_default.html",',
|
|
254
|
+
' "media_workflow": "runninghub/image_flux.json",',
|
|
255
|
+
' "prompt_prefix": "Minimalist matchstick figure style",',
|
|
256
|
+
' "tts_voice": "zh-CN-YunjianNeural",',
|
|
257
|
+
' "tts_speed": 1.2,',
|
|
258
|
+
' "bgm_path": "bgm/Echoes.mp3",',
|
|
259
|
+
' "bgm_volume": 0.2',
|
|
260
|
+
" }",
|
|
27
261
|
].join("\n"))
|
|
28
|
-
.action(async (
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
262
|
+
.action(async (topicArg, opts) => {
|
|
263
|
+
// 1. Layer defaults: --redo → --recipe → CLI opts → positional topic
|
|
264
|
+
let body = {};
|
|
265
|
+
if (opts.redo) {
|
|
266
|
+
const last = await loadLastCreate();
|
|
267
|
+
if (!last) {
|
|
268
|
+
throw new Error(`--redo: no previous create found at ${LAST_CREATE_PATH}. Run at least one successful create first.`);
|
|
269
|
+
}
|
|
270
|
+
body = { ...last };
|
|
271
|
+
info(`Loaded last create from ${LAST_CREATE_PATH}`);
|
|
272
|
+
}
|
|
273
|
+
if (opts.recipe) {
|
|
274
|
+
const recipe = await loadRecipe(opts.recipe);
|
|
275
|
+
body = { ...body, ...recipe };
|
|
276
|
+
info(`Loaded recipe from ${opts.recipe}`);
|
|
277
|
+
}
|
|
278
|
+
// CLI options layer
|
|
279
|
+
const fromOpts = optsToBody(opts);
|
|
280
|
+
body = { ...body, ...fromOpts };
|
|
281
|
+
// Positional topic wins for `text` (with @file support)
|
|
282
|
+
if (topicArg) {
|
|
283
|
+
body.text = await resolveText(topicArg);
|
|
284
|
+
}
|
|
285
|
+
else if (typeof body.text === "string") {
|
|
286
|
+
body.text = await resolveText(body.text);
|
|
287
|
+
}
|
|
288
|
+
if (!body.text) {
|
|
289
|
+
throw new Error("text is required — pass it as the positional arg, or via --text / --recipe / --redo.");
|
|
290
|
+
}
|
|
291
|
+
// 2. Apply defaults for fields still unset
|
|
292
|
+
const finalBody = {
|
|
293
|
+
...DEFAULTS,
|
|
294
|
+
...body,
|
|
295
|
+
text: body.text,
|
|
296
|
+
};
|
|
297
|
+
// 3. Estimate cost
|
|
298
|
+
const estimate = estimateUnits(finalBody);
|
|
299
|
+
// 4. Dry-run: print & exit
|
|
300
|
+
if (opts.dryRun) {
|
|
301
|
+
info("--- DRY RUN ---");
|
|
302
|
+
info("Final request body:");
|
|
303
|
+
print(finalBody);
|
|
304
|
+
info(`Estimated cost: ${estimate} units`);
|
|
305
|
+
info("(use without --dry-run to actually submit)");
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
info(`Submitting create task (≈ ${estimate} units)...`);
|
|
309
|
+
const submitted = await post("/api/v1/pipelines/standard", finalBody);
|
|
310
|
+
// 5. Save as last (post-submit, before wait — so even cancelled tasks can be replayed)
|
|
311
|
+
await saveLastCreate(finalBody).catch((e) => {
|
|
312
|
+
warn(`Could not save last-create.json: ${e.message}`);
|
|
36
313
|
});
|
|
37
314
|
if (opts.wait === false) {
|
|
38
315
|
print({ task_id: submitted.task_id, status: submitted.status });
|
package/package.json
CHANGED