reelforge 0.3.1 → 0.3.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 +37 -41
- package/dist/commands/create.js +122 -18
- package/dist/index.js +8 -13
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -14,37 +14,43 @@ Or use directly without install:
|
|
|
14
14
|
npx reelforge <command>
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
After install, two binaries are on your `PATH` — `reelforge` and the short alias `rf`. Both behave identically
|
|
17
|
+
After install, two binaries are on your `PATH` — `reelforge` and the short alias `rf`. Both behave identically; the docs use `rf` from here on.
|
|
18
18
|
|
|
19
19
|
```bash
|
|
20
20
|
rf --version # same as `reelforge --version`
|
|
21
|
-
rf create "..." -o out.mp4
|
|
22
21
|
```
|
|
23
22
|
|
|
24
23
|
## Quick start
|
|
25
24
|
|
|
26
|
-
|
|
25
|
+
The CLI ships pointing at the hosted instance (`https://reelforge.timor419.com`). Log in once, then call:
|
|
27
26
|
|
|
28
27
|
```bash
|
|
29
28
|
npm install -g reelforge
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
# or the short alias:
|
|
34
|
-
rf create "为什么我们还没找到外星文明?" -o space.mp4
|
|
29
|
+
rf login # opens browser; headless? rf login <api_key>
|
|
30
|
+
rf whoami # balance + api_keys
|
|
31
|
+
rf create "为什么我们还没找到外星文明?" # auto-saves to ./<title>-<id>.mp4 in cwd
|
|
35
32
|
```
|
|
36
33
|
|
|
37
34
|
That's the whole story — no server to run.
|
|
38
35
|
|
|
36
|
+
### Output behavior
|
|
37
|
+
|
|
38
|
+
| invocation | result |
|
|
39
|
+
|---|---|
|
|
40
|
+
| `rf create "..."` | Saves to `./<sanitized-title>-<task_id_short>.mp4`, prints the path |
|
|
41
|
+
| `rf create "..." -o ./videos/space.mp4` | Saves to that exact path (must include filename, not just a directory) |
|
|
42
|
+
| `rf create "..." --no-download` | Skips local save, prints JSON result with `video_url` |
|
|
43
|
+
| `rf create "..." \| jq .video_url` | When stdout is piped, download is skipped automatically |
|
|
44
|
+
|
|
39
45
|
### Self-hosting
|
|
40
46
|
|
|
41
47
|
If you want to run your own ReelForge Studio (own LLM / RunningHub keys, your own pricing) clone the upstream repo, `pnpm dev`, then point the CLI at it:
|
|
42
48
|
|
|
43
49
|
```bash
|
|
44
|
-
|
|
50
|
+
rf --server http://localhost:8501 health
|
|
45
51
|
# or persist:
|
|
46
52
|
export REELFORGE_SERVER=http://localhost:8501
|
|
47
|
-
# or via `
|
|
53
|
+
# or via `rf login <key> --server http://localhost:8501`
|
|
48
54
|
```
|
|
49
55
|
|
|
50
56
|
## Global options
|
|
@@ -60,7 +66,7 @@ export REELFORGE_SERVER=http://localhost:8501
|
|
|
60
66
|
|
|
61
67
|
## Command map
|
|
62
68
|
|
|
63
|
-
Run `
|
|
69
|
+
Run `rf <command> --help` for full details on any of these.
|
|
64
70
|
|
|
65
71
|
### Core capabilities
|
|
66
72
|
|
|
@@ -100,7 +106,7 @@ Run `reelforge <command> --help` for full details on any of these.
|
|
|
100
106
|
|
|
101
107
|
### End-to-end pipelines
|
|
102
108
|
|
|
103
|
-
All `pipelines *` commands submit an **async task** and (by default) poll until it finishes with a live progress indicator on stderr. Use `--no-wait` to return immediately with a `task_id`, then `
|
|
109
|
+
All `pipelines *` commands submit an **async task** and (by default) poll until it finishes with a live progress indicator on stderr. Use `--no-wait` to return immediately with a `task_id`, then `rf tasks wait <id>` later.
|
|
104
110
|
|
|
105
111
|
| command | what it does |
|
|
106
112
|
|---|---|
|
|
@@ -133,25 +139,28 @@ All `pipelines *` commands submit an **async task** and (by default) poll until
|
|
|
133
139
|
## Examples
|
|
134
140
|
|
|
135
141
|
```bash
|
|
136
|
-
# 1.
|
|
137
|
-
|
|
142
|
+
# 1. One-click out a video (auto-saves to ./<title>-<id>.mp4 in cwd)
|
|
143
|
+
rf create "为什么我们还没找到外星文明?"
|
|
144
|
+
|
|
145
|
+
# 2. Same, but with a fixed script and explicit output path
|
|
146
|
+
rf pipelines standard \
|
|
138
147
|
-t "Hello world. This is scene one.\n\nThis is scene two." \
|
|
139
148
|
--mode fixed --title "Smoke Test" \
|
|
140
149
|
--frame-template 1080x1920/static_default.html \
|
|
141
150
|
--tts-voice en-US-AriaNeural -o smoke.mp4
|
|
142
151
|
|
|
143
|
-
#
|
|
144
|
-
|
|
145
|
-
|
|
152
|
+
# 3. Inspect existing tasks & redownload a finished video
|
|
153
|
+
rf tasks list --limit 5
|
|
154
|
+
rf history get <task-id> --download recovered.mp4
|
|
146
155
|
|
|
147
|
-
#
|
|
148
|
-
|
|
156
|
+
# 4. JSON pipe for automation
|
|
157
|
+
rf workflows list --kind image --json | jq '.workflows[].key'
|
|
149
158
|
|
|
150
|
-
#
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
159
|
+
# 5. Configure & test LLM (self-hosted)
|
|
160
|
+
rf config set llm.api_key sk-xxxxx
|
|
161
|
+
rf config set llm.base_url https://dashscope.aliyuncs.com/compatible-mode/v1
|
|
162
|
+
rf config set llm.model qwen-plus
|
|
163
|
+
rf llm chat -p 'one-sentence summary of antifragile'
|
|
155
164
|
```
|
|
156
165
|
|
|
157
166
|
## Tip — getting unstuck
|
|
@@ -159,23 +168,10 @@ reelforge llm chat -p 'one-sentence summary of antifragile'
|
|
|
159
168
|
Every level has `--help`:
|
|
160
169
|
|
|
161
170
|
```bash
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
```
|
|
167
|
-
|
|
168
|
-
## Development & publishing
|
|
169
|
-
|
|
170
|
-
```bash
|
|
171
|
-
cd cli
|
|
172
|
-
npm install
|
|
173
|
-
npm run build
|
|
174
|
-
npm link # makes `reelforge` available globally for testing
|
|
175
|
-
reelforge health # try it out
|
|
176
|
-
|
|
177
|
-
# Publish to npm
|
|
178
|
-
npm publish --access public
|
|
171
|
+
rf --help # top-level overview
|
|
172
|
+
rf pipelines --help # list of pipelines
|
|
173
|
+
rf pipelines standard --help # full option reference
|
|
174
|
+
rf tts edge --help # one specific command
|
|
179
175
|
```
|
|
180
176
|
|
|
181
177
|
## License
|
package/dist/commands/create.js
CHANGED
|
@@ -82,6 +82,63 @@ async function saveLastCreate(body) {
|
|
|
82
82
|
await fs.mkdir(path.dirname(LAST_CREATE_PATH), { recursive: true });
|
|
83
83
|
await fs.writeFile(LAST_CREATE_PATH, JSON.stringify(body, null, 2) + "\n", "utf-8");
|
|
84
84
|
}
|
|
85
|
+
// ── Filename derivation ─────────────────────────────────────────
|
|
86
|
+
//
|
|
87
|
+
// Cascade (highest → lowest):
|
|
88
|
+
// 1. result.title — server's actual video title (LLM or explicit)
|
|
89
|
+
// 2. body.title — user-supplied --title (pre-task fallback)
|
|
90
|
+
// 3. raw topic (mode=generate, length ≤ 60, no @-prefix)
|
|
91
|
+
// 4. @file stem — when text was loaded from @./script.txt
|
|
92
|
+
// 5. "reelforge" literal
|
|
93
|
+
// Always suffixed with "-<task_id[:8]>" to avoid collisions.
|
|
94
|
+
const FILENAME_MAX_CHARS = 40;
|
|
95
|
+
function sanitizeFilename(name) {
|
|
96
|
+
const cleaned = name
|
|
97
|
+
.replace(/[\/\\:*?"<>|\r\n\t]+/g, "-")
|
|
98
|
+
.replace(/\s+/g, " ")
|
|
99
|
+
.trim()
|
|
100
|
+
.replace(/^[-.\s]+|[-.\s]+$/g, "");
|
|
101
|
+
const chars = Array.from(cleaned);
|
|
102
|
+
if (chars.length <= FILENAME_MAX_CHARS)
|
|
103
|
+
return cleaned;
|
|
104
|
+
return chars.slice(0, FILENAME_MAX_CHARS).join("").replace(/[-.\s]+$/g, "");
|
|
105
|
+
}
|
|
106
|
+
function computeDefaultFilename(args) {
|
|
107
|
+
const ext = args.ext || "mp4";
|
|
108
|
+
const shortId = args.taskId.slice(0, 8);
|
|
109
|
+
let base;
|
|
110
|
+
if (args.resultTitle && args.resultTitle.trim()) {
|
|
111
|
+
base = sanitizeFilename(args.resultTitle);
|
|
112
|
+
}
|
|
113
|
+
else if (args.bodyTitle && args.bodyTitle.trim()) {
|
|
114
|
+
base = sanitizeFilename(args.bodyTitle);
|
|
115
|
+
}
|
|
116
|
+
else if (args.mode === "generate" &&
|
|
117
|
+
args.rawTextInput &&
|
|
118
|
+
!args.rawTextInput.startsWith("@") &&
|
|
119
|
+
Array.from(args.rawTextInput).length <= 60) {
|
|
120
|
+
base = sanitizeFilename(args.rawTextInput);
|
|
121
|
+
}
|
|
122
|
+
else if (args.fileStemFromAt) {
|
|
123
|
+
base = sanitizeFilename(args.fileStemFromAt);
|
|
124
|
+
}
|
|
125
|
+
return `${base || "reelforge"}-${shortId}.${ext}`;
|
|
126
|
+
}
|
|
127
|
+
async function validateOutputPath(out) {
|
|
128
|
+
if (out.endsWith("/") || out.endsWith("\\")) {
|
|
129
|
+
throw new Error(`-o must include a filename (got directory-only path: ${out}). Example: -o ./videos/space.mp4`);
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
const stat = await fs.stat(out);
|
|
133
|
+
if (stat.isDirectory()) {
|
|
134
|
+
throw new Error(`-o must include a filename (path exists as a directory: ${out}). Example: -o ${out.replace(/[\\/]+$/, "")}/space.mp4`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
catch (e) {
|
|
138
|
+
if (e.code !== "ENOENT")
|
|
139
|
+
throw e;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
85
142
|
/** Camel-case CLI options → snake_case body, only including provided fields */
|
|
86
143
|
function optsToBody(opts) {
|
|
87
144
|
const out = {};
|
|
@@ -186,7 +243,8 @@ export function registerCreate(program) {
|
|
|
186
243
|
.option("--redo", "replay last successful create from ~/.reelforge/last-create.json")
|
|
187
244
|
.option("--dry-run", "print the final request body + estimated units; do NOT submit")
|
|
188
245
|
.option("--no-wait", "submit and return task_id immediately (do not poll)")
|
|
189
|
-
.option("-o, --output <file>", "save the final video to this path (
|
|
246
|
+
.option("-o, --output <file>", "save the final video to this exact path (must include filename, e.g. ./out/space.mp4). Default: auto-named file in current directory.")
|
|
247
|
+
.option("--no-download", "do not save the video locally — just print the JSON result with video_url")
|
|
190
248
|
.option("--poll-ms <ms>", "poll interval while waiting", (v) => parseInt(v, 10), 1500)
|
|
191
249
|
.option("--timeout-ms <ms>", "max wait time before aborting (default unlimited)", (v) => parseInt(v, 10))
|
|
192
250
|
.addHelpText("after", [
|
|
@@ -200,9 +258,15 @@ export function registerCreate(program) {
|
|
|
200
258
|
" Visual : --frame-template --media-workflow --prompt-prefix",
|
|
201
259
|
" TTS : --tts-voice --tts-speed --tts-inference-mode --tts-workflow --voice-id --ref-audio",
|
|
202
260
|
" BGM : --bgm --bgm-volume --bgm-mode",
|
|
203
|
-
" Output : --video-fps --template-params -o --no-wait --poll-ms --timeout-ms",
|
|
261
|
+
" Output : --video-fps --template-params -o --no-download --no-wait --poll-ms --timeout-ms",
|
|
204
262
|
" Workflow: --recipe --redo --dry-run",
|
|
205
263
|
"",
|
|
264
|
+
"Output behavior:",
|
|
265
|
+
" No flag → saves to ./<title>-<task_id>.mp4 in current directory, prints the path",
|
|
266
|
+
" -o <path> → saves to that exact path (must include filename, not just a directory)",
|
|
267
|
+
" --no-download → skips local save, just prints JSON result with video_url",
|
|
268
|
+
" (when stdout is piped, --no-download is implied automatically)",
|
|
269
|
+
"",
|
|
206
270
|
"Explore available resources (separate commands):",
|
|
207
271
|
" reelforge templates list # all HTML templates",
|
|
208
272
|
" reelforge workflows list --kind image # all AI image workflows",
|
|
@@ -210,41 +274,47 @@ export function registerCreate(program) {
|
|
|
210
274
|
" reelforge tts voices --locale zh # Edge TTS voice ids",
|
|
211
275
|
" reelforge bgm list # built-in BGM files",
|
|
212
276
|
"",
|
|
213
|
-
"Examples:",
|
|
214
|
-
" # Minimum —
|
|
215
|
-
'
|
|
277
|
+
"Examples (`rf` is a short alias for `reelforge`):",
|
|
278
|
+
" # Minimum — saves to ./<title>-<short_id>.mp4 in cwd",
|
|
279
|
+
' rf create "为什么我们还没找到外星文明?"',
|
|
280
|
+
"",
|
|
281
|
+
" # Pick the exact output path",
|
|
282
|
+
' rf create "..." -o ./videos/space.mp4',
|
|
216
283
|
"",
|
|
217
284
|
" # Long script from a file, fixed mode (no LLM scriptwriting)",
|
|
218
|
-
"
|
|
285
|
+
" rf create @./script.txt --mode fixed --split-mode paragraph",
|
|
219
286
|
"",
|
|
220
287
|
" # Landscape (1920x1080)",
|
|
221
|
-
'
|
|
288
|
+
' rf create "..." --frame-template 1920x1080/image_default.html',
|
|
222
289
|
"",
|
|
223
290
|
" # AI-generated video background instead of still image",
|
|
224
|
-
'
|
|
291
|
+
' rf create "..." \\',
|
|
225
292
|
" --frame-template 1080x1920/video_default.html \\",
|
|
226
293
|
" --media-workflow runninghub/video_wan2.2.json",
|
|
227
294
|
"",
|
|
228
295
|
" # Add BGM",
|
|
229
|
-
'
|
|
296
|
+
' rf create "..." --bgm bgm/Echoes.mp3 --bgm-volume 0.3 --bgm-mode loop',
|
|
230
297
|
"",
|
|
231
298
|
" # Change voice + speed",
|
|
232
|
-
'
|
|
299
|
+
' rf create "..." --tts-voice zh-CN-XiaoxiaoNeural --tts-speed 1.0',
|
|
233
300
|
"",
|
|
234
301
|
" # Full recipe in one file",
|
|
235
|
-
"
|
|
302
|
+
" rf create --recipe ./space.recipe.json",
|
|
236
303
|
"",
|
|
237
304
|
" # Override a field on top of a recipe",
|
|
238
|
-
'
|
|
305
|
+
' rf create --recipe ./space.recipe.json --text "新主题" -n 8',
|
|
239
306
|
"",
|
|
240
307
|
" # Replay last successful create",
|
|
241
|
-
"
|
|
308
|
+
" rf create --redo",
|
|
242
309
|
"",
|
|
243
310
|
" # Replay last but tweak one knob",
|
|
244
|
-
"
|
|
311
|
+
" rf create --redo --tts-speed 1.0",
|
|
245
312
|
"",
|
|
246
313
|
" # See exactly what would be sent (no submission)",
|
|
247
|
-
'
|
|
314
|
+
' rf create "..." -n 7 --bgm bgm/Echoes.mp3 --dry-run',
|
|
315
|
+
"",
|
|
316
|
+
" # Pipe-friendly: skip local download, take video_url for downstream",
|
|
317
|
+
' rf create "..." --no-download --json | jq -r .video_url',
|
|
248
318
|
"",
|
|
249
319
|
"Recipe file format (every field is optional; all keys match the REST API body):",
|
|
250
320
|
" {",
|
|
@@ -260,6 +330,10 @@ export function registerCreate(program) {
|
|
|
260
330
|
" }",
|
|
261
331
|
].join("\n"))
|
|
262
332
|
.action(async (topicArg, opts) => {
|
|
333
|
+
// Validate -o early so we fail before submitting a paid task
|
|
334
|
+
if (opts.output) {
|
|
335
|
+
await validateOutputPath(opts.output);
|
|
336
|
+
}
|
|
263
337
|
// 1. Layer defaults: --redo → --recipe → CLI opts → positional topic
|
|
264
338
|
let body = {};
|
|
265
339
|
if (opts.redo) {
|
|
@@ -278,6 +352,12 @@ export function registerCreate(program) {
|
|
|
278
352
|
// CLI options layer
|
|
279
353
|
const fromOpts = optsToBody(opts);
|
|
280
354
|
body = { ...body, ...fromOpts };
|
|
355
|
+
// Capture the raw text input (with potential @-prefix) for filename derivation.
|
|
356
|
+
// After `resolveText` we lose the @path → file stem mapping.
|
|
357
|
+
const rawTextInput = topicArg ?? (typeof body.text === "string" ? body.text : undefined);
|
|
358
|
+
const fileStemFromAt = rawTextInput?.startsWith("@")
|
|
359
|
+
? path.parse(rawTextInput.slice(1)).name
|
|
360
|
+
: undefined;
|
|
281
361
|
// Positional topic wins for `text` (with @file support)
|
|
282
362
|
if (topicArg) {
|
|
283
363
|
body.text = await resolveText(topicArg);
|
|
@@ -324,9 +404,33 @@ export function registerCreate(program) {
|
|
|
324
404
|
throw new Error(t.error || `Task ended with status ${t.status}`);
|
|
325
405
|
}
|
|
326
406
|
const result = t.result;
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
407
|
+
// Decide where (or whether) to save locally.
|
|
408
|
+
// -o → that exact path
|
|
409
|
+
// --no-download → skip
|
|
410
|
+
// stdout piped → skip (clig.dev: don't dump binary-touching side effects into a script)
|
|
411
|
+
// otherwise → auto-named in cwd
|
|
412
|
+
if (result?.video_url) {
|
|
413
|
+
const stdoutIsPipe = !process.stdout.isTTY;
|
|
414
|
+
const skipDownload = !!opts.noDownload || (stdoutIsPipe && !opts.output);
|
|
415
|
+
let savedPath;
|
|
416
|
+
if (opts.output) {
|
|
417
|
+
savedPath = opts.output;
|
|
418
|
+
}
|
|
419
|
+
else if (!skipDownload) {
|
|
420
|
+
savedPath = computeDefaultFilename({
|
|
421
|
+
resultTitle: result.title,
|
|
422
|
+
bodyTitle: finalBody.title,
|
|
423
|
+
mode: finalBody.mode,
|
|
424
|
+
rawTextInput,
|
|
425
|
+
fileStemFromAt,
|
|
426
|
+
taskId: t.id,
|
|
427
|
+
ext: "mp4",
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
if (savedPath) {
|
|
431
|
+
await downloadTo(result.video_url, savedPath);
|
|
432
|
+
success(`Saved → ${savedPath}`);
|
|
433
|
+
}
|
|
330
434
|
}
|
|
331
435
|
print({ task_id: t.id, status: t.status, ...result });
|
|
332
436
|
});
|
package/dist/index.js
CHANGED
|
@@ -66,19 +66,14 @@ program
|
|
|
66
66
|
program.addHelpText("afterAll", [
|
|
67
67
|
"",
|
|
68
68
|
"Examples (tip: `rf` works wherever you see `reelforge`):",
|
|
69
|
-
"
|
|
70
|
-
" rf create '
|
|
71
|
-
"
|
|
72
|
-
"
|
|
73
|
-
"
|
|
74
|
-
"
|
|
75
|
-
"
|
|
76
|
-
"
|
|
77
|
-
"",
|
|
78
|
-
"Setup before publishing to npm:",
|
|
79
|
-
" 1) cd cli && npm install && npm run build",
|
|
80
|
-
" 2) npm link (test locally as `reelforge`)",
|
|
81
|
-
" 3) npm publish --access public",
|
|
69
|
+
" rf create '为什么我们还没有找到外星文明?' # auto-saves to ./<title>-<id>.mp4 in cwd",
|
|
70
|
+
" rf create '...' -o ./videos/space.mp4 # pick the exact path",
|
|
71
|
+
" rf llm chat --prompt 'explain antifragile in 3 sentences'",
|
|
72
|
+
" rf tts edge --text 'hello world' --voice en-US-AriaNeural -o out.mp3",
|
|
73
|
+
" rf images generate --prompt 'a cat' --workflow selfhost/image_flux.json -o cat.png",
|
|
74
|
+
" rf pipelines standard --text 'why we explore space' --tts-voice zh-CN-YunjianNeural",
|
|
75
|
+
" rf tasks list --status running",
|
|
76
|
+
" rf config get",
|
|
82
77
|
].join("\n"));
|
|
83
78
|
registerAuth(program);
|
|
84
79
|
registerCreate(program);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "reelforge",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"description": "CLI for ReelForge Studio — AI video engine. Every REST API exposed as a command, with --help on every level.",
|
|
3
|
+
"version": "0.3.2",
|
|
4
|
+
"description": "CLI for ReelForge Studio — AI video engine. Installs as both `reelforge` and the short alias `rf`. Every REST API exposed as a command, with --help on every level.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|