reelforge 1.24.3 → 1.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -291
- package/package.json +4 -41
- package/bin/reelforge.js +0 -8
- package/dist/client.js +0 -120
- package/dist/commands/assets-workflow-text.js +0 -22
- package/dist/commands/assets.js +0 -243
- package/dist/commands/audio.js +0 -91
- package/dist/commands/auth.js +0 -170
- package/dist/commands/bgm.js +0 -59
- package/dist/commands/clip.js +0 -139
- package/dist/commands/compose.js +0 -298
- package/dist/commands/compositions.js +0 -148
- package/dist/commands/config.js +0 -62
- package/dist/commands/content.js +0 -66
- package/dist/commands/cover.js +0 -421
- package/dist/commands/create.js +0 -638
- package/dist/commands/dub.js +0 -220
- package/dist/commands/extract.js +0 -115
- package/dist/commands/fetch.js +0 -129
- package/dist/commands/files.js +0 -70
- package/dist/commands/health.js +0 -12
- package/dist/commands/history.js +0 -44
- package/dist/commands/images.js +0 -107
- package/dist/commands/llm.js +0 -67
- package/dist/commands/media.js +0 -136
- package/dist/commands/models.js +0 -36
- package/dist/commands/pipelines.js +0 -142
- package/dist/commands/platform.js +0 -218
- package/dist/commands/regen.js +0 -134
- package/dist/commands/render.js +0 -82
- package/dist/commands/script.js +0 -128
- package/dist/commands/styles.js +0 -113
- package/dist/commands/subtitles.js +0 -246
- package/dist/commands/tasks.js +0 -59
- package/dist/commands/tts.js +0 -134
- package/dist/commands/vlog.js +0 -91
- package/dist/data/vlog/general.md +0 -129
- package/dist/data/vlog/pet.md +0 -160
- package/dist/index.js +0 -183
- package/dist/utils/config-file.js +0 -37
- package/dist/utils/download.js +0 -13
- package/dist/utils/file-upload.js +0 -102
- package/dist/utils/oss-upload.js +0 -44
- package/dist/utils/output.js +0 -91
- package/dist/utils/task-waiter.js +0 -40
package/dist/commands/clip.js
DELETED
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs/promises";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { post } from "../client.js";
|
|
4
|
-
import { waitForTask } from "../utils/task-waiter.js";
|
|
5
|
-
import { downloadTo } from "../utils/download.js";
|
|
6
|
-
import { formatUsd, humanDuration, info, isJson, print, success } from "../utils/output.js";
|
|
7
|
-
async function loadSpec(raw) {
|
|
8
|
-
let text = raw;
|
|
9
|
-
if (raw.startsWith("@")) {
|
|
10
|
-
text = await fs.readFile(path.resolve(raw.slice(1)), "utf8");
|
|
11
|
-
}
|
|
12
|
-
let parsed;
|
|
13
|
-
try {
|
|
14
|
-
parsed = JSON.parse(text);
|
|
15
|
-
}
|
|
16
|
-
catch (e) {
|
|
17
|
-
throw new Error(`--spec is not valid JSON: ${e instanceof Error ? e.message : e}`);
|
|
18
|
-
}
|
|
19
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
20
|
-
throw new Error("--spec must be a JSON object (the component's render spec)");
|
|
21
|
-
}
|
|
22
|
-
return parsed;
|
|
23
|
-
}
|
|
24
|
-
export function registerClip(program) {
|
|
25
|
-
program
|
|
26
|
-
.command("clip")
|
|
27
|
-
.description("一个 scene 组件 → 独立竖屏 MP4 (可选配音; 给 `rf dub --append` 拼片尾用)")
|
|
28
|
-
.helpOption("-h, --help", "show help")
|
|
29
|
-
.requiredOption("--component <id>", "组件 kind id (如 kinetic-text / quote-card / index-board)")
|
|
30
|
-
.requiredOption("--spec <json>", "组件 spec,内联 JSON 或 @文件路径")
|
|
31
|
-
.option("--theme <id>", "主题皮肤 id (缺省 = 组件所属 profile;general 组件用 general)")
|
|
32
|
-
.option("--narration <text>", "配音文案 (传了才有声音;不传 = 无声片段)")
|
|
33
|
-
.option("--voice <id>", "配音音色 (index-tts-2 音色名, 默认 熊小二)", "熊小二")
|
|
34
|
-
.option("--tts-model <id>", "TTS 模型 (默认 vox/index-tts-2)", "vox/index-tts-2")
|
|
35
|
-
.option("--duration <sec>", "片段时长 (秒);缺省 = 组件自然时长(无声)或配音时长(有声)", (v) => parseFloat(v))
|
|
36
|
-
.option("--subtitles", "烧录字幕 (默认不烧;组件通常自带文字)")
|
|
37
|
-
.option("--subtitle-style <style>", "字幕样式: plate | stroke | cinema (默认 plate)", "plate")
|
|
38
|
-
.option("-o, --output <file>", "输出 MP4 路径 (默认: clip-<组件>.mp4, 放在当前目录)")
|
|
39
|
-
.option("--preview-only", "只生成 preview, 不渲染最终 MP4")
|
|
40
|
-
.option("--poll-ms <ms>", "轮询间隔", (v) => parseInt(v, 10), 1500)
|
|
41
|
-
.option("--timeout-ms <ms>", "最长等待时间", (v) => parseInt(v, 10))
|
|
42
|
-
.addHelpText("after", [
|
|
43
|
-
"",
|
|
44
|
-
"它是什么:",
|
|
45
|
-
" 把单个 scene 组件渲染成一段独立的 1080×1920 竖屏 MP4。通用命令 —— 任何",
|
|
46
|
-
" 想单独跑的组件都走这里;最常见用途是给 `rf dub --append` 做固定片尾(关注卡 /",
|
|
47
|
-
" 赞助板 / 口播尾板),生成一次反复拼,省去每条视频重复 TTS。",
|
|
48
|
-
"",
|
|
49
|
-
"配音 (可选):",
|
|
50
|
-
" · 不传 --narration → 无声片段,时长 = 组件自然时长 (或 --duration)。",
|
|
51
|
-
" · 传 --narration → 熊小二配音 (可 --voice 换),时长 = max(配音, 组件自然时长),",
|
|
52
|
-
" 即配音短也不会截断动画;--subtitles 可另烧字幕。",
|
|
53
|
-
"",
|
|
54
|
-
"组件 (--component) + spec (--spec):",
|
|
55
|
-
" · --component 传组件的 kind id。常用通用组件: kinetic-text(一句大字) /",
|
|
56
|
-
" quote-card(金句) / comparison-vs(对比) / stat-counter(数字) /",
|
|
57
|
-
" bullet-list(要点) / timeline(时间线)。finance / mindset 垂直组件也可,",
|
|
58
|
-
" 会自动套对应主题皮肤 (也可 --theme 指定)。",
|
|
59
|
-
" · --spec 是该组件的渲染字段 (JSON 对象),内联或 @文件。字段随组件而定,",
|
|
60
|
-
" 例: kinetic-text = {text, emphasis};quote-card = {quote, author, context}。",
|
|
61
|
-
"",
|
|
62
|
-
"认证: 需要 API key — `rf login <key>` / $REELFORGE_API_KEY / --api-key。",
|
|
63
|
-
"可用音色: `rf tts voices`。",
|
|
64
|
-
"",
|
|
65
|
-
"示例:",
|
|
66
|
-
" # 无声金句卡 (用组件自然时长)",
|
|
67
|
-
' rf clip --component quote-card --spec \'{"quote":"慢即是快","author":"老张"}\'',
|
|
68
|
-
"",
|
|
69
|
-
" # 带配音的关注片尾,再拼到 dub 后面 (典型用法)",
|
|
70
|
-
' rf clip --component kinetic-text --spec \'{"text":"关注 机械制造师","emphasis":"机械制造师"}\' \\',
|
|
71
|
-
' --narration "关注机械制造师官方账号,每天一个离谱发明" -o outro.mp4',
|
|
72
|
-
" rf dub gameplay.mp4 --append outro.mp4",
|
|
73
|
-
"",
|
|
74
|
-
" # 指定时长 + 字幕",
|
|
75
|
-
' rf clip --component kinetic-text --spec \'{"text":"上车!"}\' --narration "都给我上车" --subtitles --duration 4',
|
|
76
|
-
"",
|
|
77
|
-
" # 财经组件 (spec 从文件读, 自动用 finance 皮肤)",
|
|
78
|
-
" rf clip --component index-board --spec @board.json",
|
|
79
|
-
].join("\n"))
|
|
80
|
-
.action(async (opts) => {
|
|
81
|
-
const spec = await loadSpec(opts.spec);
|
|
82
|
-
const subtitleStyle = (opts.subtitleStyle || "plate").toLowerCase();
|
|
83
|
-
if (!["plate", "stroke", "cinema"].includes(subtitleStyle)) {
|
|
84
|
-
throw new Error(`--subtitle-style must be plate | stroke | cinema (got ${opts.subtitleStyle})`);
|
|
85
|
-
}
|
|
86
|
-
if (opts.duration !== undefined && (!Number.isFinite(opts.duration) || opts.duration <= 0)) {
|
|
87
|
-
throw new Error(`--duration must be a positive number (got ${opts.duration})`);
|
|
88
|
-
}
|
|
89
|
-
const body = {
|
|
90
|
-
component: opts.component,
|
|
91
|
-
spec,
|
|
92
|
-
voice: opts.voice || "熊小二",
|
|
93
|
-
tts_model: opts.ttsModel || "vox/index-tts-2",
|
|
94
|
-
subtitles: opts.subtitles === true,
|
|
95
|
-
subtitle_style: subtitleStyle,
|
|
96
|
-
};
|
|
97
|
-
if (opts.theme)
|
|
98
|
-
body.theme = opts.theme;
|
|
99
|
-
if (opts.narration)
|
|
100
|
-
body.narration = opts.narration;
|
|
101
|
-
if (opts.duration !== undefined)
|
|
102
|
-
body.duration_sec = opts.duration;
|
|
103
|
-
if (opts.previewOnly)
|
|
104
|
-
body.preview_only = true;
|
|
105
|
-
info(`Rendering component "${opts.component}"${opts.narration ? " (voiced)" : " (silent)"}...`);
|
|
106
|
-
const submitted = await post("/api/v1/clip", body);
|
|
107
|
-
info(`Submitted task: ${submitted.task_id} — 渲染中, 请稍候...`);
|
|
108
|
-
const t = await waitForTask(submitted.task_id, { pollMs: opts.pollMs, timeoutMs: opts.timeoutMs });
|
|
109
|
-
if (t.status !== "completed") {
|
|
110
|
-
throw new Error(t.error || `Task ended with status ${t.status}`);
|
|
111
|
-
}
|
|
112
|
-
const result = t.result;
|
|
113
|
-
if (result?.render_pending) {
|
|
114
|
-
info(`MP4 render skipped (--preview-only). Run \`rf render ${t.id}\` to finalize.`);
|
|
115
|
-
}
|
|
116
|
-
else if (result?.video_url) {
|
|
117
|
-
const stdoutIsPipe = !process.stdout.isTTY;
|
|
118
|
-
const skipDownload = stdoutIsPipe && !opts.output;
|
|
119
|
-
let savedPath;
|
|
120
|
-
if (opts.output)
|
|
121
|
-
savedPath = path.resolve(opts.output);
|
|
122
|
-
else if (!skipDownload)
|
|
123
|
-
savedPath = path.resolve(`clip-${opts.component}.mp4`);
|
|
124
|
-
if (savedPath) {
|
|
125
|
-
await downloadTo(result.video_url, savedPath);
|
|
126
|
-
success(`Saved → ${savedPath}`);
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
if (result?.generation_ms != null) {
|
|
130
|
-
const playback = result.duration != null ? `, ${result.duration.toFixed(1)}s` : "";
|
|
131
|
-
const skin = result.theme ? `, theme ${result.theme}` : "";
|
|
132
|
-
info(`generated in ${humanDuration(result.generation_ms)}${playback}${skin}`);
|
|
133
|
-
}
|
|
134
|
-
if (result?.price_usd != null)
|
|
135
|
-
info(`charge: ${formatUsd(result.price_usd)}`);
|
|
136
|
-
if (isJson())
|
|
137
|
-
print({ task_id: t.id, status: t.status, ...result });
|
|
138
|
-
});
|
|
139
|
-
}
|
package/dist/commands/compose.js
DELETED
|
@@ -1,298 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs/promises";
|
|
2
|
-
import fsSync from "node:fs";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { post, getServer } from "../client.js";
|
|
5
|
-
import { uploadToOss } from "../utils/oss-upload.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 EXT_TO_MIME = {
|
|
10
|
-
".mp3": "audio/mpeg",
|
|
11
|
-
".wav": "audio/wav",
|
|
12
|
-
".m4a": "audio/mp4",
|
|
13
|
-
".ogg": "audio/ogg",
|
|
14
|
-
".png": "image/png",
|
|
15
|
-
".jpg": "image/jpeg",
|
|
16
|
-
".jpeg": "image/jpeg",
|
|
17
|
-
".webp": "image/webp",
|
|
18
|
-
".mp4": "video/mp4",
|
|
19
|
-
".mov": "video/quicktime",
|
|
20
|
-
".webm": "video/webm",
|
|
21
|
-
};
|
|
22
|
-
function listSpecPaths(spec) {
|
|
23
|
-
const paths = new Set();
|
|
24
|
-
if (spec.audio.narration)
|
|
25
|
-
paths.add(spec.audio.narration);
|
|
26
|
-
if (spec.audio.bgm)
|
|
27
|
-
paths.add(spec.audio.bgm);
|
|
28
|
-
for (const s of spec.scenes) {
|
|
29
|
-
if (s.image)
|
|
30
|
-
paths.add(s.image);
|
|
31
|
-
if (s.video)
|
|
32
|
-
paths.add(s.video);
|
|
33
|
-
}
|
|
34
|
-
if (spec.cover)
|
|
35
|
-
paths.add(spec.cover.image);
|
|
36
|
-
if (spec.brand?.logo)
|
|
37
|
-
paths.add(spec.brand.logo);
|
|
38
|
-
return [...paths];
|
|
39
|
-
}
|
|
40
|
-
async function readAsDataUri(specPath, baseDir) {
|
|
41
|
-
const abs = path.isAbsolute(specPath) ? specPath : path.resolve(baseDir, specPath);
|
|
42
|
-
if (!fsSync.existsSync(abs)) {
|
|
43
|
-
throw new Error(`compose: spec references "${specPath}" but file not found at ${abs}`);
|
|
44
|
-
}
|
|
45
|
-
const ext = path.extname(abs).toLowerCase();
|
|
46
|
-
const mime = EXT_TO_MIME[ext];
|
|
47
|
-
if (!mime) {
|
|
48
|
-
throw new Error(`compose: unsupported file extension "${ext}" (path: ${abs}). Supported: ` +
|
|
49
|
-
Object.keys(EXT_TO_MIME).join(" / "));
|
|
50
|
-
}
|
|
51
|
-
const buf = await fs.readFile(abs);
|
|
52
|
-
return { dataUri: `data:${mime};base64,${buf.toString("base64")}`, bytes: buf.byteLength };
|
|
53
|
-
}
|
|
54
|
-
const EXAMPLE_SPEC_HINT = `{
|
|
55
|
-
"experimental": "compose.v2",
|
|
56
|
-
"audio": { "narration": "./narration.mp3" },
|
|
57
|
-
"scenes": [
|
|
58
|
-
{ "start_sec": 0, "end_sec": 5, "image": "./s1.png", "motion": "zoom-in" },
|
|
59
|
-
{ "start_sec": 5, "end_sec": 11, "video": "./clip.mp4", "muted": true }
|
|
60
|
-
],
|
|
61
|
-
"subtitles": [
|
|
62
|
-
{ "start_sec": 0, "end_sec": 5, "text": "为什么咖啡因让人精神" },
|
|
63
|
-
{ "start_sec": 5, "end_sec": 11, "text": "原来它跟睡眠物质是同一把钥匙" }
|
|
64
|
-
],
|
|
65
|
-
"title": "咖啡因的真相"
|
|
66
|
-
}`;
|
|
67
|
-
const EXAMPLE_SPEC_V3_HINT = `{
|
|
68
|
-
"experimental": "compose.v3",
|
|
69
|
-
"audio": { "tts_voice": "熊小二" },
|
|
70
|
-
"scenes": [
|
|
71
|
-
{
|
|
72
|
-
"video": "./s1.mp4", "muted": true,
|
|
73
|
-
"narration": ["今天热得不行", "我跑厕所睡觉"],
|
|
74
|
-
"intro_title": "热到躲厕所"
|
|
75
|
-
},
|
|
76
|
-
{
|
|
77
|
-
"image": "./splash.jpg",
|
|
78
|
-
"narration": ["主人你找拖鞋去吧"]
|
|
79
|
-
}
|
|
80
|
-
],
|
|
81
|
-
"subtitle_style": "stroke",
|
|
82
|
-
"title": "贼喵被抓现行"
|
|
83
|
-
}`;
|
|
84
|
-
export function registerCompose(program) {
|
|
85
|
-
program
|
|
86
|
-
.command("compose <spec>")
|
|
87
|
-
.description("⚠️ EXPERIMENTAL · 从 JSON spec 渲染 MP4 — 调用方精确指定每个 scene 的图/视频 / 时间 / 字幕")
|
|
88
|
-
.helpOption("-h, --help", "show help")
|
|
89
|
-
.option("-o, --output <file>", "output MP4 path. Default: <spec-basename>.mp4 in cwd")
|
|
90
|
-
.option("--preview-only", "build the composition and return preview URLs; skip MP4 render (faster + cheaper iteration)")
|
|
91
|
-
.option("--poll-ms <ms>", "poll interval while waiting", (v) => parseInt(v, 10), 1500)
|
|
92
|
-
.option("--timeout-ms <ms>", "max wait time before aborting", (v) => parseInt(v, 10))
|
|
93
|
-
.option("--watch", "监听 spec.json,每次保存让 server 重建 HF preview(自动 --preview-only,不渲 MP4),打印一个 preview URL — 任何设备浏览器打开即可看效果。Ctrl-C 退出")
|
|
94
|
-
.option("--watch-debounce-ms <ms>", "watch 模式下文件变化的 debounce 时长", (v) => parseInt(v, 10), 400)
|
|
95
|
-
.addHelpText("after", [
|
|
96
|
-
"",
|
|
97
|
-
"⚠️ Schema versioning",
|
|
98
|
-
" Two schemas coexist; the server picks one via spec.experimental.",
|
|
99
|
-
" compose.v2 — you write every start_sec/end_sec yourself.",
|
|
100
|
-
" compose.v3 — audio-first: server runs TTS+ASR and derives times.",
|
|
101
|
-
" Both are EXPERIMENTAL; pin your CLI (e.g. reelforge@1.16.x) if you",
|
|
102
|
-
" build an agent against them.",
|
|
103
|
-
"",
|
|
104
|
-
"compose.v2 minimal example (manual timing):",
|
|
105
|
-
EXAMPLE_SPEC_HINT,
|
|
106
|
-
"",
|
|
107
|
-
"compose.v3 minimal example (audio-first, recommended for short videos):",
|
|
108
|
-
EXAMPLE_SPEC_V3_HINT,
|
|
109
|
-
"",
|
|
110
|
-
"Path resolution:",
|
|
111
|
-
" File paths in the spec are resolved RELATIVE to the spec.json's",
|
|
112
|
-
" directory. Absolute paths are also accepted. The CLI reads each",
|
|
113
|
-
" file and base64-encodes it into the request body — no separate",
|
|
114
|
-
" upload step needed.",
|
|
115
|
-
"",
|
|
116
|
-
"Supported file types:",
|
|
117
|
-
" audio: .mp3 .wav .m4a .ogg",
|
|
118
|
-
" image: .png .jpg .jpeg .webp",
|
|
119
|
-
" video: .mp4 .mov .webm",
|
|
120
|
-
"",
|
|
121
|
-
"Scene visuals:",
|
|
122
|
-
" Each scene has exactly one of `image` or `video`. Video scenes",
|
|
123
|
-
" play their source clip; if the clip is shorter than the scene",
|
|
124
|
-
" window, the last frame freezes (Ken-Burns continues, so the held",
|
|
125
|
-
" shot doesn't look frozen). Use `muted: true` to silence the clip's",
|
|
126
|
-
" own audio — default lets it mix with the narration.",
|
|
127
|
-
"",
|
|
128
|
-
"Size limits:",
|
|
129
|
-
" total decoded payload ≤ 55 MB (server cap). Stays in the safe-upload",
|
|
130
|
-
" envelope. Typical 30s spec (5 scenes + narration) is ~5-10 MB.",
|
|
131
|
-
"",
|
|
132
|
-
"Examples:",
|
|
133
|
-
" # Render directly to a file",
|
|
134
|
-
" rf compose ./my-video-spec.json -o ./output.mp4",
|
|
135
|
-
"",
|
|
136
|
-
" # Preview only — prints preview URL, no MP4 yet (~2 min, ~$0.05)",
|
|
137
|
-
" rf compose ./my-video-spec.json --preview-only",
|
|
138
|
-
"",
|
|
139
|
-
" # Watch mode — 编辑 spec → 保存 → server 重建 preview → CLI 打印 URL,手机/浏览器点开看效果",
|
|
140
|
-
" rf compose ./my-video-spec.json --watch",
|
|
141
|
-
"",
|
|
142
|
-
" # JSON output (parse from agent)",
|
|
143
|
-
" rf compose ./my-video-spec.json --json",
|
|
144
|
-
"",
|
|
145
|
-
"Full schema reference + worked examples (cognitive science / mixed",
|
|
146
|
-
"image+video / dual-language financial / minimal) live at:",
|
|
147
|
-
" docs/compose-spec.md in the ReelForge repo",
|
|
148
|
-
"",
|
|
149
|
-
"Use-case workflows (compose is the generic primitive; use-case SOPs",
|
|
150
|
-
"live under their own namespace):",
|
|
151
|
-
" rf assets workflow 用户素材 → 视频 工作流",
|
|
152
|
-
"",
|
|
153
|
-
"(`rf` works wherever you see `reelforge`)",
|
|
154
|
-
].join("\n"))
|
|
155
|
-
.action(async (specPathArg, opts) => {
|
|
156
|
-
const specPath = path.resolve(specPathArg);
|
|
157
|
-
if (!fsSync.existsSync(specPath)) {
|
|
158
|
-
throw new Error(`spec not found: ${specPath}`);
|
|
159
|
-
}
|
|
160
|
-
await renderComposeOnce(specPath, opts);
|
|
161
|
-
if (opts.watch) {
|
|
162
|
-
await runWatchLoop(specPath, opts);
|
|
163
|
-
}
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
-
async function renderComposeOnce(specPath, opts) {
|
|
167
|
-
let spec;
|
|
168
|
-
try {
|
|
169
|
-
const raw = await fs.readFile(specPath, "utf-8");
|
|
170
|
-
spec = JSON.parse(raw);
|
|
171
|
-
}
|
|
172
|
-
catch (e) {
|
|
173
|
-
throw new Error(`failed to read / parse spec ${specPath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
174
|
-
}
|
|
175
|
-
if (spec.experimental !== "compose.v2" && spec.experimental !== "compose.v3") {
|
|
176
|
-
throw new Error(`spec.experimental must be "compose.v2" or "compose.v3" (got: ${JSON.stringify(spec.experimental)}). ` +
|
|
177
|
-
`See docs/compose-spec.md or docs/assets-to-video-workflow.md.`);
|
|
178
|
-
}
|
|
179
|
-
const baseDir = path.dirname(specPath);
|
|
180
|
-
info(`Reading spec from ${specPath}...`);
|
|
181
|
-
const specPaths = listSpecPaths(spec);
|
|
182
|
-
const files = {};
|
|
183
|
-
const fileKeys = {};
|
|
184
|
-
for (const p of specPaths) {
|
|
185
|
-
const abs = path.isAbsolute(p) ? p : path.resolve(baseDir, p);
|
|
186
|
-
if (!fsSync.existsSync(abs)) {
|
|
187
|
-
throw new Error(`compose: spec references "${p}" but file not found at ${abs}`);
|
|
188
|
-
}
|
|
189
|
-
try {
|
|
190
|
-
fileKeys[p] = await uploadToOss(abs);
|
|
191
|
-
}
|
|
192
|
-
catch (e) {
|
|
193
|
-
warn(`${p}: OSS 直传不可用,回退内联上传 (${e instanceof Error ? e.message : e})`);
|
|
194
|
-
const r = await readAsDataUri(p, baseDir);
|
|
195
|
-
files[p] = r.dataUri;
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
info(`Bundling ${specPaths.length} files (${Object.keys(fileKeys).length} 直传 OSS, ${Object.keys(files).length} 内联)...`);
|
|
199
|
-
const body = { spec };
|
|
200
|
-
if (Object.keys(fileKeys).length)
|
|
201
|
-
body.file_keys = fileKeys;
|
|
202
|
-
if (Object.keys(files).length)
|
|
203
|
-
body.files = files;
|
|
204
|
-
if (opts.previewOnly)
|
|
205
|
-
body.preview_only = true;
|
|
206
|
-
const submitted = await post("/api/v1/compose", body);
|
|
207
|
-
info(`Submitted task: ${submitted.task_id}`);
|
|
208
|
-
const t = await waitForTask(submitted.task_id, {
|
|
209
|
-
pollMs: opts.pollMs,
|
|
210
|
-
timeoutMs: opts.timeoutMs,
|
|
211
|
-
});
|
|
212
|
-
if (t.status !== "completed") {
|
|
213
|
-
throw new Error(t.error || `Task ended with status ${t.status}`);
|
|
214
|
-
}
|
|
215
|
-
const result = t.result;
|
|
216
|
-
const serverBase = getServer().replace(/\/+$/, "");
|
|
217
|
-
const previewPageAbs = result?.preview_urls?.page ? serverBase + result.preview_urls.page : undefined;
|
|
218
|
-
const previewPlayerAbs = result?.preview_urls?.player ? serverBase + result.preview_urls.player : undefined;
|
|
219
|
-
if (previewPageAbs)
|
|
220
|
-
info(`Preview page: ${previewPageAbs}`);
|
|
221
|
-
if (previewPlayerAbs)
|
|
222
|
-
info(`Player only: ${previewPlayerAbs}`);
|
|
223
|
-
if (result?.render_pending) {
|
|
224
|
-
info(`MP4 render skipped (--preview-only). Run \`rf render ${t.id}\` to finalize.`);
|
|
225
|
-
}
|
|
226
|
-
else if (result?.video_url) {
|
|
227
|
-
const stdoutIsPipe = !process.stdout.isTTY;
|
|
228
|
-
const skipDownload = stdoutIsPipe && !opts.output;
|
|
229
|
-
let savedPath;
|
|
230
|
-
if (opts.output) {
|
|
231
|
-
savedPath = opts.output;
|
|
232
|
-
}
|
|
233
|
-
else if (!skipDownload) {
|
|
234
|
-
const stem = path.parse(specPath).name;
|
|
235
|
-
savedPath = path.resolve(`${stem}-${t.id.slice(0, 8)}.mp4`);
|
|
236
|
-
}
|
|
237
|
-
if (savedPath) {
|
|
238
|
-
await downloadTo(result.video_url, savedPath);
|
|
239
|
-
success(`Saved → ${savedPath}`);
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
if (result?.generation_ms != null) {
|
|
243
|
-
const gen = humanDuration(result.generation_ms);
|
|
244
|
-
const playback = result.duration != null ? `, video ${result.duration.toFixed(1)}s` : "";
|
|
245
|
-
info(`generated in ${gen}${playback}`);
|
|
246
|
-
}
|
|
247
|
-
if (result?.price_usd != null)
|
|
248
|
-
info(`charge: ${formatUsd(result.price_usd)}`);
|
|
249
|
-
print({ task_id: t.id, status: t.status, ...result });
|
|
250
|
-
}
|
|
251
|
-
async function runWatchLoop(specPath, opts) {
|
|
252
|
-
const debounceMs = opts.watchDebounceMs ?? 400;
|
|
253
|
-
const watchOpts = { ...opts, previewOnly: true };
|
|
254
|
-
if (opts.output) {
|
|
255
|
-
warn(` --output is ignored in --watch mode (preview-only — no MP4 is produced)`);
|
|
256
|
-
}
|
|
257
|
-
info(`👀 watching ${specPath} — preview iteration mode`);
|
|
258
|
-
info(` server: ${getServer()}`);
|
|
259
|
-
info(` each save → server rebuilds preview (NO MP4 render) → prints URL`);
|
|
260
|
-
info(` open the URL on any device (phone / laptop) to scrub HF player`);
|
|
261
|
-
info(` Ctrl-C to stop`);
|
|
262
|
-
let running = false;
|
|
263
|
-
let queued = false;
|
|
264
|
-
let debounceTimer = null;
|
|
265
|
-
const rerender = async () => {
|
|
266
|
-
if (running) {
|
|
267
|
-
queued = true;
|
|
268
|
-
return;
|
|
269
|
-
}
|
|
270
|
-
running = true;
|
|
271
|
-
try {
|
|
272
|
-
const t0 = Date.now();
|
|
273
|
-
info(`\n— rebuild —`);
|
|
274
|
-
await renderComposeOnce(specPath, watchOpts);
|
|
275
|
-
info(`✓ rebuilt in ${humanDuration(Date.now() - t0)}; refresh / open the URL above`);
|
|
276
|
-
}
|
|
277
|
-
catch (e) {
|
|
278
|
-
warn(`✗ rebuild failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
279
|
-
}
|
|
280
|
-
finally {
|
|
281
|
-
running = false;
|
|
282
|
-
if (queued) {
|
|
283
|
-
queued = false;
|
|
284
|
-
setTimeout(rerender, 50);
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
};
|
|
288
|
-
fsSync.watch(specPath, () => {
|
|
289
|
-
if (debounceTimer)
|
|
290
|
-
clearTimeout(debounceTimer);
|
|
291
|
-
debounceTimer = setTimeout(rerender, debounceMs);
|
|
292
|
-
});
|
|
293
|
-
process.on("SIGINT", () => {
|
|
294
|
-
info("\n👋 watch stopped");
|
|
295
|
-
process.exit(0);
|
|
296
|
-
});
|
|
297
|
-
await new Promise(() => { });
|
|
298
|
-
}
|
|
@@ -1,148 +0,0 @@
|
|
|
1
|
-
import { post } from "../client.js";
|
|
2
|
-
import { downloadTo } from "../utils/download.js";
|
|
3
|
-
import { print, success, info, warn } from "../utils/output.js";
|
|
4
|
-
import { fmtSize, resolveUploadInput, resolveUploadInputs } from "../utils/file-upload.js";
|
|
5
|
-
const onOssFallback = (flagName, reason) => warn(`${flagName}: OSS 直传不可用,回退内联上传 (${reason})`);
|
|
6
|
-
const WHEN_TO_USE = [
|
|
7
|
-
"",
|
|
8
|
-
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
|
|
9
|
-
"何时用 / 不要用 rf compositions (AGENT 必读)",
|
|
10
|
-
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
|
|
11
|
-
"✓ 用户已经有完整素材 (视频 / 图片 / 音频),仅需 拼接 / 加 BGM / 叠 logo",
|
|
12
|
-
"✓ 用户明确说『不要 AI 配文案』『只是简单合成』",
|
|
13
|
-
"",
|
|
14
|
-
"✗ 用户说『帮我做个视频』+ 给的是主题/想法 → 走 `rf create -t \"<topic>\"`",
|
|
15
|
-
"✗ 用户给了文案脚本 → 走 `rf create --script @file.txt`",
|
|
16
|
-
"✗ 用户想 AI 在自己的素材上加配音 + 字幕 → 当前需要分两步:",
|
|
17
|
-
" 先 `rf create --script ...` 出 AI 部分,再用 compositions 拼到自己素材里",
|
|
18
|
-
"✗ Agent 全控每帧 → 走 `rf compose <spec.json>` (EXPERIMENTAL)",
|
|
19
|
-
"",
|
|
20
|
-
"文件输入 (1.14.0+):",
|
|
21
|
-
" 全部命令的文件参数都支持三种形式,CLI 自动判定:",
|
|
22
|
-
" 本地路径 → CLI 读 + base64 上传 (最常用)",
|
|
23
|
-
" https:// → 透传给服务端,ffmpeg 直接拉",
|
|
24
|
-
" data: URI → 透传,服务端解码",
|
|
25
|
-
" 注: 老接口里 `rf files upload` 拿到的服务端路径 (data/uploads/<name>) ",
|
|
26
|
-
" 不再支持 — 这组命令现在自带上传,无需先 upload。",
|
|
27
|
-
].join("\n");
|
|
28
|
-
export function registerCompositions(program) {
|
|
29
|
-
const comp = program
|
|
30
|
-
.command("compositions")
|
|
31
|
-
.description("视频合成 (底层 ffmpeg): 拼接 / 加 BGM / 图+音 → 视频 / 透明叠加")
|
|
32
|
-
.helpOption("-h, --help", "show help")
|
|
33
|
-
.addHelpText("after", WHEN_TO_USE);
|
|
34
|
-
comp
|
|
35
|
-
.command("concat <videos...>")
|
|
36
|
-
.description("Concatenate multiple MP4 files into one (optionally mix a BGM track)")
|
|
37
|
-
.helpOption("-h, --help", "show help")
|
|
38
|
-
.option("-o, --output <file>", "output path (REQUIRED)")
|
|
39
|
-
.option("--method <method>", "demuxer | filter", "demuxer")
|
|
40
|
-
.option("--bgm <path>", "optional BGM file to mix in (local / URL / data: URI)")
|
|
41
|
-
.option("--bgm-volume <n>", "BGM volume (0..1)", parseFloat, 0.2)
|
|
42
|
-
.option("--bgm-mode <mode>", "loop | once", "loop")
|
|
43
|
-
.addHelpText("after", [
|
|
44
|
-
"",
|
|
45
|
-
"Example:",
|
|
46
|
-
" rf compositions concat clip1.mp4 clip2.mp4 clip3.mp4 -o final.mp4 \\",
|
|
47
|
-
" --bgm bgm/default.mp3 --bgm-volume 0.15",
|
|
48
|
-
"",
|
|
49
|
-
"Files are auto-uploaded — pass local paths directly. Don't pre-upload",
|
|
50
|
-
"via `rf files upload` and pass server-side paths; that's the old broken",
|
|
51
|
-
"pattern.",
|
|
52
|
-
].join("\n"))
|
|
53
|
-
.action(async (videos, opts) => {
|
|
54
|
-
if (!opts.output)
|
|
55
|
-
throw new Error("--output is required");
|
|
56
|
-
const { resolved, totalBytes } = await resolveUploadInputs(videos, {
|
|
57
|
-
flagName: "<videos...>",
|
|
58
|
-
onFallback: onOssFallback,
|
|
59
|
-
});
|
|
60
|
-
const bgm = opts.bgm
|
|
61
|
-
? await resolveUploadInput(opts.bgm, { flagName: "--bgm", onFallback: onOssFallback })
|
|
62
|
-
: null;
|
|
63
|
-
if (totalBytes)
|
|
64
|
-
info(`Uploading ${resolved.length} videos (${fmtSize(totalBytes + (bgm?.bytes ?? 0))})...`);
|
|
65
|
-
const videoKeys = resolved.filter((r) => r.key).map((r) => r.key);
|
|
66
|
-
const videoUris = resolved.filter((r) => r.url).map((r) => r.url);
|
|
67
|
-
const r = await post("/api/v1/compositions/concat", {
|
|
68
|
-
videos: videoUris,
|
|
69
|
-
video_keys: videoKeys,
|
|
70
|
-
method: opts.method,
|
|
71
|
-
...(bgm?.key ? { bgm_key: bgm.key } : bgm?.url ? { bgm_path: bgm.url } : {}),
|
|
72
|
-
bgm_volume: opts.bgmVolume,
|
|
73
|
-
bgm_mode: opts.bgmMode,
|
|
74
|
-
});
|
|
75
|
-
await downloadTo(r.url, opts.output);
|
|
76
|
-
success(`Saved → ${opts.output}`);
|
|
77
|
-
print(r);
|
|
78
|
-
});
|
|
79
|
-
comp
|
|
80
|
-
.command("bgm")
|
|
81
|
-
.description("Add background music to an existing video")
|
|
82
|
-
.helpOption("-h, --help", "show help")
|
|
83
|
-
.requiredOption("-i, --input <file>", "input video (local / URL / data: URI)")
|
|
84
|
-
.requiredOption("--bgm <file>", "BGM audio file (local / URL / data: URI)")
|
|
85
|
-
.option("-o, --output <file>", "output path (REQUIRED)")
|
|
86
|
-
.option("--volume <n>", "BGM volume (0..1)", parseFloat, 0.2)
|
|
87
|
-
.option("--mode <mode>", "loop | once", "loop")
|
|
88
|
-
.action(async (opts) => {
|
|
89
|
-
if (!opts.output)
|
|
90
|
-
throw new Error("--output is required");
|
|
91
|
-
const v = await resolveUploadInput(opts.input, { flagName: "-i / --input", onFallback: onOssFallback });
|
|
92
|
-
const b = await resolveUploadInput(opts.bgm, { flagName: "--bgm", onFallback: onOssFallback });
|
|
93
|
-
info(`Uploading video (${fmtSize(v.bytes ?? 0)}) + bgm (${fmtSize(b.bytes ?? 0)})...`);
|
|
94
|
-
const r = await post("/api/v1/compositions/bgm", {
|
|
95
|
-
...(v.key ? { video_key: v.key } : { video: v.url }),
|
|
96
|
-
...(b.key ? { bgm_key: b.key } : { bgm: b.url }),
|
|
97
|
-
volume: opts.volume,
|
|
98
|
-
mode: opts.mode,
|
|
99
|
-
});
|
|
100
|
-
await downloadTo(r.url, opts.output);
|
|
101
|
-
success(`Saved → ${opts.output}`);
|
|
102
|
-
print(r);
|
|
103
|
-
});
|
|
104
|
-
comp
|
|
105
|
-
.command("image-to-video")
|
|
106
|
-
.description("Build a video from a single image + an audio track")
|
|
107
|
-
.helpOption("-h, --help", "show help")
|
|
108
|
-
.requiredOption("-i, --image <file>", "input image (local / URL / data: URI)")
|
|
109
|
-
.requiredOption("-a, --audio <file>", "input audio (local / URL / data: URI)")
|
|
110
|
-
.option("-o, --output <file>", "output path (REQUIRED)")
|
|
111
|
-
.option("--fps <n>", "frames per second", parseInt, 30)
|
|
112
|
-
.action(async (opts) => {
|
|
113
|
-
if (!opts.output)
|
|
114
|
-
throw new Error("--output is required");
|
|
115
|
-
const img = await resolveUploadInput(opts.image, { flagName: "-i / --image", onFallback: onOssFallback });
|
|
116
|
-
const aud = await resolveUploadInput(opts.audio, { flagName: "-a / --audio", onFallback: onOssFallback });
|
|
117
|
-
info(`Uploading image (${fmtSize(img.bytes ?? 0)}) + audio (${fmtSize(aud.bytes ?? 0)})...`);
|
|
118
|
-
const r = await post("/api/v1/compositions/image-to-video", {
|
|
119
|
-
...(img.key ? { image_key: img.key } : { image: img.url }),
|
|
120
|
-
...(aud.key ? { audio_key: aud.key } : { audio: aud.url }),
|
|
121
|
-
fps: opts.fps,
|
|
122
|
-
});
|
|
123
|
-
await downloadTo(r.url, opts.output);
|
|
124
|
-
success(`Saved → ${opts.output}`);
|
|
125
|
-
print(r);
|
|
126
|
-
});
|
|
127
|
-
comp
|
|
128
|
-
.command("overlay")
|
|
129
|
-
.description("Overlay a transparent PNG on top of a video")
|
|
130
|
-
.helpOption("-h, --help", "show help")
|
|
131
|
-
.requiredOption("-v, --video <file>", "input video (local / URL / data: URI)")
|
|
132
|
-
.requiredOption("--overlay <file>", "overlay PNG, transparent (local / URL / data: URI)")
|
|
133
|
-
.option("-o, --output <file>", "output path (REQUIRED)")
|
|
134
|
-
.action(async (opts) => {
|
|
135
|
-
if (!opts.output)
|
|
136
|
-
throw new Error("--output is required");
|
|
137
|
-
const v = await resolveUploadInput(opts.video, { flagName: "-v / --video", onFallback: onOssFallback });
|
|
138
|
-
const o = await resolveUploadInput(opts.overlay, { flagName: "--overlay", onFallback: onOssFallback });
|
|
139
|
-
info(`Uploading video (${fmtSize(v.bytes ?? 0)}) + overlay (${fmtSize(o.bytes ?? 0)})...`);
|
|
140
|
-
const r = await post("/api/v1/compositions/overlay", {
|
|
141
|
-
...(v.key ? { video_key: v.key } : { video: v.url }),
|
|
142
|
-
...(o.key ? { overlay_key: o.key } : { overlay_image: o.url }),
|
|
143
|
-
});
|
|
144
|
-
await downloadTo(r.url, opts.output);
|
|
145
|
-
success(`Saved → ${opts.output}`);
|
|
146
|
-
print(r);
|
|
147
|
-
});
|
|
148
|
-
}
|
package/dist/commands/config.js
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs/promises";
|
|
2
|
-
import { get, patch } from "../client.js";
|
|
3
|
-
import { print } from "../utils/output.js";
|
|
4
|
-
export function registerConfig(program) {
|
|
5
|
-
const cfg = program
|
|
6
|
-
.command("config")
|
|
7
|
-
.description("读取或更新服务器配置 (LLM / 模型网关 keys)")
|
|
8
|
-
.helpOption("-h, --help", "show help");
|
|
9
|
-
cfg
|
|
10
|
-
.command("get")
|
|
11
|
-
.description("Print the current config (API keys masked)")
|
|
12
|
-
.helpOption("-h, --help", "show help")
|
|
13
|
-
.action(async () => {
|
|
14
|
-
const r = await get("/api/v1/config");
|
|
15
|
-
print(r);
|
|
16
|
-
});
|
|
17
|
-
cfg
|
|
18
|
-
.command("set <key> <value>")
|
|
19
|
-
.description("Update a single config value using dotted path, e.g. `llm.api_key sk-xxx`")
|
|
20
|
-
.helpOption("-h, --help", "show help")
|
|
21
|
-
.addHelpText("after", [
|
|
22
|
-
"",
|
|
23
|
-
"Examples:",
|
|
24
|
-
" reelforge config set llm.api_key sk-xxxxxx",
|
|
25
|
-
" reelforge config set llm.model <model-id>",
|
|
26
|
-
].join("\n"))
|
|
27
|
-
.action(async (key, value) => {
|
|
28
|
-
const parts = key.split(".");
|
|
29
|
-
const patchBody = {};
|
|
30
|
-
let cur = patchBody;
|
|
31
|
-
for (let i = 0; i < parts.length - 1; i++) {
|
|
32
|
-
const next = {};
|
|
33
|
-
cur[parts[i]] = next;
|
|
34
|
-
cur = next;
|
|
35
|
-
}
|
|
36
|
-
cur[parts[parts.length - 1]] = coerce(value);
|
|
37
|
-
const r = await patch("/api/v1/config", patchBody);
|
|
38
|
-
print(r);
|
|
39
|
-
});
|
|
40
|
-
cfg
|
|
41
|
-
.command("patch <file>")
|
|
42
|
-
.description("Apply a JSON-merge patch file to the config")
|
|
43
|
-
.helpOption("-h, --help", "show help")
|
|
44
|
-
.action(async (file) => {
|
|
45
|
-
const body = JSON.parse(await fs.readFile(file, "utf-8"));
|
|
46
|
-
const r = await patch("/api/v1/config", body);
|
|
47
|
-
print(r);
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
function coerce(s) {
|
|
51
|
-
if (s === "true")
|
|
52
|
-
return true;
|
|
53
|
-
if (s === "false")
|
|
54
|
-
return false;
|
|
55
|
-
if (s === "null")
|
|
56
|
-
return null;
|
|
57
|
-
if (/^-?\d+$/.test(s))
|
|
58
|
-
return parseInt(s, 10);
|
|
59
|
-
if (/^-?\d+\.\d+$/.test(s))
|
|
60
|
-
return parseFloat(s);
|
|
61
|
-
return s;
|
|
62
|
-
}
|