reelforge 1.18.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +295 -0
- package/bin/reelforge.js +8 -0
- package/dist/client.js +120 -0
- package/dist/commands/assets-workflow-text.js +22 -0
- package/dist/commands/assets.js +231 -0
- package/dist/commands/audio.js +73 -0
- package/dist/commands/auth.js +170 -0
- package/dist/commands/bgm.js +45 -0
- package/dist/commands/compose.js +293 -0
- package/dist/commands/compositions.js +143 -0
- package/dist/commands/config.js +62 -0
- package/dist/commands/content.js +66 -0
- package/dist/commands/cover.js +397 -0
- package/dist/commands/create.js +629 -0
- package/dist/commands/extract.js +102 -0
- package/dist/commands/fetch.js +129 -0
- package/dist/commands/files.js +56 -0
- package/dist/commands/health.js +12 -0
- package/dist/commands/history.js +44 -0
- package/dist/commands/images.js +88 -0
- package/dist/commands/llm.js +67 -0
- package/dist/commands/media.js +128 -0
- package/dist/commands/models.js +36 -0
- package/dist/commands/pipelines.js +142 -0
- package/dist/commands/platform.js +218 -0
- package/dist/commands/regen.js +134 -0
- package/dist/commands/render.js +82 -0
- package/dist/commands/script.js +128 -0
- package/dist/commands/styles.js +113 -0
- package/dist/commands/subtitles.js +246 -0
- package/dist/commands/tasks.js +59 -0
- package/dist/commands/tts.js +134 -0
- package/dist/index.js +173 -0
- package/dist/utils/config-file.js +37 -0
- package/dist/utils/download.js +13 -0
- package/dist/utils/file-upload.js +59 -0
- package/dist/utils/output.js +91 -0
- package/dist/utils/task-waiter.js +40 -0
- package/package.json +44 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { get, post } from "../client.js";
|
|
2
|
+
import { downloadTo } from "../utils/download.js";
|
|
3
|
+
import { print, table, success } from "../utils/output.js";
|
|
4
|
+
const DEFAULT_RELAYX_TTS_MODEL = "vox/index-tts-2";
|
|
5
|
+
export function registerTts(program) {
|
|
6
|
+
const tts = program
|
|
7
|
+
.command("tts")
|
|
8
|
+
.description("语音合成 (TTS): 本地 (免费) / 云端")
|
|
9
|
+
.helpOption("-h, --help", "显示帮助");
|
|
10
|
+
tts
|
|
11
|
+
.command("edge")
|
|
12
|
+
.description("用本地引擎合成语音 (免费, 不联外网)")
|
|
13
|
+
.helpOption("-h, --help", "显示帮助")
|
|
14
|
+
.requiredOption("-t, --text <text>", "要合成的文本")
|
|
15
|
+
.option("--voice <id>", "音色 id (例: zh-CN-YunjianNeural / en-US-AriaNeural)", "zh-CN-YunjianNeural")
|
|
16
|
+
.option("--speed <n>", "语速倍率 (0.5..2)", parseFloat, 1.2)
|
|
17
|
+
.option("--rate <rate>", "原生 rate 字符串, 如 '+30%' (覆盖 --speed)")
|
|
18
|
+
.option("-o, --output <file>", "保存音频到指定路径")
|
|
19
|
+
.addHelpText("after", [
|
|
20
|
+
"",
|
|
21
|
+
"示例:",
|
|
22
|
+
" rf tts edge -t '你好' --voice zh-CN-XiaoxiaoNeural -o hello.mp3",
|
|
23
|
+
" rf tts edge -t 'hello' --voice en-US-AriaNeural --speed 1.4 -o out.mp3",
|
|
24
|
+
"",
|
|
25
|
+
"查看可用 voices: `rf tts voices` (按 locale 过滤: `--locale zh`)",
|
|
26
|
+
].join("\n"))
|
|
27
|
+
.action(async (opts) => {
|
|
28
|
+
const body = { text: opts.text };
|
|
29
|
+
if (opts.voice)
|
|
30
|
+
body.voice = opts.voice;
|
|
31
|
+
if (opts.speed !== undefined)
|
|
32
|
+
body.speed = opts.speed;
|
|
33
|
+
if (opts.rate)
|
|
34
|
+
body.rate = opts.rate;
|
|
35
|
+
const r = await post("/api/v1/tts/edge", body);
|
|
36
|
+
if (opts.output) {
|
|
37
|
+
await downloadTo(r.url, opts.output);
|
|
38
|
+
success(`Saved → ${opts.output} (${r.size_bytes} bytes, voice=${r.voice}, rate=${r.rate})`);
|
|
39
|
+
}
|
|
40
|
+
print({ ok: r.ok, voice: r.voice, rate: r.rate, size_bytes: r.size_bytes, file_path: r.file_path, url: r.url, downloaded_to: opts.output || null });
|
|
41
|
+
});
|
|
42
|
+
tts
|
|
43
|
+
.command("relayx")
|
|
44
|
+
.description("用云端 TTS 合成语音 (149 个内置音色)")
|
|
45
|
+
.helpOption("-h, --help", "显示帮助")
|
|
46
|
+
.requiredOption("-t, --text <text>", "要合成的文本")
|
|
47
|
+
.option("-m, --model <id>", "TTS 模型 id (默认云端模型)")
|
|
48
|
+
.option("--voice <id>", "音色 id (默认: 专业解说)")
|
|
49
|
+
.option("--speed <n>", "语速倍率 (0.5..2)", parseFloat)
|
|
50
|
+
.option("-o, --output <file>", "保存音频到指定路径")
|
|
51
|
+
.addHelpText("after", [
|
|
52
|
+
"",
|
|
53
|
+
"示例:",
|
|
54
|
+
" rf tts relayx -t '你好世界' --voice '专业解说' -o hello.mp3",
|
|
55
|
+
" rf tts relayx -t '...' --voice '熊小二' --speed 1.1 -o out.mp3",
|
|
56
|
+
"",
|
|
57
|
+
"查看可用 voices: `rf tts voices` (默认这 149 个) /",
|
|
58
|
+
" `rf tts voices --model <其他 SKU>`",
|
|
59
|
+
].join("\n"))
|
|
60
|
+
.action(async (opts) => {
|
|
61
|
+
const body = { text: opts.text };
|
|
62
|
+
if (opts.model)
|
|
63
|
+
body.model = opts.model;
|
|
64
|
+
if (opts.voice)
|
|
65
|
+
body.voice = opts.voice;
|
|
66
|
+
if (opts.speed !== undefined)
|
|
67
|
+
body.speed = opts.speed;
|
|
68
|
+
const r = await post("/api/v1/tts/relayx", body);
|
|
69
|
+
if (opts.output) {
|
|
70
|
+
await downloadTo(r.url, opts.output);
|
|
71
|
+
success(`Saved → ${opts.output} (${r.size_bytes} bytes, model=${r.model}, voice=${r.voice})`);
|
|
72
|
+
}
|
|
73
|
+
print(r);
|
|
74
|
+
});
|
|
75
|
+
tts
|
|
76
|
+
.command("voices")
|
|
77
|
+
.description("列出可用音色 (本地 + 云端, 共 ~170+)")
|
|
78
|
+
.helpOption("-h, --help", "显示帮助")
|
|
79
|
+
.option("--provider <p>", "edge | relayx | all (默认: all,即两者都列)", "all")
|
|
80
|
+
.option("--model <id>", "云端模型 id (默认云端模型; 例: <SKU>)")
|
|
81
|
+
.option("--locale <prefix>", "本地引擎才有: 按 locale 前缀过滤, 如 zh / en-US")
|
|
82
|
+
.option("--refresh", "云端才有: 跳过 5 分钟服务端缓存")
|
|
83
|
+
.addHelpText("after", [
|
|
84
|
+
"",
|
|
85
|
+
"示例:",
|
|
86
|
+
" rf tts voices # 本地 + 云端,合计 ~174",
|
|
87
|
+
" rf tts voices --provider edge # 只看本地",
|
|
88
|
+
" rf tts voices --provider edge --locale zh # 本地中文音色",
|
|
89
|
+
" rf tts voices --provider relayx # 只看云端 (149)",
|
|
90
|
+
" rf tts voices --model <SKU> # 换个云端 SKU",
|
|
91
|
+
"",
|
|
92
|
+
"默认 --provider=all 同时列两边,本地用 locale 区分语种,云端用 voice id 选择。",
|
|
93
|
+
].join("\n"))
|
|
94
|
+
.action(async (opts) => {
|
|
95
|
+
const provider = (opts.provider || "all").toLowerCase();
|
|
96
|
+
const model = opts.model || DEFAULT_RELAYX_TTS_MODEL;
|
|
97
|
+
async function fetchEdge() {
|
|
98
|
+
const r = await get("/api/v1/tts/voices?provider=edge");
|
|
99
|
+
let voices = r.voices;
|
|
100
|
+
if (opts.locale)
|
|
101
|
+
voices = voices.filter((v) => v.locale.startsWith(opts.locale));
|
|
102
|
+
return voices.map((v) => ({ provider: "edge", model: "edge", id: v.id, label: v.label, locale: v.locale, gender: v.gender, featured: "" }));
|
|
103
|
+
}
|
|
104
|
+
async function fetchRelayx() {
|
|
105
|
+
const qs = new URLSearchParams({ provider: "relayx", model });
|
|
106
|
+
if (opts.refresh)
|
|
107
|
+
qs.set("refresh", "1");
|
|
108
|
+
const r = await get(`/api/v1/tts/voices?${qs.toString()}`);
|
|
109
|
+
return r.voices.map((v) => ({
|
|
110
|
+
provider: "relayx",
|
|
111
|
+
model,
|
|
112
|
+
id: v.id,
|
|
113
|
+
label: v.label,
|
|
114
|
+
locale: "",
|
|
115
|
+
gender: "",
|
|
116
|
+
featured: v.featured ? "★" : "",
|
|
117
|
+
}));
|
|
118
|
+
}
|
|
119
|
+
if (provider === "edge") {
|
|
120
|
+
table(await fetchEdge());
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (provider === "relayx") {
|
|
124
|
+
table(await fetchRelayx());
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (provider === "all") {
|
|
128
|
+
const [edgeVoices, relayxVoices] = await Promise.all([fetchEdge(), fetchRelayx()]);
|
|
129
|
+
table([...edgeVoices, ...relayxVoices]);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
throw new Error(`未知的 --provider: ${provider} (应为 edge / relayx / all)`);
|
|
133
|
+
});
|
|
134
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import { setServer, setApiKey } from "./client.js";
|
|
6
|
+
import { setOutputOptions } from "./utils/output.js";
|
|
7
|
+
const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json");
|
|
8
|
+
const pkgVersion = JSON.parse(readFileSync(pkgPath, "utf-8")).version;
|
|
9
|
+
import { registerAuth } from "./commands/auth.js";
|
|
10
|
+
import { registerCreate } from "./commands/create.js";
|
|
11
|
+
import { registerRender } from "./commands/render.js";
|
|
12
|
+
import { registerRegen } from "./commands/regen.js";
|
|
13
|
+
import { registerLlm } from "./commands/llm.js";
|
|
14
|
+
import { registerModels } from "./commands/models.js";
|
|
15
|
+
import { registerTts } from "./commands/tts.js";
|
|
16
|
+
import { registerImages } from "./commands/images.js";
|
|
17
|
+
import { registerContent } from "./commands/content.js";
|
|
18
|
+
import { registerAudio } from "./commands/audio.js";
|
|
19
|
+
import { registerSubtitles } from "./commands/subtitles.js";
|
|
20
|
+
import { registerCompositions } from "./commands/compositions.js";
|
|
21
|
+
import { registerPipelines } from "./commands/pipelines.js";
|
|
22
|
+
import { registerBgm } from "./commands/bgm.js";
|
|
23
|
+
import { registerFiles } from "./commands/files.js";
|
|
24
|
+
import { registerTasks } from "./commands/tasks.js";
|
|
25
|
+
import { registerHistory } from "./commands/history.js";
|
|
26
|
+
import { registerConfig } from "./commands/config.js";
|
|
27
|
+
import { registerHealth } from "./commands/health.js";
|
|
28
|
+
import { registerPlatform } from "./commands/platform.js";
|
|
29
|
+
import { registerFetch } from "./commands/fetch.js";
|
|
30
|
+
import { registerCover } from "./commands/cover.js";
|
|
31
|
+
import { registerStyles } from "./commands/styles.js";
|
|
32
|
+
import { registerAssets } from "./commands/assets.js";
|
|
33
|
+
import { registerExtract } from "./commands/extract.js";
|
|
34
|
+
import { registerMedia } from "./commands/media.js";
|
|
35
|
+
import { registerScript } from "./commands/script.js";
|
|
36
|
+
import { registerCompose } from "./commands/compose.js";
|
|
37
|
+
import { error as logError } from "./utils/output.js";
|
|
38
|
+
import { ApiCallError } from "./client.js";
|
|
39
|
+
const program = new Command();
|
|
40
|
+
program
|
|
41
|
+
.name("reelforge")
|
|
42
|
+
.description([
|
|
43
|
+
"ReelForge — AI 视频生成与媒体处理 CLI。一句话主题或自己的脚本 → 抖音/TikTok/视频号竖屏 MP4。",
|
|
44
|
+
"",
|
|
45
|
+
"本 CLI 是工具型原子能力集合 — 解析 / 文字 / 语音 / 视觉 / 媒体处理 / 渲染六层全覆盖,",
|
|
46
|
+
"做的是确定性原子操作。脚本写作、多轮创作迭代等创意性工作请在上层(自己脑子或 Claude Code)做完再来调命令。",
|
|
47
|
+
"",
|
|
48
|
+
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
|
|
49
|
+
"做视频前的决策指南",
|
|
50
|
+
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
|
|
51
|
+
"你手头有什么 用哪个命令",
|
|
52
|
+
" 一句话主题 / 想法 `rf create -t \"<topic>\"`",
|
|
53
|
+
" (AI 全权: 写文案 + 配音 + 出视觉)",
|
|
54
|
+
" 自己写好的文案脚本 `rf create --script @file.txt`",
|
|
55
|
+
" (AI 配视觉 / 配音 / 字幕)",
|
|
56
|
+
" 现成视频/图片素材 + 仅要拼接 + BGM `rf compositions concat ...`",
|
|
57
|
+
" (底层 ffmpeg, 不做 AI)",
|
|
58
|
+
" 现成素材 + 想 AI 加文案配音 分两步: 先 `rf create --script` 出 AI",
|
|
59
|
+
" 那段,再 `rf compositions concat`",
|
|
60
|
+
" 把 AI 段跟现成素材拼起来",
|
|
61
|
+
" 别人的视频/链接做参考 先 `rf extract <url>` 拿文案 →",
|
|
62
|
+
" 整理脚本 → `rf create --script ...`",
|
|
63
|
+
" 完全自定义每帧 `rf compose <spec.json>` (EXPERIMENTAL)",
|
|
64
|
+
" 想看『你们有哪些风格 / 封面?』 `rf styles list --json` /",
|
|
65
|
+
" `rf cover templates --json`",
|
|
66
|
+
" (输出含 preview URL,可用 markdown ",
|
|
67
|
+
" `` 在聊天界面渲染成缩略图)",
|
|
68
|
+
"",
|
|
69
|
+
"→ 如果不确定哪种方式适合,可以从下面 4 个常见场景里选一个:",
|
|
70
|
+
" (A) AI 写全文案+场景, 我只挑风格 → rf create -t",
|
|
71
|
+
" (B) 我有文案, AI 配视觉/配音 → rf create --script",
|
|
72
|
+
" (C) 我有素材, 只要拼接+BGM → rf compositions concat",
|
|
73
|
+
" (D) 我有素材+脚本, 想 AI 配视觉到我的素材上 → 暂不支持,分 (B)+(C) 两段",
|
|
74
|
+
"",
|
|
75
|
+
" 默认服务器: 托管服务(无需配置)",
|
|
76
|
+
" 自定义服务器: --server <url> 或 REELFORGE_SERVER=<url>",
|
|
77
|
+
" 短别名: `rf` 等价于 `reelforge`(如 `rf create ...`)",
|
|
78
|
+
"",
|
|
79
|
+
"任意子命令加 --help 看用法。例: `rf cover prepend --help` / `rf media probe --help`。",
|
|
80
|
+
].join("\n"))
|
|
81
|
+
.version(pkgVersion, "-v, --version", "显示 CLI 版本")
|
|
82
|
+
.helpOption("-h, --help", "显示帮助")
|
|
83
|
+
.addHelpCommand("help [command]", "查看某个子命令的帮助")
|
|
84
|
+
.showHelpAfterError("(用 `reelforge <command> --help` 查看用法)")
|
|
85
|
+
.showSuggestionAfterError(true)
|
|
86
|
+
.option("-s, --server <url>", "服务器地址 (默认托管服务, 或环境变量 $REELFORGE_SERVER, 或 login 保存的)")
|
|
87
|
+
.option("-k, --api-key <key>", "API key (覆盖 $REELFORGE_API_KEY 和 login 保存的 key)")
|
|
88
|
+
.option("--json", "输出原始 JSON 而不是格式化文本")
|
|
89
|
+
.option("--quiet", "静默模式 - 不打 stderr 的提示信息")
|
|
90
|
+
.hook("preAction", (thisCommand) => {
|
|
91
|
+
const opts = thisCommand.optsWithGlobals();
|
|
92
|
+
if (opts.server)
|
|
93
|
+
setServer(opts.server);
|
|
94
|
+
if (opts.apiKey)
|
|
95
|
+
setApiKey(opts.apiKey);
|
|
96
|
+
setOutputOptions({ json: !!opts.json, quiet: !!opts.quiet });
|
|
97
|
+
});
|
|
98
|
+
program.addHelpText("after", [
|
|
99
|
+
"",
|
|
100
|
+
"示例:",
|
|
101
|
+
" rf create '为什么我们还没有找到外星文明?' 自动保存到 ./<title>-<id>.mp4",
|
|
102
|
+
" rf create '...' -o ./videos/space.mp4 指定输出路径",
|
|
103
|
+
" rf create --script @story.txt 用自己的脚本生成",
|
|
104
|
+
" rf llm chat -p '帮我解释一下反脆弱' 直通 LLM 单轮对话",
|
|
105
|
+
" rf tts relayx --text 'hello' -o out.mp3 生成语音",
|
|
106
|
+
" rf images generate -p '一只橘猫' -o cat.png 生成图片",
|
|
107
|
+
" rf extract <PDF|抖音|URL|github> 解析素材为文本",
|
|
108
|
+
" rf fetch '<抖音分享链接>' -t 下载抖音 MP4 + ASR 转写",
|
|
109
|
+
" rf cover -i scene.png -t '标题' -o cover.png 单图渲染封面",
|
|
110
|
+
" rf assets describe ./photo.jpg 图片 / 视频 → 一句话描述",
|
|
111
|
+
" rf history list 看之前生成的视频",
|
|
112
|
+
" rf tasks list --status running 看进行中的任务",
|
|
113
|
+
"",
|
|
114
|
+
"Agent 工作流(use-case SOP,挂在用例命名空间下)",
|
|
115
|
+
" rf assets workflow 用户素材→视频 SOP(给 agent 看的)",
|
|
116
|
+
].join("\n"));
|
|
117
|
+
registerAuth(program);
|
|
118
|
+
registerCreate(program);
|
|
119
|
+
registerRender(program);
|
|
120
|
+
registerRegen(program);
|
|
121
|
+
registerLlm(program);
|
|
122
|
+
registerModels(program);
|
|
123
|
+
registerTts(program);
|
|
124
|
+
registerImages(program);
|
|
125
|
+
registerContent(program);
|
|
126
|
+
registerAudio(program);
|
|
127
|
+
registerSubtitles(program);
|
|
128
|
+
registerCompositions(program);
|
|
129
|
+
registerPipelines(program);
|
|
130
|
+
registerBgm(program);
|
|
131
|
+
registerFiles(program);
|
|
132
|
+
registerTasks(program);
|
|
133
|
+
registerHistory(program);
|
|
134
|
+
registerConfig(program);
|
|
135
|
+
registerHealth(program);
|
|
136
|
+
registerPlatform(program);
|
|
137
|
+
registerFetch(program);
|
|
138
|
+
registerCover(program);
|
|
139
|
+
registerStyles(program);
|
|
140
|
+
registerAssets(program);
|
|
141
|
+
registerExtract(program);
|
|
142
|
+
registerMedia(program);
|
|
143
|
+
registerScript(program);
|
|
144
|
+
registerCompose(program);
|
|
145
|
+
async function main() {
|
|
146
|
+
if (process.argv.length <= 2) {
|
|
147
|
+
program.outputHelp();
|
|
148
|
+
process.exit(0);
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
await program.parseAsync(process.argv);
|
|
152
|
+
process.exit(0);
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
if (err instanceof ApiCallError) {
|
|
156
|
+
const hint = err.status === 401
|
|
157
|
+
? " → Run `reelforge login <api_key>` first, or pass --api-key <key>."
|
|
158
|
+
: err.status === 402
|
|
159
|
+
? " → Insufficient balance. Top up via admin."
|
|
160
|
+
: err.status === 403
|
|
161
|
+
? " → Key revoked or account disabled. Contact admin."
|
|
162
|
+
: err.status === 429
|
|
163
|
+
? " → Rate-limited by jarvis-auth. Try again shortly."
|
|
164
|
+
: "";
|
|
165
|
+
logError(`${err.status ? `[${err.status}] ` : ""}${err.message}${hint ? `\n${hint}` : ""}`);
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
logError(err instanceof Error ? err.message : String(err));
|
|
169
|
+
}
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
main();
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
function configDir() {
|
|
5
|
+
return path.join(os.homedir(), ".reelforge");
|
|
6
|
+
}
|
|
7
|
+
function configPath() {
|
|
8
|
+
return path.join(configDir(), "config.json");
|
|
9
|
+
}
|
|
10
|
+
export async function loadConfig() {
|
|
11
|
+
try {
|
|
12
|
+
const raw = await fs.readFile(configPath(), "utf-8");
|
|
13
|
+
const parsed = JSON.parse(raw);
|
|
14
|
+
return typeof parsed === "object" && parsed ? parsed : {};
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return {};
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export async function saveConfig(c) {
|
|
21
|
+
await fs.mkdir(configDir(), { recursive: true });
|
|
22
|
+
const p = configPath();
|
|
23
|
+
await fs.writeFile(p, JSON.stringify(c, null, 2) + "\n", { mode: 0o600 });
|
|
24
|
+
return p;
|
|
25
|
+
}
|
|
26
|
+
export async function deleteConfig() {
|
|
27
|
+
try {
|
|
28
|
+
await fs.unlink(configPath());
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function getConfigPath() {
|
|
36
|
+
return configPath();
|
|
37
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { getServer } from "../client.js";
|
|
4
|
+
export async function downloadTo(url, dest) {
|
|
5
|
+
await fs.mkdir(path.dirname(path.resolve(dest)), { recursive: true });
|
|
6
|
+
const full = /^https?:\/\//i.test(url) ? url : `${getServer()}${url.startsWith("/") ? url : `/${url}`}`;
|
|
7
|
+
const r = await fetch(full);
|
|
8
|
+
if (!r.ok)
|
|
9
|
+
throw new Error(`Download failed (${r.status}) for ${full}`);
|
|
10
|
+
const buf = Buffer.from(await r.arrayBuffer());
|
|
11
|
+
await fs.writeFile(dest, buf);
|
|
12
|
+
return dest;
|
|
13
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import fsSync from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
export const EXT_TO_MIME = {
|
|
5
|
+
".png": "image/png",
|
|
6
|
+
".jpg": "image/jpeg",
|
|
7
|
+
".jpeg": "image/jpeg",
|
|
8
|
+
".webp": "image/webp",
|
|
9
|
+
".mp3": "audio/mpeg",
|
|
10
|
+
".wav": "audio/wav",
|
|
11
|
+
".m4a": "audio/mp4",
|
|
12
|
+
".aac": "audio/aac",
|
|
13
|
+
".ogg": "audio/ogg",
|
|
14
|
+
".flac": "audio/flac",
|
|
15
|
+
".mp4": "video/mp4",
|
|
16
|
+
".mov": "video/quicktime",
|
|
17
|
+
".webm": "video/webm",
|
|
18
|
+
".mkv": "video/x-matroska",
|
|
19
|
+
};
|
|
20
|
+
export async function resolveFileInput(input, opts) {
|
|
21
|
+
const t = input.trim();
|
|
22
|
+
if (!t)
|
|
23
|
+
throw new Error(`${opts.flagName}: empty value`);
|
|
24
|
+
if (/^https?:\/\//i.test(t))
|
|
25
|
+
return { url: t, kind: "http-url" };
|
|
26
|
+
if (t.startsWith("data:"))
|
|
27
|
+
return { url: t, kind: "data-uri" };
|
|
28
|
+
const abs = path.resolve(t);
|
|
29
|
+
if (!fsSync.existsSync(abs)) {
|
|
30
|
+
throw new Error(`${opts.flagName}: local file not found: ${abs}\n` +
|
|
31
|
+
` (Acceptable forms: local path / https:// URL / data: URI. ` +
|
|
32
|
+
`Server-side paths like 'data/uploads/...' do NOT work — CLI commands ` +
|
|
33
|
+
`auto-upload local files, so just pass the local path directly.)`);
|
|
34
|
+
}
|
|
35
|
+
const ext = path.extname(abs).toLowerCase();
|
|
36
|
+
const map = opts.extToMime ?? EXT_TO_MIME;
|
|
37
|
+
const mime = map[ext];
|
|
38
|
+
if (!mime) {
|
|
39
|
+
throw new Error(`${opts.flagName}: unsupported file extension "${ext}" (path: ${abs}). ` +
|
|
40
|
+
`Supported: ${Object.keys(map).join(" / ")}`);
|
|
41
|
+
}
|
|
42
|
+
const buf = await fs.readFile(abs);
|
|
43
|
+
const url = `data:${mime};base64,${buf.toString("base64")}`;
|
|
44
|
+
return { url, bytes: buf.byteLength, kind: "local-file" };
|
|
45
|
+
}
|
|
46
|
+
export async function resolveFileInputs(inputs, opts) {
|
|
47
|
+
const resolved = await Promise.all(inputs.map((input) => resolveFileInput(input, opts)));
|
|
48
|
+
const totalBytes = resolved.reduce((n, r) => n + (r.bytes ?? 0), 0);
|
|
49
|
+
return { resolved, totalBytes };
|
|
50
|
+
}
|
|
51
|
+
export function fmtSize(bytes) {
|
|
52
|
+
if (!bytes)
|
|
53
|
+
return "0";
|
|
54
|
+
if (bytes < 1024)
|
|
55
|
+
return `${bytes} B`;
|
|
56
|
+
if (bytes < 1024 * 1024)
|
|
57
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
58
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
59
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import kleur from "kleur";
|
|
2
|
+
let opts = {};
|
|
3
|
+
export function setOutputOptions(o) {
|
|
4
|
+
opts = { ...opts, ...o };
|
|
5
|
+
}
|
|
6
|
+
export function isJson() {
|
|
7
|
+
return !!opts.json;
|
|
8
|
+
}
|
|
9
|
+
export function print(data) {
|
|
10
|
+
if (opts.json) {
|
|
11
|
+
process.stdout.write(JSON.stringify(data, null, 2) + "\n");
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
if (data === null || data === undefined)
|
|
15
|
+
return;
|
|
16
|
+
if (typeof data === "string") {
|
|
17
|
+
process.stdout.write(data + "\n");
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
process.stdout.write(JSON.stringify(data, null, 2) + "\n");
|
|
21
|
+
}
|
|
22
|
+
export function info(msg) {
|
|
23
|
+
if (opts.quiet || opts.json)
|
|
24
|
+
return;
|
|
25
|
+
process.stderr.write(kleur.cyan("›") + " " + msg + "\n");
|
|
26
|
+
}
|
|
27
|
+
export function success(msg) {
|
|
28
|
+
if (opts.quiet || opts.json)
|
|
29
|
+
return;
|
|
30
|
+
process.stderr.write(kleur.green("✔") + " " + msg + "\n");
|
|
31
|
+
}
|
|
32
|
+
export function warn(msg) {
|
|
33
|
+
if (opts.quiet)
|
|
34
|
+
return;
|
|
35
|
+
process.stderr.write(kleur.yellow("!") + " " + msg + "\n");
|
|
36
|
+
}
|
|
37
|
+
export function error(msg) {
|
|
38
|
+
process.stderr.write(kleur.red("✗") + " " + msg + "\n");
|
|
39
|
+
}
|
|
40
|
+
export function table(rows, columns) {
|
|
41
|
+
if (opts.json) {
|
|
42
|
+
print(rows);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (!rows.length) {
|
|
46
|
+
info("(empty)");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const cols = columns || Object.keys(rows[0]);
|
|
50
|
+
const widths = cols.map((c) => Math.max(c.length, ...rows.map((r) => String(r[c] ?? "").length)));
|
|
51
|
+
const fmt = (cells) => cells.map((cell, i) => cell.padEnd(widths[i])).join(" ");
|
|
52
|
+
process.stdout.write(kleur.bold(fmt(cols)) + "\n");
|
|
53
|
+
process.stdout.write(cols.map((_, i) => "-".repeat(widths[i])).join(" ") + "\n");
|
|
54
|
+
for (const r of rows) {
|
|
55
|
+
process.stdout.write(fmt(cols.map((c) => String(r[c] ?? ""))) + "\n");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export function formatUsd(usd) {
|
|
59
|
+
if (usd == null)
|
|
60
|
+
return "-";
|
|
61
|
+
const abs = Math.abs(usd);
|
|
62
|
+
const decimals = abs > 0 && abs < 0.01 ? 6 : 4;
|
|
63
|
+
return `$${usd.toFixed(decimals)}`;
|
|
64
|
+
}
|
|
65
|
+
export function bytes(n) {
|
|
66
|
+
if (n == null)
|
|
67
|
+
return "-";
|
|
68
|
+
if (n < 1024)
|
|
69
|
+
return `${n} B`;
|
|
70
|
+
if (n < 1024 * 1024)
|
|
71
|
+
return `${(n / 1024).toFixed(1)} KB`;
|
|
72
|
+
if (n < 1024 * 1024 * 1024)
|
|
73
|
+
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
74
|
+
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
|
75
|
+
}
|
|
76
|
+
export function humanDuration(ms) {
|
|
77
|
+
if (ms == null)
|
|
78
|
+
return "-";
|
|
79
|
+
const totalSec = Math.round(ms / 1000);
|
|
80
|
+
const h = Math.floor(totalSec / 3600);
|
|
81
|
+
const m = Math.floor((totalSec % 3600) / 60);
|
|
82
|
+
const s = totalSec % 60;
|
|
83
|
+
const parts = [];
|
|
84
|
+
if (h > 0)
|
|
85
|
+
parts.push(`${h}h`);
|
|
86
|
+
if (m > 0)
|
|
87
|
+
parts.push(`${m}m`);
|
|
88
|
+
if (s > 0 || parts.length === 0)
|
|
89
|
+
parts.push(`${s}s`);
|
|
90
|
+
return parts.join("");
|
|
91
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { get } from "../client.js";
|
|
2
|
+
import kleur from "kleur";
|
|
3
|
+
import { isJson } from "./output.js";
|
|
4
|
+
export async function waitForTask(taskId, opts = {}) {
|
|
5
|
+
const pollMs = opts.pollMs ?? 1500;
|
|
6
|
+
const deadline = opts.timeoutMs ? Date.now() + opts.timeoutMs : Number.POSITIVE_INFINITY;
|
|
7
|
+
const show = opts.showProgress !== false && !isJson();
|
|
8
|
+
let lastLine = "";
|
|
9
|
+
while (true) {
|
|
10
|
+
const t = await get(`/api/v1/tasks/${taskId}`);
|
|
11
|
+
if (show) {
|
|
12
|
+
const last = t.events[t.events.length - 1];
|
|
13
|
+
const evt = last?.eventType || last?.event_type || "";
|
|
14
|
+
const action = last?.action ? ` · ${last.action}` : "";
|
|
15
|
+
const frame = last && (last.frameCurrent || last.frame_current)
|
|
16
|
+
? ` · frame ${last.frameCurrent ?? last.frame_current}/${last.frameTotal ?? last.frame_total}`
|
|
17
|
+
: "";
|
|
18
|
+
const line = `${(t.progress * 100).toFixed(0).padStart(3)}% ${t.status} ${evt}${action}${frame}`;
|
|
19
|
+
if (line !== lastLine) {
|
|
20
|
+
process.stderr.write("\r" + " ".repeat(Math.max(lastLine.length, line.length)) + "\r");
|
|
21
|
+
process.stderr.write(kleur.cyan("⟳ ") + line);
|
|
22
|
+
lastLine = line;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (t.status === "completed" || t.status === "failed" || t.status === "cancelled") {
|
|
26
|
+
if (show)
|
|
27
|
+
process.stderr.write("\n");
|
|
28
|
+
return t;
|
|
29
|
+
}
|
|
30
|
+
if (Date.now() > deadline) {
|
|
31
|
+
if (show)
|
|
32
|
+
process.stderr.write("\n");
|
|
33
|
+
throw new Error(`Timed out after ${opts.timeoutMs} ms waiting for task ${taskId}`);
|
|
34
|
+
}
|
|
35
|
+
await sleep(pollMs);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function sleep(ms) {
|
|
39
|
+
return new Promise((res) => setTimeout(res, ms));
|
|
40
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "reelforge",
|
|
3
|
+
"version": "1.18.2",
|
|
4
|
+
"description": "AI 视频生成 CLI。一句话主题或自己的脚本 → 自动出抖音/TikTok/视频号竖屏 MP4。安装即用:`reelforge` 或短别名 `rf`。",
|
|
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": ">=22"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsc -p tsconfig.json && node scripts/copy-docs.mjs",
|
|
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
|
+
"douyin",
|
|
39
|
+
"tiktok",
|
|
40
|
+
"shorts",
|
|
41
|
+
"vertical-video",
|
|
42
|
+
"cli"
|
|
43
|
+
]
|
|
44
|
+
}
|