reelforge 0.3.1 → 0.4.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 +37 -41
- package/dist/commands/create.js +257 -20
- package/dist/index.js +8 -13
- package/package.json +52 -52
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 = {};
|
|
@@ -140,6 +197,115 @@ const DEFAULTS = {
|
|
|
140
197
|
tts_voice: "zh-CN-YunjianNeural",
|
|
141
198
|
tts_speed: 1.2,
|
|
142
199
|
};
|
|
200
|
+
const STYLE_PRESETS = {
|
|
201
|
+
matchstick: {
|
|
202
|
+
prefix: "Minimalist black-and-white matchstick figure style illustration, clean lines, simple sketch style",
|
|
203
|
+
label: "极简黑白火柴人",
|
|
204
|
+
scene: "知识科普 / 哲思 / 段子",
|
|
205
|
+
},
|
|
206
|
+
cinematic: {
|
|
207
|
+
prefix: "cinematic photography, soft natural lighting, shallow depth of field, 35mm film, color grading",
|
|
208
|
+
label: "电影感",
|
|
209
|
+
scene: "故事 / 旅行 / 通用百搭",
|
|
210
|
+
},
|
|
211
|
+
photorealistic: {
|
|
212
|
+
prefix: "photorealistic, professional studio lighting, sharp focus, high detail, 85mm portrait lens",
|
|
213
|
+
label: "写实棚拍",
|
|
214
|
+
scene: "产品 / 人像 / 美食",
|
|
215
|
+
},
|
|
216
|
+
documentary: {
|
|
217
|
+
prefix: "documentary photography, candid moment, natural available light, photojournalism, realistic colors",
|
|
218
|
+
label: "纪实新闻",
|
|
219
|
+
scene: "历史 / 人物 / 纪录",
|
|
220
|
+
},
|
|
221
|
+
flat: {
|
|
222
|
+
prefix: "flat vector illustration, modern editorial style, geometric shapes, limited color palette, clean composition",
|
|
223
|
+
label: "扁平商业插画",
|
|
224
|
+
scene: "财经 / 商业 / 数据科普",
|
|
225
|
+
},
|
|
226
|
+
anime: {
|
|
227
|
+
prefix: "anime illustration, cel-shaded, vibrant colors, detailed background, expressive characters, Makoto Shinkai aesthetic",
|
|
228
|
+
label: "日漫",
|
|
229
|
+
scene: "故事 / 二次元 / 年轻向",
|
|
230
|
+
},
|
|
231
|
+
comic: {
|
|
232
|
+
prefix: "comic book illustration, bold ink outlines, halftone shading, dynamic poses, Marvel style",
|
|
233
|
+
label: "美漫",
|
|
234
|
+
scene: "动作 / 英雄向 / 段子",
|
|
235
|
+
},
|
|
236
|
+
watercolor: {
|
|
237
|
+
prefix: "soft watercolor painting, gentle brush strokes, pastel color palette, dreamy atmosphere, hand-painted texture",
|
|
238
|
+
label: "水彩",
|
|
239
|
+
scene: "治愈 / 情感 / 小红书风",
|
|
240
|
+
},
|
|
241
|
+
"oil-painting": {
|
|
242
|
+
prefix: "oil painting, visible brushstrokes, rich saturated colors, classical composition, canvas texture",
|
|
243
|
+
label: "油画",
|
|
244
|
+
scene: "文艺 / 古典 / 艺术史",
|
|
245
|
+
},
|
|
246
|
+
ink: {
|
|
247
|
+
prefix: "traditional Chinese ink painting, sumi-e style, flowing brushwork, monochrome wash, minimalist composition with negative space",
|
|
248
|
+
label: "中国水墨",
|
|
249
|
+
scene: "中式叙事 / 古诗词 / 国风",
|
|
250
|
+
},
|
|
251
|
+
"3d-render": {
|
|
252
|
+
prefix: "3D render, octane render, smooth surfaces, soft global illumination, polished and clean",
|
|
253
|
+
label: "三维渲染",
|
|
254
|
+
scene: "科技 / 产品 / 未来 / 概念图",
|
|
255
|
+
},
|
|
256
|
+
claymation: {
|
|
257
|
+
prefix: "claymation style, handmade clay textures, soft diffused studio lighting, matte surfaces, pastel colors",
|
|
258
|
+
label: "黏土定格",
|
|
259
|
+
scene: "儿童 / 萌系 / 治愈段子",
|
|
260
|
+
},
|
|
261
|
+
"low-poly": {
|
|
262
|
+
prefix: "low poly 3D art, triangular faceted surfaces, gradient coloring, minimalist geometric, clean style",
|
|
263
|
+
label: "低多边形",
|
|
264
|
+
scene: "科技 / 概念 / 设计感",
|
|
265
|
+
},
|
|
266
|
+
pixel: {
|
|
267
|
+
prefix: "16-bit pixel art, retro game aesthetic, limited color palette, blocky shading, SNES style",
|
|
268
|
+
label: "像素",
|
|
269
|
+
scene: "游戏 / 怀旧 / 段子",
|
|
270
|
+
},
|
|
271
|
+
cyberpunk: {
|
|
272
|
+
prefix: "cyberpunk, neon-lit dystopian cityscape, rain-slicked streets, holographic signs, Blade Runner aesthetic",
|
|
273
|
+
label: "赛博朋克",
|
|
274
|
+
scene: "科技未来 / 都市夜景",
|
|
275
|
+
},
|
|
276
|
+
vaporwave: {
|
|
277
|
+
prefix: "vaporwave aesthetic, pink and purple gradient, retro 90s elements, glitch effects, dreamlike surreal",
|
|
278
|
+
label: "蒸汽波",
|
|
279
|
+
scene: "网络梗 / 怀旧 / 抽象审美",
|
|
280
|
+
},
|
|
281
|
+
"art-deco": {
|
|
282
|
+
prefix: "Art Deco style, geometric symmetric patterns, gold and black palette, 1920s glamour, luxury aesthetic",
|
|
283
|
+
label: "装饰艺术",
|
|
284
|
+
scene: "奢华品牌 / 复古优雅",
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
// CJK chars take 2 display columns in monospace terminals; pad accordingly.
|
|
288
|
+
function displayWidth(s) {
|
|
289
|
+
let w = 0;
|
|
290
|
+
for (const c of s)
|
|
291
|
+
w += c.charCodeAt(0) > 0x7f ? 2 : 1;
|
|
292
|
+
return w;
|
|
293
|
+
}
|
|
294
|
+
function padDisplay(s, width) {
|
|
295
|
+
const pad = Math.max(0, width - displayWidth(s));
|
|
296
|
+
return s + " ".repeat(pad);
|
|
297
|
+
}
|
|
298
|
+
function formatStylePresetsList() {
|
|
299
|
+
const keys = Object.keys(STYLE_PRESETS);
|
|
300
|
+
const keyW = Math.max(...keys.map((k) => k.length));
|
|
301
|
+
const labelW = Math.max(...keys.map((k) => displayWidth(STYLE_PRESETS[k].label)));
|
|
302
|
+
return keys
|
|
303
|
+
.map((k) => {
|
|
304
|
+
const p = STYLE_PRESETS[k];
|
|
305
|
+
return ` ${padDisplay(k, keyW)} ${padDisplay(p.label, labelW)} ${p.scene}`;
|
|
306
|
+
})
|
|
307
|
+
.join("\n");
|
|
308
|
+
}
|
|
143
309
|
// ── Command registration ────────────────────────────────────────
|
|
144
310
|
export function registerCreate(program) {
|
|
145
311
|
program
|
|
@@ -159,7 +325,8 @@ export function registerCreate(program) {
|
|
|
159
325
|
// --- Visual ---
|
|
160
326
|
.option("--frame-template <key>", "HTML frame template, e.g. 1080x1920/image_default.html")
|
|
161
327
|
.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")
|
|
328
|
+
.option("--prompt-prefix <text>", "raw style prefix prepended to every image prompt (overrides --style)")
|
|
329
|
+
.option("--style <preset>", "image style preset — shortcut for --prompt-prefix; see 'Style presets' below for the full list")
|
|
163
330
|
// --- Audio (TTS) ---
|
|
164
331
|
.option("--tts-voice <id>", "Edge TTS voice id, e.g. zh-CN-YunjianNeural / en-US-AriaNeural")
|
|
165
332
|
.option("--tts-speed <n>", "speech speed 0.5..2", parseFloat)
|
|
@@ -186,7 +353,8 @@ export function registerCreate(program) {
|
|
|
186
353
|
.option("--redo", "replay last successful create from ~/.reelforge/last-create.json")
|
|
187
354
|
.option("--dry-run", "print the final request body + estimated units; do NOT submit")
|
|
188
355
|
.option("--no-wait", "submit and return task_id immediately (do not poll)")
|
|
189
|
-
.option("-o, --output <file>", "save the final video to this path (
|
|
356
|
+
.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.")
|
|
357
|
+
.option("--no-download", "do not save the video locally — just print the JSON result with video_url")
|
|
190
358
|
.option("--poll-ms <ms>", "poll interval while waiting", (v) => parseInt(v, 10), 1500)
|
|
191
359
|
.option("--timeout-ms <ms>", "max wait time before aborting (default unlimited)", (v) => parseInt(v, 10))
|
|
192
360
|
.addHelpText("after", [
|
|
@@ -197,12 +365,23 @@ export function registerCreate(program) {
|
|
|
197
365
|
"",
|
|
198
366
|
"Param groups:",
|
|
199
367
|
" Content : --mode --title -n --split-mode --min/max-narration-words --min/max-image-prompt-words",
|
|
200
|
-
" Visual : --frame-template --media-workflow --prompt-prefix",
|
|
368
|
+
" Visual : --frame-template --media-workflow --style --prompt-prefix",
|
|
201
369
|
" TTS : --tts-voice --tts-speed --tts-inference-mode --tts-workflow --voice-id --ref-audio",
|
|
202
370
|
" BGM : --bgm --bgm-volume --bgm-mode",
|
|
203
|
-
" Output : --video-fps --template-params -o --no-wait --poll-ms --timeout-ms",
|
|
371
|
+
" Output : --video-fps --template-params -o --no-download --no-wait --poll-ms --timeout-ms",
|
|
204
372
|
" Workflow: --recipe --redo --dry-run",
|
|
205
373
|
"",
|
|
374
|
+
"Style presets (--style <preset>) — quick shortcut for --prompt-prefix:",
|
|
375
|
+
formatStylePresetsList(),
|
|
376
|
+
" · Pass --prompt-prefix to override (raw string always wins).",
|
|
377
|
+
" · Omit both to use the server's configured default style.",
|
|
378
|
+
"",
|
|
379
|
+
"Output behavior:",
|
|
380
|
+
" No flag → saves to ./<title>-<task_id>.mp4 in current directory, prints the path",
|
|
381
|
+
" -o <path> → saves to that exact path (must include filename, not just a directory)",
|
|
382
|
+
" --no-download → skips local save, just prints JSON result with video_url",
|
|
383
|
+
" (when stdout is piped, --no-download is implied automatically)",
|
|
384
|
+
"",
|
|
206
385
|
"Explore available resources (separate commands):",
|
|
207
386
|
" reelforge templates list # all HTML templates",
|
|
208
387
|
" reelforge workflows list --kind image # all AI image workflows",
|
|
@@ -210,41 +389,54 @@ export function registerCreate(program) {
|
|
|
210
389
|
" reelforge tts voices --locale zh # Edge TTS voice ids",
|
|
211
390
|
" reelforge bgm list # built-in BGM files",
|
|
212
391
|
"",
|
|
213
|
-
"Examples:",
|
|
214
|
-
" # Minimum —
|
|
215
|
-
'
|
|
392
|
+
"Examples (`rf` is a short alias for `reelforge`):",
|
|
393
|
+
" # Minimum — saves to ./<title>-<short_id>.mp4 in cwd",
|
|
394
|
+
' rf create "为什么我们还没找到外星文明?"',
|
|
395
|
+
"",
|
|
396
|
+
" # Pick the exact output path",
|
|
397
|
+
' rf create "..." -o ./videos/space.mp4',
|
|
216
398
|
"",
|
|
217
399
|
" # Long script from a file, fixed mode (no LLM scriptwriting)",
|
|
218
|
-
"
|
|
400
|
+
" rf create @./script.txt --mode fixed --split-mode paragraph",
|
|
219
401
|
"",
|
|
220
402
|
" # Landscape (1920x1080)",
|
|
221
|
-
'
|
|
403
|
+
' rf create "..." --frame-template 1920x1080/image_default.html',
|
|
222
404
|
"",
|
|
223
405
|
" # AI-generated video background instead of still image",
|
|
224
|
-
'
|
|
406
|
+
' rf create "..." \\',
|
|
225
407
|
" --frame-template 1080x1920/video_default.html \\",
|
|
226
408
|
" --media-workflow runninghub/video_wan2.2.json",
|
|
227
409
|
"",
|
|
228
410
|
" # Add BGM",
|
|
229
|
-
'
|
|
411
|
+
' rf create "..." --bgm bgm/Echoes.mp3 --bgm-volume 0.3 --bgm-mode loop',
|
|
230
412
|
"",
|
|
231
413
|
" # Change voice + speed",
|
|
232
|
-
'
|
|
414
|
+
' rf create "..." --tts-voice zh-CN-XiaoxiaoNeural --tts-speed 1.0',
|
|
415
|
+
"",
|
|
416
|
+
" # Pick a built-in style preset",
|
|
417
|
+
' rf create "..." --style cinematic',
|
|
418
|
+
' rf create "美食教程" --style photorealistic',
|
|
419
|
+
"",
|
|
420
|
+
" # Free-form style — write your own prefix from scratch",
|
|
421
|
+
' rf create "..." --prompt-prefix "Studio Ghibli, pastel, dreamy"',
|
|
233
422
|
"",
|
|
234
423
|
" # Full recipe in one file",
|
|
235
|
-
"
|
|
424
|
+
" rf create --recipe ./space.recipe.json",
|
|
236
425
|
"",
|
|
237
426
|
" # Override a field on top of a recipe",
|
|
238
|
-
'
|
|
427
|
+
' rf create --recipe ./space.recipe.json --text "新主题" -n 8',
|
|
239
428
|
"",
|
|
240
429
|
" # Replay last successful create",
|
|
241
|
-
"
|
|
430
|
+
" rf create --redo",
|
|
242
431
|
"",
|
|
243
432
|
" # Replay last but tweak one knob",
|
|
244
|
-
"
|
|
433
|
+
" rf create --redo --tts-speed 1.0",
|
|
245
434
|
"",
|
|
246
435
|
" # See exactly what would be sent (no submission)",
|
|
247
|
-
'
|
|
436
|
+
' rf create "..." -n 7 --bgm bgm/Echoes.mp3 --dry-run',
|
|
437
|
+
"",
|
|
438
|
+
" # Pipe-friendly: skip local download, take video_url for downstream",
|
|
439
|
+
' rf create "..." --no-download --json | jq -r .video_url',
|
|
248
440
|
"",
|
|
249
441
|
"Recipe file format (every field is optional; all keys match the REST API body):",
|
|
250
442
|
" {",
|
|
@@ -260,6 +452,21 @@ export function registerCreate(program) {
|
|
|
260
452
|
" }",
|
|
261
453
|
].join("\n"))
|
|
262
454
|
.action(async (topicArg, opts) => {
|
|
455
|
+
// Validate -o early so we fail before submitting a paid task
|
|
456
|
+
if (opts.output) {
|
|
457
|
+
await validateOutputPath(opts.output);
|
|
458
|
+
}
|
|
459
|
+
// Expand --style preset to --prompt-prefix unless an explicit
|
|
460
|
+
// --prompt-prefix is also given (the raw string always wins).
|
|
461
|
+
if (opts.style) {
|
|
462
|
+
const preset = STYLE_PRESETS[opts.style];
|
|
463
|
+
if (!preset) {
|
|
464
|
+
throw new Error(`Unknown --style: ${opts.style}\nAvailable presets:\n${formatStylePresetsList()}`);
|
|
465
|
+
}
|
|
466
|
+
if (opts.promptPrefix === undefined) {
|
|
467
|
+
opts.promptPrefix = preset.prefix;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
263
470
|
// 1. Layer defaults: --redo → --recipe → CLI opts → positional topic
|
|
264
471
|
let body = {};
|
|
265
472
|
if (opts.redo) {
|
|
@@ -278,6 +485,12 @@ export function registerCreate(program) {
|
|
|
278
485
|
// CLI options layer
|
|
279
486
|
const fromOpts = optsToBody(opts);
|
|
280
487
|
body = { ...body, ...fromOpts };
|
|
488
|
+
// Capture the raw text input (with potential @-prefix) for filename derivation.
|
|
489
|
+
// After `resolveText` we lose the @path → file stem mapping.
|
|
490
|
+
const rawTextInput = topicArg ?? (typeof body.text === "string" ? body.text : undefined);
|
|
491
|
+
const fileStemFromAt = rawTextInput?.startsWith("@")
|
|
492
|
+
? path.parse(rawTextInput.slice(1)).name
|
|
493
|
+
: undefined;
|
|
281
494
|
// Positional topic wins for `text` (with @file support)
|
|
282
495
|
if (topicArg) {
|
|
283
496
|
body.text = await resolveText(topicArg);
|
|
@@ -324,9 +537,33 @@ export function registerCreate(program) {
|
|
|
324
537
|
throw new Error(t.error || `Task ended with status ${t.status}`);
|
|
325
538
|
}
|
|
326
539
|
const result = t.result;
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
540
|
+
// Decide where (or whether) to save locally.
|
|
541
|
+
// -o → that exact path
|
|
542
|
+
// --no-download → skip
|
|
543
|
+
// stdout piped → skip (clig.dev: don't dump binary-touching side effects into a script)
|
|
544
|
+
// otherwise → auto-named in cwd
|
|
545
|
+
if (result?.video_url) {
|
|
546
|
+
const stdoutIsPipe = !process.stdout.isTTY;
|
|
547
|
+
const skipDownload = !!opts.noDownload || (stdoutIsPipe && !opts.output);
|
|
548
|
+
let savedPath;
|
|
549
|
+
if (opts.output) {
|
|
550
|
+
savedPath = opts.output;
|
|
551
|
+
}
|
|
552
|
+
else if (!skipDownload) {
|
|
553
|
+
savedPath = computeDefaultFilename({
|
|
554
|
+
resultTitle: result.title,
|
|
555
|
+
bodyTitle: finalBody.title,
|
|
556
|
+
mode: finalBody.mode,
|
|
557
|
+
rawTextInput,
|
|
558
|
+
fileStemFromAt,
|
|
559
|
+
taskId: t.id,
|
|
560
|
+
ext: "mp4",
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
if (savedPath) {
|
|
564
|
+
await downloadTo(result.video_url, savedPath);
|
|
565
|
+
success(`Saved → ${savedPath}`);
|
|
566
|
+
}
|
|
330
567
|
}
|
|
331
568
|
print({ task_id: t.id, status: t.status, ...result });
|
|
332
569
|
});
|
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,52 +1,52 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "reelforge",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "CLI for ReelForge Studio — AI video engine. Every REST API exposed as a command, with --help on every level.",
|
|
5
|
-
"license": "Apache-2.0",
|
|
6
|
-
"type": "module",
|
|
7
|
-
"bin": {
|
|
8
|
-
"reelforge": "./bin/reelforge.js",
|
|
9
|
-
"rf": "./bin/reelforge.js"
|
|
10
|
-
},
|
|
11
|
-
"files": [
|
|
12
|
-
"bin",
|
|
13
|
-
"dist",
|
|
14
|
-
"README.md"
|
|
15
|
-
],
|
|
16
|
-
"engines": {
|
|
17
|
-
"node": ">=18.17"
|
|
18
|
-
},
|
|
19
|
-
"scripts": {
|
|
20
|
-
"build": "tsc -p tsconfig.json",
|
|
21
|
-
"dev": "tsc -p tsconfig.json --watch",
|
|
22
|
-
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
23
|
-
"clean": "rimraf dist",
|
|
24
|
-
"prepublishOnly": "npm run clean && npm run build"
|
|
25
|
-
},
|
|
26
|
-
"dependencies": {
|
|
27
|
-
"commander": "^12.1.0",
|
|
28
|
-
"kleur": "^4.1.5"
|
|
29
|
-
},
|
|
30
|
-
"devDependencies": {
|
|
31
|
-
"@types/node": "^20.14.0",
|
|
32
|
-
"rimraf": "^6.0.1",
|
|
33
|
-
"typescript": "^5.5.0"
|
|
34
|
-
},
|
|
35
|
-
"keywords": [
|
|
36
|
-
"reelforge",
|
|
37
|
-
"ai-video",
|
|
38
|
-
"comfyui",
|
|
39
|
-
"runninghub",
|
|
40
|
-
"tts",
|
|
41
|
-
"edge-tts",
|
|
42
|
-
"ffmpeg",
|
|
43
|
-
"playwright",
|
|
44
|
-
"cli"
|
|
45
|
-
],
|
|
46
|
-
"repository": {
|
|
47
|
-
"type": "git",
|
|
48
|
-
"url": "https://github.com/puke3615/ReelForge.git",
|
|
49
|
-
"directory": "cli"
|
|
50
|
-
},
|
|
51
|
-
"homepage": "https://github.com/puke3615/ReelForge"
|
|
52
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "reelforge",
|
|
3
|
+
"version": "0.4.0",
|
|
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
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"reelforge": "./bin/reelforge.js",
|
|
9
|
+
"rf": "./bin/reelforge.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"bin",
|
|
13
|
+
"dist",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18.17"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsc -p tsconfig.json",
|
|
21
|
+
"dev": "tsc -p tsconfig.json --watch",
|
|
22
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
23
|
+
"clean": "rimraf dist",
|
|
24
|
+
"prepublishOnly": "npm run clean && npm run build"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"commander": "^12.1.0",
|
|
28
|
+
"kleur": "^4.1.5"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/node": "^20.14.0",
|
|
32
|
+
"rimraf": "^6.0.1",
|
|
33
|
+
"typescript": "^5.5.0"
|
|
34
|
+
},
|
|
35
|
+
"keywords": [
|
|
36
|
+
"reelforge",
|
|
37
|
+
"ai-video",
|
|
38
|
+
"comfyui",
|
|
39
|
+
"runninghub",
|
|
40
|
+
"tts",
|
|
41
|
+
"edge-tts",
|
|
42
|
+
"ffmpeg",
|
|
43
|
+
"playwright",
|
|
44
|
+
"cli"
|
|
45
|
+
],
|
|
46
|
+
"repository": {
|
|
47
|
+
"type": "git",
|
|
48
|
+
"url": "https://github.com/puke3615/ReelForge.git",
|
|
49
|
+
"directory": "cli"
|
|
50
|
+
},
|
|
51
|
+
"homepage": "https://github.com/puke3615/ReelForge"
|
|
52
|
+
}
|