reelforge 1.24.2 → 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 +8 -295
- package/package.json +7 -44
- 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/assets.js
DELETED
|
@@ -1,243 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs/promises";
|
|
2
|
-
import fsSync from "node:fs";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { post } from "../client.js";
|
|
5
|
-
import { uploadToOss } from "../utils/oss-upload.js";
|
|
6
|
-
import { info, isJson, print, success, warn } from "../utils/output.js";
|
|
7
|
-
import { ASSETS_WORKFLOW_TEXT } from "./assets-workflow-text.js";
|
|
8
|
-
const IMAGE_EXT = new Set([".jpg", ".jpeg", ".png", ".webp"]);
|
|
9
|
-
const VIDEO_EXT = new Set([".mp4", ".mov", ".webm", ".mkv", ".m4v"]);
|
|
10
|
-
const SUPPORTED_EXT = new Set([...IMAGE_EXT, ...VIDEO_EXT]);
|
|
11
|
-
const BATCH_CONCURRENCY = 4;
|
|
12
|
-
function mimeForExt(ext) {
|
|
13
|
-
switch (ext) {
|
|
14
|
-
case ".png": return "image/png";
|
|
15
|
-
case ".jpg":
|
|
16
|
-
case ".jpeg": return "image/jpeg";
|
|
17
|
-
case ".webp": return "image/webp";
|
|
18
|
-
case ".mp4":
|
|
19
|
-
case ".m4v": return "video/mp4";
|
|
20
|
-
case ".mov": return "video/quicktime";
|
|
21
|
-
case ".webm": return "video/webm";
|
|
22
|
-
case ".mkv": return "video/x-matroska";
|
|
23
|
-
default: return null;
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
function typeForExt(ext) {
|
|
27
|
-
if (IMAGE_EXT.has(ext))
|
|
28
|
-
return "image";
|
|
29
|
-
if (VIDEO_EXT.has(ext))
|
|
30
|
-
return "video";
|
|
31
|
-
return null;
|
|
32
|
-
}
|
|
33
|
-
async function resolveAsset(input) {
|
|
34
|
-
const t = input.trim();
|
|
35
|
-
if (t.startsWith("data:image/"))
|
|
36
|
-
return { url: t, type: "image" };
|
|
37
|
-
if (t.startsWith("data:video/"))
|
|
38
|
-
return { url: t, type: "video" };
|
|
39
|
-
if (t.startsWith("data:")) {
|
|
40
|
-
throw new Error(`unsupported data: URI MIME (need data:image/... or data:video/...)`);
|
|
41
|
-
}
|
|
42
|
-
if (/^https?:\/\//i.test(t)) {
|
|
43
|
-
let ext = "";
|
|
44
|
-
try {
|
|
45
|
-
ext = path.extname(new URL(t).pathname).toLowerCase();
|
|
46
|
-
}
|
|
47
|
-
catch {
|
|
48
|
-
}
|
|
49
|
-
const guessed = typeForExt(ext);
|
|
50
|
-
if (!guessed) {
|
|
51
|
-
throw new Error(`cannot infer asset type from URL (extension "${ext || "<none>"}"). ` +
|
|
52
|
-
`Use a URL ending in a supported extension, or download the file and pass the local path.`);
|
|
53
|
-
}
|
|
54
|
-
return { url: t, type: guessed };
|
|
55
|
-
}
|
|
56
|
-
const abs = path.resolve(t);
|
|
57
|
-
if (!fsSync.existsSync(abs)) {
|
|
58
|
-
throw new Error(`asset not found: ${abs}`);
|
|
59
|
-
}
|
|
60
|
-
const ext = path.extname(abs).toLowerCase();
|
|
61
|
-
const type = typeForExt(ext);
|
|
62
|
-
const mime = mimeForExt(ext);
|
|
63
|
-
if (!type || !mime) {
|
|
64
|
-
const supported = [...IMAGE_EXT, ...VIDEO_EXT].join(" / ");
|
|
65
|
-
throw new Error(`unsupported extension ${ext} (supported: ${supported})`);
|
|
66
|
-
}
|
|
67
|
-
try {
|
|
68
|
-
const key = await uploadToOss(abs);
|
|
69
|
-
return { key, type };
|
|
70
|
-
}
|
|
71
|
-
catch (e) {
|
|
72
|
-
warn(`${path.basename(abs)}: OSS 直传不可用,回退内联上传 (${e instanceof Error ? e.message : e})`);
|
|
73
|
-
const buf = await fs.readFile(abs);
|
|
74
|
-
return { url: `data:${mime};base64,${buf.toString("base64")}`, type };
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
async function describeOne(input, opts) {
|
|
78
|
-
const { url, key, type } = await resolveAsset(input);
|
|
79
|
-
const body = {};
|
|
80
|
-
if (key)
|
|
81
|
-
body[`${type}_key`] = key;
|
|
82
|
-
else
|
|
83
|
-
body[`${type}_url`] = url;
|
|
84
|
-
if (opts.model)
|
|
85
|
-
body.model = opts.model;
|
|
86
|
-
if (opts.prompt)
|
|
87
|
-
body.prompt = opts.prompt;
|
|
88
|
-
return post("/api/v1/assets/describe", body);
|
|
89
|
-
}
|
|
90
|
-
function listAssetFiles(dir) {
|
|
91
|
-
return fsSync
|
|
92
|
-
.readdirSync(dir)
|
|
93
|
-
.filter((name) => SUPPORTED_EXT.has(path.extname(name).toLowerCase()))
|
|
94
|
-
.filter((name) => !name.startsWith("."))
|
|
95
|
-
.sort();
|
|
96
|
-
}
|
|
97
|
-
async function pMap(items, fn, limit) {
|
|
98
|
-
const out = new Array(items.length);
|
|
99
|
-
let idx = 0;
|
|
100
|
-
async function worker() {
|
|
101
|
-
while (true) {
|
|
102
|
-
const i = idx++;
|
|
103
|
-
if (i >= items.length)
|
|
104
|
-
return;
|
|
105
|
-
out[i] = await fn(items[i], i);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
109
|
-
return out;
|
|
110
|
-
}
|
|
111
|
-
export function registerAssets(program) {
|
|
112
|
-
const cmd = program
|
|
113
|
-
.command("assets")
|
|
114
|
-
.description("素材相关能力: describe(图片/视频 → 一句话描述) / workflow(agent 用户素材→视频 SOP)")
|
|
115
|
-
.helpOption("-h, --help", "show help");
|
|
116
|
-
cmd
|
|
117
|
-
.command("workflow")
|
|
118
|
-
.description("打印 agent 用户素材→视频 完整 SOP(给 Claude / Cursor 等 agent 看;底层使用 rf compose 渲染)")
|
|
119
|
-
.helpOption("-h, --help", "show help")
|
|
120
|
-
.addHelpText("after", [
|
|
121
|
-
"",
|
|
122
|
-
"打印素材→视频工作流指南到 stdout。",
|
|
123
|
-
"适合 agent 启动时一次性读入;普通用户也可以 `rf assets workflow | less` 浏览。",
|
|
124
|
-
"spec 的结构与示例见 `rf compose --help`。",
|
|
125
|
-
].join("\n"))
|
|
126
|
-
.action(() => {
|
|
127
|
-
warn("`rf assets workflow` 已更名 → 用 `rf vlog`(列赛道)/ `rf vlog pet`(宠物 SOP)。下方为旧版通用 SOP。");
|
|
128
|
-
process.stdout.write(ASSETS_WORKFLOW_TEXT);
|
|
129
|
-
if (!ASSETS_WORKFLOW_TEXT.endsWith("\n"))
|
|
130
|
-
process.stdout.write("\n");
|
|
131
|
-
});
|
|
132
|
-
cmd
|
|
133
|
-
.command("describe <pathOrDir>")
|
|
134
|
-
.description("用多模态 LLM 给图片或视频生成中文描述。" +
|
|
135
|
-
"单文件打印一句话;目录批量,扫描所有 jpg/png/mp4/mov 等并输出 recipe 风格 JSON。")
|
|
136
|
-
.helpOption("-h, --help", "show help")
|
|
137
|
-
.option("-o, --output <file>", "写到文件而不是 stdout(单文件写描述文本,目录写 JSON)")
|
|
138
|
-
.option("--model <id>", "指定 RelayX 上的 vision-capable 模型(默认 qwen/qwen3-vl-flash;其他可选: qwen/qwen3-vl-plus / openai/gpt-4.1-mini / google/gemini-3-flash / anthropic/claude-4-7-sonnet)")
|
|
139
|
-
.option("--prompt <text>", "覆盖默认提示词(图片默认 30 字简描,视频默认 80 字动作+氛围描述)")
|
|
140
|
-
.addHelpText("after", [
|
|
141
|
-
"",
|
|
142
|
-
"支持的扩展名:",
|
|
143
|
-
" 图片: .jpg .jpeg .png .webp",
|
|
144
|
-
" 视频: .mp4 .mov .webm .mkv .m4v",
|
|
145
|
-
"",
|
|
146
|
-
"Examples:",
|
|
147
|
-
" # 图片,直接打到终端",
|
|
148
|
-
" rf assets describe ./cat.jpg",
|
|
149
|
-
"",
|
|
150
|
-
" # 视频(自动检测扩展名),输出 ~80 字含动作和氛围的描述",
|
|
151
|
-
" rf assets describe ./clip.mp4",
|
|
152
|
-
"",
|
|
153
|
-
" # 整个相册目录批量(图片视频混着扫),输出 recipe JSON",
|
|
154
|
-
" rf assets describe ./trip/ -o trip.recipe.json",
|
|
155
|
-
"",
|
|
156
|
-
" # 自定义提示词",
|
|
157
|
-
" rf assets describe ./clip.mp4 --prompt 'List 3 key visual moments in this video, comma-separated.'",
|
|
158
|
-
"",
|
|
159
|
-
"Output:",
|
|
160
|
-
" 单文件: 直接打印 description 到 stdout(--json 时打印完整 JSON)",
|
|
161
|
-
" 目录: { assets: [{image|video, description}, ...] }",
|
|
162
|
-
"",
|
|
163
|
-
"成本参考(默认 qwen3-vl-flash):",
|
|
164
|
-
" 图片 ~$0.00007 (¥0.0005)",
|
|
165
|
-
" 视频 ~$0.00007/秒 (60s 视频 ¥0.03)",
|
|
166
|
-
].join("\n"))
|
|
167
|
-
.action(async (pathOrDir, opts) => {
|
|
168
|
-
const isLocal = !/^https?:\/\//i.test(pathOrDir) && !pathOrDir.startsWith("data:");
|
|
169
|
-
const isDir = isLocal && fsSync.existsSync(pathOrDir) && fsSync.statSync(pathOrDir).isDirectory();
|
|
170
|
-
if (isDir) {
|
|
171
|
-
const abs = path.resolve(pathOrDir);
|
|
172
|
-
const names = listAssetFiles(abs);
|
|
173
|
-
if (names.length === 0) {
|
|
174
|
-
throw new Error(`no supported asset files in ${abs} ` +
|
|
175
|
-
`(looked for ${[...SUPPORTED_EXT].join(" / ")})`);
|
|
176
|
-
}
|
|
177
|
-
info(`Captioning ${names.length} assets from ${abs} (concurrency=${BATCH_CONCURRENCY})...`);
|
|
178
|
-
const results = await pMap(names, async (name) => {
|
|
179
|
-
const filePath = path.join(abs, name);
|
|
180
|
-
const ext = path.extname(name).toLowerCase();
|
|
181
|
-
const type = typeForExt(ext);
|
|
182
|
-
try {
|
|
183
|
-
const r = await describeOne(filePath, opts);
|
|
184
|
-
return {
|
|
185
|
-
type,
|
|
186
|
-
path: filePath,
|
|
187
|
-
description: r.description,
|
|
188
|
-
cost_usd: r.cost_usd ?? 0,
|
|
189
|
-
intrinsic_duration_ms: r.intrinsic_duration_ms,
|
|
190
|
-
};
|
|
191
|
-
}
|
|
192
|
-
catch (err) {
|
|
193
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
194
|
-
warn(` ${name}: failed — ${msg}`);
|
|
195
|
-
return { type, path: filePath, description: "", error: msg, cost_usd: 0 };
|
|
196
|
-
}
|
|
197
|
-
}, BATCH_CONCURRENCY);
|
|
198
|
-
const totalCost = results.reduce((s, r) => s + (r.cost_usd ?? 0), 0);
|
|
199
|
-
const okCount = results.filter((r) => r.description).length;
|
|
200
|
-
const stub = {
|
|
201
|
-
assets: results.map((r) => {
|
|
202
|
-
if (r.type === "video") {
|
|
203
|
-
const entry = {
|
|
204
|
-
video: r.path,
|
|
205
|
-
description: r.description,
|
|
206
|
-
};
|
|
207
|
-
if (typeof r.intrinsic_duration_ms === "number" && r.intrinsic_duration_ms > 0) {
|
|
208
|
-
entry.intrinsic_duration_ms = r.intrinsic_duration_ms;
|
|
209
|
-
}
|
|
210
|
-
return entry;
|
|
211
|
-
}
|
|
212
|
-
return { image: r.path, description: r.description };
|
|
213
|
-
}),
|
|
214
|
-
};
|
|
215
|
-
const json = JSON.stringify(stub, null, 2);
|
|
216
|
-
if (opts.output) {
|
|
217
|
-
const outAbs = path.resolve(opts.output);
|
|
218
|
-
await fs.mkdir(path.dirname(outAbs), { recursive: true });
|
|
219
|
-
await fs.writeFile(outAbs, json);
|
|
220
|
-
success(`Wrote ${okCount}/${names.length} captions → ${outAbs} (total cost $${totalCost.toFixed(4)})`);
|
|
221
|
-
}
|
|
222
|
-
else {
|
|
223
|
-
print(stub);
|
|
224
|
-
info(`(${okCount}/${names.length} ok, total cost $${totalCost.toFixed(4)})`);
|
|
225
|
-
}
|
|
226
|
-
return;
|
|
227
|
-
}
|
|
228
|
-
const r = await describeOne(pathOrDir, opts);
|
|
229
|
-
if (opts.output) {
|
|
230
|
-
const outAbs = path.resolve(opts.output);
|
|
231
|
-
await fs.mkdir(path.dirname(outAbs), { recursive: true });
|
|
232
|
-
await fs.writeFile(outAbs, r.description);
|
|
233
|
-
success(`Wrote description (${r.type}) → ${outAbs} (${r.model} · ${(r.duration_ms / 1000).toFixed(2)}s · $${(r.cost_usd ?? 0).toFixed(6)})`);
|
|
234
|
-
}
|
|
235
|
-
else if (isJson()) {
|
|
236
|
-
print(r);
|
|
237
|
-
}
|
|
238
|
-
else {
|
|
239
|
-
print(r.description);
|
|
240
|
-
info(`${r.type} · ${r.model} · ${(r.duration_ms / 1000).toFixed(2)}s · cost $${(r.cost_usd ?? 0).toFixed(6)}`);
|
|
241
|
-
}
|
|
242
|
-
});
|
|
243
|
-
}
|
package/dist/commands/audio.js
DELETED
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs/promises";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { uploadMultipart, post } from "../client.js";
|
|
4
|
-
import { uploadToOss } from "../utils/oss-upload.js";
|
|
5
|
-
import { print, warn } from "../utils/output.js";
|
|
6
|
-
export function registerAudio(program) {
|
|
7
|
-
const audio = program
|
|
8
|
-
.command("audio")
|
|
9
|
-
.description("音频原子能力: 转写 (音频 → 文字 + 词级时间戳)")
|
|
10
|
-
.helpOption("-h, --help", "show help");
|
|
11
|
-
audio
|
|
12
|
-
.command("transcribe")
|
|
13
|
-
.description("Transcribe an audio file to text + word-level timestamps")
|
|
14
|
-
.helpOption("-h, --help", "show help")
|
|
15
|
-
.option("-f, --file <path>", "local audio file (mp3/wav/m4a). Use this OR --url.")
|
|
16
|
-
.option("-u, --url <url>", "remote audio URL — server downloads and transcribes.")
|
|
17
|
-
.option("-l, --language <code>", "language hint (e.g. zh, en). Optional — auto-detected.")
|
|
18
|
-
.option("-m, --model <id>", "override the ASR model id (defaults to the server's)")
|
|
19
|
-
.option("-o, --output <file>", "write the full JSON response to this file as well as stdout")
|
|
20
|
-
.addHelpText("after", [
|
|
21
|
-
"",
|
|
22
|
-
"Examples:",
|
|
23
|
-
" rf audio transcribe -f ./narration.mp3",
|
|
24
|
-
" rf audio transcribe --url https://example.com/clip.mp3 --language zh",
|
|
25
|
-
" rf audio transcribe -f ./voice.wav --json | jq '.words[:5]'",
|
|
26
|
-
].join("\n"))
|
|
27
|
-
.action(async (opts) => {
|
|
28
|
-
if (!opts.file && !opts.url) {
|
|
29
|
-
throw new Error("either --file or --url is required");
|
|
30
|
-
}
|
|
31
|
-
if (opts.file && opts.url) {
|
|
32
|
-
throw new Error("--file and --url are mutually exclusive");
|
|
33
|
-
}
|
|
34
|
-
let r;
|
|
35
|
-
if (opts.file) {
|
|
36
|
-
let key = null;
|
|
37
|
-
try {
|
|
38
|
-
key = await uploadToOss(opts.file);
|
|
39
|
-
}
|
|
40
|
-
catch (e) {
|
|
41
|
-
warn(`OSS 直传不可用,回退 multipart 上传 (${e instanceof Error ? e.message : e})`);
|
|
42
|
-
}
|
|
43
|
-
if (key) {
|
|
44
|
-
const body = { key };
|
|
45
|
-
if (opts.language)
|
|
46
|
-
body.language = opts.language;
|
|
47
|
-
if (opts.model)
|
|
48
|
-
body.model = opts.model;
|
|
49
|
-
r = await post("/api/v1/audio/transcribe", body);
|
|
50
|
-
}
|
|
51
|
-
else {
|
|
52
|
-
const buf = await fs.readFile(opts.file);
|
|
53
|
-
const filename = path.basename(opts.file);
|
|
54
|
-
const ext = path.extname(filename).toLowerCase();
|
|
55
|
-
const mime = ext === ".wav" ? "audio/wav" :
|
|
56
|
-
ext === ".m4a" ? "audio/mp4" :
|
|
57
|
-
ext === ".flac" ? "audio/flac" :
|
|
58
|
-
ext === ".ogg" ? "audio/ogg" :
|
|
59
|
-
"audio/mpeg";
|
|
60
|
-
const fileBlob = new File([new Uint8Array(buf)], filename, { type: mime });
|
|
61
|
-
const fields = { file: fileBlob };
|
|
62
|
-
if (opts.language)
|
|
63
|
-
fields.language = opts.language;
|
|
64
|
-
if (opts.model)
|
|
65
|
-
fields.model = opts.model;
|
|
66
|
-
r = await uploadMultipart("/api/v1/audio/transcribe", fields);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
else {
|
|
70
|
-
const body = { audio_url: opts.url };
|
|
71
|
-
if (opts.language)
|
|
72
|
-
body.language = opts.language;
|
|
73
|
-
if (opts.model)
|
|
74
|
-
body.model = opts.model;
|
|
75
|
-
r = await post("/api/v1/audio/transcribe", body);
|
|
76
|
-
}
|
|
77
|
-
if (opts.output) {
|
|
78
|
-
await fs.writeFile(opts.output, JSON.stringify(r, null, 2), "utf-8");
|
|
79
|
-
}
|
|
80
|
-
print({
|
|
81
|
-
model: r.model,
|
|
82
|
-
language: r.language,
|
|
83
|
-
duration: r.duration,
|
|
84
|
-
text: r.text,
|
|
85
|
-
n_segments: r.segments.length,
|
|
86
|
-
n_words: r.words.length,
|
|
87
|
-
segments: r.segments,
|
|
88
|
-
words: r.words,
|
|
89
|
-
});
|
|
90
|
-
});
|
|
91
|
-
}
|
package/dist/commands/auth.js
DELETED
|
@@ -1,170 +0,0 @@
|
|
|
1
|
-
import http from "node:http";
|
|
2
|
-
import crypto from "node:crypto";
|
|
3
|
-
import os from "node:os";
|
|
4
|
-
import { spawn } from "node:child_process";
|
|
5
|
-
import { get, setApiKey, setServer, getServer } from "../client.js";
|
|
6
|
-
import { loadConfig, saveConfig, deleteConfig, getConfigPath } from "../utils/config-file.js";
|
|
7
|
-
import { info, success, print, table, formatUsd } from "../utils/output.js";
|
|
8
|
-
function openBrowser(url) {
|
|
9
|
-
const cmd = process.platform === "darwin"
|
|
10
|
-
? "open"
|
|
11
|
-
: process.platform === "win32"
|
|
12
|
-
? "rundll32"
|
|
13
|
-
: "xdg-open";
|
|
14
|
-
const args = process.platform === "win32"
|
|
15
|
-
? ["url.dll,FileProtocolHandler", url]
|
|
16
|
-
: [url];
|
|
17
|
-
try {
|
|
18
|
-
spawn(cmd, args, { detached: true, stdio: "ignore" }).unref();
|
|
19
|
-
return true;
|
|
20
|
-
}
|
|
21
|
-
catch {
|
|
22
|
-
return false;
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
async function browserOAuthFlow(serverUrl) {
|
|
26
|
-
const state = crypto.randomBytes(16).toString("hex");
|
|
27
|
-
const hostname = (os.hostname() || "device").slice(0, 64);
|
|
28
|
-
return new Promise((resolve, reject) => {
|
|
29
|
-
let timer = null;
|
|
30
|
-
const server = http.createServer((req, res) => {
|
|
31
|
-
const url = new URL(req.url || "/", "http://127.0.0.1");
|
|
32
|
-
if (url.pathname !== "/cb") {
|
|
33
|
-
res.writeHead(404, { "Content-Type": "text/plain" }).end("Not found");
|
|
34
|
-
return;
|
|
35
|
-
}
|
|
36
|
-
const gotState = url.searchParams.get("state");
|
|
37
|
-
const token = url.searchParams.get("token");
|
|
38
|
-
const errCode = url.searchParams.get("error");
|
|
39
|
-
const finish = (statusCode, title, body, outcome) => {
|
|
40
|
-
res.writeHead(statusCode, {
|
|
41
|
-
"Content-Type": "text/html; charset=utf-8",
|
|
42
|
-
Connection: "close",
|
|
43
|
-
}).end(`<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8"><title>ReelForge CLI</title><style>body{font-family:-apple-system,system-ui,"Segoe UI",sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#fafaf7;color:#222}div{text-align:center;max-width:24rem;padding:1.5rem}h1{font-size:1.5rem;margin:0 0 .5rem;font-weight:600}p{color:#666;font-size:.875rem;line-height:1.5}</style></head><body><div><h1>${title}</h1><p>${body}</p></div><script>setTimeout(()=>window.close?.(),2000)</script></body></html>`);
|
|
44
|
-
if (timer)
|
|
45
|
-
clearTimeout(timer);
|
|
46
|
-
server.close();
|
|
47
|
-
server.closeAllConnections?.();
|
|
48
|
-
if (outcome.ok)
|
|
49
|
-
resolve(outcome.token);
|
|
50
|
-
else
|
|
51
|
-
reject(outcome.err);
|
|
52
|
-
};
|
|
53
|
-
if (gotState !== state) {
|
|
54
|
-
finish(400, "授权失败", "state 不匹配,可能是 CSRF。请重新跑 reelforge login。", {
|
|
55
|
-
ok: false,
|
|
56
|
-
err: new Error("state mismatch"),
|
|
57
|
-
});
|
|
58
|
-
return;
|
|
59
|
-
}
|
|
60
|
-
if (errCode || !token) {
|
|
61
|
-
finish(400, "授权被取消", "可以关闭此页面回到 CLI。", {
|
|
62
|
-
ok: false,
|
|
63
|
-
err: new Error(errCode || "no token returned"),
|
|
64
|
-
});
|
|
65
|
-
return;
|
|
66
|
-
}
|
|
67
|
-
finish(200, "✓ 授权成功", "可以关闭此页面,CLI 已自动接收 key。", {
|
|
68
|
-
ok: true,
|
|
69
|
-
token,
|
|
70
|
-
});
|
|
71
|
-
});
|
|
72
|
-
server.listen(0, "127.0.0.1", () => {
|
|
73
|
-
const addr = server.address();
|
|
74
|
-
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
75
|
-
const callback = `http://127.0.0.1:${port}/cb`;
|
|
76
|
-
const authUrl = new URL("/cli-auth", serverUrl);
|
|
77
|
-
authUrl.searchParams.set("callback", callback);
|
|
78
|
-
authUrl.searchParams.set("state", state);
|
|
79
|
-
authUrl.searchParams.set("hostname", hostname);
|
|
80
|
-
info(`Listening on ${callback}`);
|
|
81
|
-
info("正在打开浏览器完成授权...");
|
|
82
|
-
const opened = openBrowser(authUrl.toString());
|
|
83
|
-
if (!opened) {
|
|
84
|
-
info("(打开浏览器失败,请手动访问以下 URL):");
|
|
85
|
-
info(authUrl.toString());
|
|
86
|
-
}
|
|
87
|
-
else {
|
|
88
|
-
info(`(如果浏览器没自动弹出,请手动访问: ${authUrl.toString()})`);
|
|
89
|
-
}
|
|
90
|
-
});
|
|
91
|
-
timer = setTimeout(() => {
|
|
92
|
-
server.close();
|
|
93
|
-
reject(new Error("授权超时(5 分钟)。请重新跑 reelforge login"));
|
|
94
|
-
}, 5 * 60 * 1000);
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
export function registerAuth(program) {
|
|
98
|
-
program
|
|
99
|
-
.command("login [api_key]")
|
|
100
|
-
.description("登录: 不带参数自动开浏览器 OAuth; 带 api_key 直接保存")
|
|
101
|
-
.helpOption("-h, --help", "show help")
|
|
102
|
-
.option("--server <url>", "also persist a custom server URL")
|
|
103
|
-
.addHelpText("after", [
|
|
104
|
-
"",
|
|
105
|
-
"Examples:",
|
|
106
|
-
" reelforge login # browser OAuth (recommended)",
|
|
107
|
-
" reelforge login JK1234567890ABCDEF # manual paste (SSH / headless)",
|
|
108
|
-
" reelforge login --server http://nas:8501 # OAuth against a self-hosted server",
|
|
109
|
-
"",
|
|
110
|
-
"The token is verified against /api/v1/me before being saved.",
|
|
111
|
-
].join("\n"))
|
|
112
|
-
.action(async (apiKey, opts) => {
|
|
113
|
-
if (opts.server)
|
|
114
|
-
setServer(opts.server);
|
|
115
|
-
let token;
|
|
116
|
-
if (apiKey) {
|
|
117
|
-
token = apiKey;
|
|
118
|
-
}
|
|
119
|
-
else {
|
|
120
|
-
info(`Starting browser OAuth against ${getServer()}`);
|
|
121
|
-
token = await browserOAuthFlow(getServer());
|
|
122
|
-
}
|
|
123
|
-
setApiKey(token);
|
|
124
|
-
const me = await get("/api/v1/me");
|
|
125
|
-
const existing = await loadConfig();
|
|
126
|
-
const saved = await saveConfig({
|
|
127
|
-
...existing,
|
|
128
|
-
api_key: token,
|
|
129
|
-
...(opts.server ? { server: opts.server } : {}),
|
|
130
|
-
});
|
|
131
|
-
success(`Saved → ${saved}`);
|
|
132
|
-
info(`Account: ${me.account.account_number}${me.account.label ? ` · ${me.account.label}` : ""}`);
|
|
133
|
-
info(`Balance: ${formatUsd(me.account.balance / 1_000_000)}`);
|
|
134
|
-
info(`Server: ${getServer()}`);
|
|
135
|
-
});
|
|
136
|
-
program
|
|
137
|
-
.command("logout")
|
|
138
|
-
.description("登出: 删除本地保存的 API key 和 server")
|
|
139
|
-
.helpOption("-h, --help", "show help")
|
|
140
|
-
.action(async () => {
|
|
141
|
-
const path = getConfigPath();
|
|
142
|
-
const deleted = await deleteConfig();
|
|
143
|
-
if (deleted) {
|
|
144
|
-
success(`Deleted ${path}`);
|
|
145
|
-
}
|
|
146
|
-
else {
|
|
147
|
-
info(`No config to delete (${path} not found)`);
|
|
148
|
-
}
|
|
149
|
-
});
|
|
150
|
-
program
|
|
151
|
-
.command("whoami")
|
|
152
|
-
.description("查看当前账户: 余额 / API key 列表 / 调用统计")
|
|
153
|
-
.helpOption("-h, --help", "show help")
|
|
154
|
-
.action(async () => {
|
|
155
|
-
const me = await get("/api/v1/me");
|
|
156
|
-
print({
|
|
157
|
-
account: me.account,
|
|
158
|
-
});
|
|
159
|
-
if (me.api_keys.length) {
|
|
160
|
-
info(`API keys (${me.api_keys.length}):`);
|
|
161
|
-
table(me.api_keys.map((k) => ({
|
|
162
|
-
id: k.id,
|
|
163
|
-
api_key: k.api_key,
|
|
164
|
-
label: k.label ?? "",
|
|
165
|
-
status: k.status,
|
|
166
|
-
last_used_at: k.last_used_at ?? "—",
|
|
167
|
-
})));
|
|
168
|
-
}
|
|
169
|
-
});
|
|
170
|
-
}
|
package/dist/commands/bgm.js
DELETED
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { del, get, post, uploadMultipart } from "../client.js";
|
|
4
|
-
import { uploadToOss } from "../utils/oss-upload.js";
|
|
5
|
-
import { print, table, success, bytes, warn } from "../utils/output.js";
|
|
6
|
-
export function registerBgm(program) {
|
|
7
|
-
const bgm = program
|
|
8
|
-
.command("bgm")
|
|
9
|
-
.description("BGM 库管理 (内置 + 用户上传)")
|
|
10
|
-
.helpOption("-h, --help", "show help");
|
|
11
|
-
bgm
|
|
12
|
-
.command("list")
|
|
13
|
-
.description("List all BGM files (default + user-uploaded)")
|
|
14
|
-
.helpOption("-h, --help", "show help")
|
|
15
|
-
.action(async () => {
|
|
16
|
-
const r = await get("/api/v1/bgm");
|
|
17
|
-
table(r.bgm.map((b) => ({
|
|
18
|
-
name: b.name,
|
|
19
|
-
size: bytes(b.size_bytes),
|
|
20
|
-
source: b.source,
|
|
21
|
-
path: b.relative_path,
|
|
22
|
-
})));
|
|
23
|
-
});
|
|
24
|
-
bgm
|
|
25
|
-
.command("upload <file>")
|
|
26
|
-
.description("Upload a new BGM file (saved under data/bgm/)")
|
|
27
|
-
.helpOption("-h, --help", "show help")
|
|
28
|
-
.action(async (file) => {
|
|
29
|
-
const abs = path.resolve(file);
|
|
30
|
-
const name = path.basename(abs);
|
|
31
|
-
let r;
|
|
32
|
-
let key = null;
|
|
33
|
-
try {
|
|
34
|
-
key = await uploadToOss(abs);
|
|
35
|
-
}
|
|
36
|
-
catch (e) {
|
|
37
|
-
warn(`OSS 直传不可用,回退 multipart 上传 (${e instanceof Error ? e.message : e})`);
|
|
38
|
-
}
|
|
39
|
-
if (key) {
|
|
40
|
-
r = await post("/api/v1/bgm", { key, filename: name });
|
|
41
|
-
}
|
|
42
|
-
else {
|
|
43
|
-
const buf = fs.readFileSync(abs);
|
|
44
|
-
const fileObj = new File([new Uint8Array(buf)], name, { type: "audio/mpeg" });
|
|
45
|
-
r = await uploadMultipart("/api/v1/bgm", { file: fileObj });
|
|
46
|
-
}
|
|
47
|
-
success(`Uploaded ${name}`);
|
|
48
|
-
print(r);
|
|
49
|
-
});
|
|
50
|
-
bgm
|
|
51
|
-
.command("delete <name>")
|
|
52
|
-
.alias("rm")
|
|
53
|
-
.description("Delete a user-uploaded BGM (default repo BGM is protected)")
|
|
54
|
-
.helpOption("-h, --help", "show help")
|
|
55
|
-
.action(async (name) => {
|
|
56
|
-
const r = await del(`/api/v1/bgm/${encodeURIComponent(name)}`);
|
|
57
|
-
print(r);
|
|
58
|
-
});
|
|
59
|
-
}
|