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,102 @@
|
|
|
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 { info, isJson, print, success } from "../utils/output.js";
|
|
6
|
+
const FILE_EXT_TO_MIME = {
|
|
7
|
+
".pdf": "application/pdf",
|
|
8
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
9
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
10
|
+
};
|
|
11
|
+
async function resolveSource(input) {
|
|
12
|
+
const t = input.trim();
|
|
13
|
+
if (!t)
|
|
14
|
+
throw new Error("--reference / source is empty");
|
|
15
|
+
if (t.startsWith("data:"))
|
|
16
|
+
return t;
|
|
17
|
+
if (/^https?:\/\//i.test(t))
|
|
18
|
+
return t;
|
|
19
|
+
if (/v\.douyin\.com\//i.test(t) || /复制此链接/.test(t) || /打开抖音/.test(t))
|
|
20
|
+
return t;
|
|
21
|
+
if (/^[a-z0-9][a-z0-9-._]*\/[a-z0-9][a-z0-9-._]*$/i.test(t) && !t.split("/")[0].includes(".")) {
|
|
22
|
+
return t;
|
|
23
|
+
}
|
|
24
|
+
const abs = path.resolve(t);
|
|
25
|
+
if (!fsSync.existsSync(abs)) {
|
|
26
|
+
throw new Error(`source not found: ${abs}\n` +
|
|
27
|
+
`For documents pass a PDF/DOCX/XLSX path; for videos / articles / repos pass a URL or owner/repo.`);
|
|
28
|
+
}
|
|
29
|
+
const ext = path.extname(abs).toLowerCase();
|
|
30
|
+
const mime = FILE_EXT_TO_MIME[ext];
|
|
31
|
+
if (!mime) {
|
|
32
|
+
throw new Error(`unsupported file extension "${ext}". ` +
|
|
33
|
+
`Supported: ${Object.keys(FILE_EXT_TO_MIME).join(" / ")}, or pass a URL / github owner/repo / 抖音 link.`);
|
|
34
|
+
}
|
|
35
|
+
const buf = await fs.readFile(abs);
|
|
36
|
+
return `data:${mime};base64,${buf.toString("base64")}`;
|
|
37
|
+
}
|
|
38
|
+
export function registerExtract(program) {
|
|
39
|
+
program
|
|
40
|
+
.command("extract <source>")
|
|
41
|
+
.description("素材解析: 抖音 / 网页 / GitHub repo / PDF / DOCX / XLSX → 纯文本")
|
|
42
|
+
.helpOption("-h, --help", "show help")
|
|
43
|
+
.option("-o, --output <file>", "write extracted text to file instead of stdout")
|
|
44
|
+
.addHelpText("after", [
|
|
45
|
+
"",
|
|
46
|
+
"Supported sources (auto-detected by the server):",
|
|
47
|
+
" 抖音 link v.douyin.com/... or full 复制口令 paste → ASR transcript",
|
|
48
|
+
" web URL https://... article / 知乎 / 公众号 → article body",
|
|
49
|
+
" github owner/repo or https://github.com/owner/repo → README + metadata",
|
|
50
|
+
" PDF / DOCX / local file path; auto base64-uploaded → extracted text",
|
|
51
|
+
" XLSX ",
|
|
52
|
+
"",
|
|
53
|
+
"Examples (tip: `rf` works wherever you see `reelforge`):",
|
|
54
|
+
" # 抖音视频 → ASR 文字",
|
|
55
|
+
" rf extract 'https://v.douyin.com/abc/'",
|
|
56
|
+
" rf extract '<整段复制此链接 paste>'",
|
|
57
|
+
"",
|
|
58
|
+
" # 网页文章",
|
|
59
|
+
" rf extract 'https://zhuanlan.zhihu.com/p/123456'",
|
|
60
|
+
"",
|
|
61
|
+
" # GitHub 项目",
|
|
62
|
+
" rf extract 'anthropics/claude-code'",
|
|
63
|
+
" rf extract 'https://github.com/openai/openai-cookbook'",
|
|
64
|
+
"",
|
|
65
|
+
" # 本地文档",
|
|
66
|
+
" rf extract ./paper.pdf -o /tmp/ref.txt",
|
|
67
|
+
" rf extract ./report.docx",
|
|
68
|
+
" rf extract ./sales.xlsx",
|
|
69
|
+
"",
|
|
70
|
+
" # JSON 模式 (拿到 kind + meta + text 完整 payload)",
|
|
71
|
+
" rf extract ./paper.pdf --json | jq",
|
|
72
|
+
"",
|
|
73
|
+
"Common flow (agent-driven):",
|
|
74
|
+
" rf extract <source> -o /tmp/ref.txt # 1) parse",
|
|
75
|
+
" # → (agent / 你的 LLM 用 /tmp/ref.txt 作素材, 写出脚本到 /tmp/final.txt)",
|
|
76
|
+
" rf create --script @/tmp/final.txt -d 60 # 2) render",
|
|
77
|
+
"",
|
|
78
|
+
"Cost reference:",
|
|
79
|
+
" douyin ~$0.005 (ASR cost; depends on video duration)",
|
|
80
|
+
" web/github/pdf/docx/xlsx $0 (pure parsing, no LLM/ASR)",
|
|
81
|
+
].join("\n"))
|
|
82
|
+
.action(async (source, opts) => {
|
|
83
|
+
const resolved = await resolveSource(source);
|
|
84
|
+
const r = await post("/api/v1/reference/extract", { source: resolved });
|
|
85
|
+
if (opts.output) {
|
|
86
|
+
const outAbs = path.resolve(opts.output);
|
|
87
|
+
await fs.mkdir(path.dirname(outAbs), { recursive: true });
|
|
88
|
+
await fs.writeFile(outAbs, r.text, "utf-8");
|
|
89
|
+
success(`Saved → ${outAbs} (${r.kind}, ${r.text.length} chars, ` +
|
|
90
|
+
`parse ${(r.duration_ms / 1000).toFixed(1)}s` +
|
|
91
|
+
(r.cost_usd > 0 ? `, $${r.cost_usd.toFixed(6)})` : ")"));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (isJson()) {
|
|
95
|
+
print(r);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
print(r.text);
|
|
99
|
+
info(`${r.kind} · ${r.text.length} chars · parse ${(r.duration_ms / 1000).toFixed(1)}s` +
|
|
100
|
+
(r.cost_usd > 0 ? ` · $${r.cost_usd.toFixed(6)}` : ""));
|
|
101
|
+
});
|
|
102
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
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 { print, success, info, error as logError } from "../utils/output.js";
|
|
6
|
+
function resolveOutputPaths(opts, awemeId) {
|
|
7
|
+
const cwd = process.cwd();
|
|
8
|
+
let baseDir;
|
|
9
|
+
let stem;
|
|
10
|
+
if (!opts.output) {
|
|
11
|
+
baseDir = cwd;
|
|
12
|
+
stem = awemeId;
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
const abs = path.resolve(opts.output);
|
|
16
|
+
const isExistingDir = fsSync.existsSync(abs) && fsSync.statSync(abs).isDirectory();
|
|
17
|
+
if (isExistingDir || opts.output.endsWith("/") || opts.output.endsWith("\\")) {
|
|
18
|
+
baseDir = abs;
|
|
19
|
+
stem = awemeId;
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
baseDir = path.dirname(abs);
|
|
23
|
+
const ext = path.extname(abs);
|
|
24
|
+
stem = ext ? path.basename(abs, ext) : path.basename(abs);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
mp4Path: path.join(baseDir, `${stem}.mp4`),
|
|
29
|
+
txtPath: path.join(baseDir, `${stem}.txt`),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
async function streamDownload(url, dest) {
|
|
33
|
+
await fs.mkdir(path.dirname(path.resolve(dest)), { recursive: true });
|
|
34
|
+
const r = await fetch(url, {
|
|
35
|
+
redirect: "follow",
|
|
36
|
+
headers: {
|
|
37
|
+
"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1",
|
|
38
|
+
Accept: "*/*",
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
if (!r.ok)
|
|
42
|
+
throw new Error(`MP4 download failed: HTTP ${r.status}`);
|
|
43
|
+
const buf = Buffer.from(await r.arrayBuffer());
|
|
44
|
+
if (buf.byteLength === 0)
|
|
45
|
+
throw new Error("MP4 download returned empty body");
|
|
46
|
+
await fs.writeFile(dest, buf);
|
|
47
|
+
return buf.byteLength;
|
|
48
|
+
}
|
|
49
|
+
function fmtSize(bytes) {
|
|
50
|
+
if (bytes < 1024)
|
|
51
|
+
return `${bytes} B`;
|
|
52
|
+
if (bytes < 1024 * 1024)
|
|
53
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
54
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
55
|
+
}
|
|
56
|
+
export function registerFetch(program) {
|
|
57
|
+
program
|
|
58
|
+
.command("fetch <link>")
|
|
59
|
+
.description("解析抖音分享链接 → 下载 MP4 (可选附 ASR 转写)")
|
|
60
|
+
.helpOption("-h, --help", "show help")
|
|
61
|
+
.option("-o, --output <path>", "output file or directory (default: ./<aweme_id>.mp4)")
|
|
62
|
+
.option("--url-only", "only print the high-quality MP4 URL; do not download")
|
|
63
|
+
.option("-t, --transcribe", "also run ASR on the audio and save text as <basename>.txt")
|
|
64
|
+
.option("--transcribe-only", "skip MP4 download — only save the ASR transcript .txt")
|
|
65
|
+
.addHelpText("after", [
|
|
66
|
+
"",
|
|
67
|
+
"Arguments:",
|
|
68
|
+
" <link> 抖音 share link. Bare URL or full 复制 paste (e.g. 'xxxx https://v.douyin.com/abc/ yyyy').",
|
|
69
|
+
"",
|
|
70
|
+
"Examples:",
|
|
71
|
+
" # default — download MP4 to ./<aweme_id>.mp4",
|
|
72
|
+
" rf fetch 'https://v.douyin.com/iABCxyz/'",
|
|
73
|
+
"",
|
|
74
|
+
" # paste the whole 抖音 share口令 verbatim (quotes recommended in shell)",
|
|
75
|
+
" rf fetch '5.32 复制此链接... https://v.douyin.com/iABCxyz/ ...'",
|
|
76
|
+
"",
|
|
77
|
+
" # specify output dir or file",
|
|
78
|
+
" rf fetch <link> -o ./clips/ # → ./clips/<aweme_id>.mp4",
|
|
79
|
+
" rf fetch <link> -o ./clips/charlie.mp4 # → ./clips/charlie.mp4",
|
|
80
|
+
"",
|
|
81
|
+
" # just print the MP4 URL (scripts / curl)",
|
|
82
|
+
" rf fetch <link> --url-only",
|
|
83
|
+
"",
|
|
84
|
+
" # MP4 + transcript (writes <basename>.mp4 and <basename>.txt)",
|
|
85
|
+
" rf fetch <link> -t",
|
|
86
|
+
"",
|
|
87
|
+
" # only the transcript",
|
|
88
|
+
" rf fetch <link> --transcribe-only -o ./scripts/charlie.txt",
|
|
89
|
+
].join("\n"))
|
|
90
|
+
.action(async (link, opts) => {
|
|
91
|
+
const transcribe = !!opts.transcribe || !!opts.transcribeOnly;
|
|
92
|
+
const r = await post("/api/v1/douyin/parse", {
|
|
93
|
+
share_text: link,
|
|
94
|
+
transcribe,
|
|
95
|
+
});
|
|
96
|
+
if (!r.ok) {
|
|
97
|
+
throw new Error("Server returned non-ok response");
|
|
98
|
+
}
|
|
99
|
+
if (opts.urlOnly) {
|
|
100
|
+
console.log(r.video_url);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
info(`[${r.aweme_id}] ${r.author ? `@${r.author} — ` : ""}${r.title.slice(0, 60)}${r.duration_sec ? ` (${r.duration_sec.toFixed(1)}s)` : ""}`);
|
|
104
|
+
const { mp4Path, txtPath } = resolveOutputPaths(opts, r.aweme_id);
|
|
105
|
+
if (!opts.transcribeOnly) {
|
|
106
|
+
const size = await streamDownload(r.video_url, mp4Path);
|
|
107
|
+
success(`Saved MP4 → ${mp4Path} (${fmtSize(size)})`);
|
|
108
|
+
}
|
|
109
|
+
if (transcribe) {
|
|
110
|
+
if (!r.transcript) {
|
|
111
|
+
logError("Server did not return a transcript despite transcribe=true (check server logs).");
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
await fs.mkdir(path.dirname(path.resolve(txtPath)), { recursive: true });
|
|
115
|
+
await fs.writeFile(txtPath, r.transcript.text, "utf-8");
|
|
116
|
+
success(`Saved transcript → ${txtPath} (${r.transcript.text.length} chars)`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
print({
|
|
120
|
+
aweme_id: r.aweme_id,
|
|
121
|
+
title: r.title,
|
|
122
|
+
author: r.author,
|
|
123
|
+
duration_sec: r.duration_sec,
|
|
124
|
+
mp4_path: opts.transcribeOnly ? null : mp4Path,
|
|
125
|
+
txt_path: transcribe && r.transcript ? txtPath : null,
|
|
126
|
+
video_url: r.video_url,
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { del, get, uploadMultipart } from "../client.js";
|
|
4
|
+
import { downloadTo } from "../utils/download.js";
|
|
5
|
+
import { print, table, success, bytes } from "../utils/output.js";
|
|
6
|
+
export function registerFiles(program) {
|
|
7
|
+
const files = program
|
|
8
|
+
.command("files")
|
|
9
|
+
.alias("file")
|
|
10
|
+
.description("用户文件管理: 上传 / 列表 / 下载 / 删除")
|
|
11
|
+
.helpOption("-h, --help", "show help");
|
|
12
|
+
files
|
|
13
|
+
.command("list")
|
|
14
|
+
.description("List all uploaded files")
|
|
15
|
+
.helpOption("-h, --help", "show help")
|
|
16
|
+
.action(async () => {
|
|
17
|
+
const r = await get("/api/v1/files");
|
|
18
|
+
table(r.files.map((f) => ({
|
|
19
|
+
name: f.name,
|
|
20
|
+
size: bytes(f.size_bytes),
|
|
21
|
+
modified: f.modified_at,
|
|
22
|
+
path: f.relative_path,
|
|
23
|
+
})));
|
|
24
|
+
});
|
|
25
|
+
files
|
|
26
|
+
.command("upload <file>")
|
|
27
|
+
.description("Upload a local file (saved under data/uploads/, returns its server path)")
|
|
28
|
+
.helpOption("-h, --help", "show help")
|
|
29
|
+
.action(async (file) => {
|
|
30
|
+
const abs = path.resolve(file);
|
|
31
|
+
const buf = fs.readFileSync(abs);
|
|
32
|
+
const name = path.basename(abs);
|
|
33
|
+
const fileObj = new File([new Uint8Array(buf)], name);
|
|
34
|
+
const r = await uploadMultipart("/api/v1/files", { file: fileObj });
|
|
35
|
+
success(`Uploaded → ${r.relative_path}`);
|
|
36
|
+
print(r);
|
|
37
|
+
});
|
|
38
|
+
files
|
|
39
|
+
.command("download <relativePath>")
|
|
40
|
+
.description("Download a server file to a local path")
|
|
41
|
+
.helpOption("-h, --help", "show help")
|
|
42
|
+
.requiredOption("-o, --output <file>", "local destination path")
|
|
43
|
+
.action(async (rel, opts) => {
|
|
44
|
+
await downloadTo(`/api/v1/files/${rel}`, opts.output);
|
|
45
|
+
success(`Saved → ${opts.output}`);
|
|
46
|
+
});
|
|
47
|
+
files
|
|
48
|
+
.command("delete <relativePath>")
|
|
49
|
+
.alias("rm")
|
|
50
|
+
.description("Delete an uploaded file (only data/uploads/ and output/ paths)")
|
|
51
|
+
.helpOption("-h, --help", "show help")
|
|
52
|
+
.action(async (rel) => {
|
|
53
|
+
const r = await del(`/api/v1/files/${rel}`);
|
|
54
|
+
print(r);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { get } from "../client.js";
|
|
2
|
+
import { print } from "../utils/output.js";
|
|
3
|
+
export function registerHealth(program) {
|
|
4
|
+
program
|
|
5
|
+
.command("health")
|
|
6
|
+
.description("检查服务器健康状态与可用能力")
|
|
7
|
+
.helpOption("-h, --help", "show help")
|
|
8
|
+
.action(async () => {
|
|
9
|
+
const r = await get("/api/v1/health");
|
|
10
|
+
print(r);
|
|
11
|
+
});
|
|
12
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { del, get } from "../client.js";
|
|
2
|
+
import { downloadTo } from "../utils/download.js";
|
|
3
|
+
import { print, table, success, bytes } from "../utils/output.js";
|
|
4
|
+
export function registerHistory(program) {
|
|
5
|
+
const h = program
|
|
6
|
+
.command("history")
|
|
7
|
+
.description("浏览之前生成的视频")
|
|
8
|
+
.helpOption("-h, --help", "show help");
|
|
9
|
+
h
|
|
10
|
+
.command("list")
|
|
11
|
+
.description("List all generation tasks with completed videos")
|
|
12
|
+
.helpOption("-h, --help", "show help")
|
|
13
|
+
.action(async () => {
|
|
14
|
+
const r = await get("/api/v1/history");
|
|
15
|
+
table(r.history.map((e) => ({
|
|
16
|
+
task_id: e.task_id,
|
|
17
|
+
created: e.created_at,
|
|
18
|
+
size: bytes(e.size_bytes),
|
|
19
|
+
video: e.video_url || "-",
|
|
20
|
+
})));
|
|
21
|
+
});
|
|
22
|
+
h
|
|
23
|
+
.command("get <id>")
|
|
24
|
+
.description("Show metadata + storyboard JSON for a completed task")
|
|
25
|
+
.helpOption("-h, --help", "show help")
|
|
26
|
+
.option("--download <file>", "additionally download the final video to this path")
|
|
27
|
+
.action(async (id, opts) => {
|
|
28
|
+
const r = await get(`/api/v1/history/${id}`);
|
|
29
|
+
if (opts.download && r.video_url) {
|
|
30
|
+
await downloadTo(r.video_url, opts.download);
|
|
31
|
+
success(`Saved video → ${opts.download}`);
|
|
32
|
+
}
|
|
33
|
+
print(r);
|
|
34
|
+
});
|
|
35
|
+
h
|
|
36
|
+
.command("delete <id>")
|
|
37
|
+
.alias("rm")
|
|
38
|
+
.description("Permanently delete an output/<id>/ directory")
|
|
39
|
+
.helpOption("-h, --help", "show help")
|
|
40
|
+
.action(async (id) => {
|
|
41
|
+
const r = await del(`/api/v1/history/${id}`);
|
|
42
|
+
print(r);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
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 { downloadTo } from "../utils/download.js";
|
|
6
|
+
import { print, success } from "../utils/output.js";
|
|
7
|
+
async function resolveImageRef(input) {
|
|
8
|
+
const t = input.trim();
|
|
9
|
+
if (!t)
|
|
10
|
+
throw new Error("--image: empty value");
|
|
11
|
+
if (/^https?:\/\//i.test(t) || t.startsWith("data:"))
|
|
12
|
+
return t;
|
|
13
|
+
const abs = path.resolve(t);
|
|
14
|
+
if (!fsSync.existsSync(abs)) {
|
|
15
|
+
throw new Error(`--image: local file not found: ${abs}`);
|
|
16
|
+
}
|
|
17
|
+
const ext = path.extname(abs).toLowerCase();
|
|
18
|
+
const mime = ext === ".jpg" || ext === ".jpeg" ? "image/jpeg" :
|
|
19
|
+
ext === ".webp" ? "image/webp" :
|
|
20
|
+
ext === ".png" ? "image/png" :
|
|
21
|
+
null;
|
|
22
|
+
if (!mime) {
|
|
23
|
+
throw new Error(`--image: unsupported extension ${ext} (use png/jpg/jpeg/webp)`);
|
|
24
|
+
}
|
|
25
|
+
const buf = await fs.readFile(abs);
|
|
26
|
+
return `data:${mime};base64,${buf.toString("base64")}`;
|
|
27
|
+
}
|
|
28
|
+
export function registerImages(program) {
|
|
29
|
+
const images = program
|
|
30
|
+
.command("images")
|
|
31
|
+
.description("图像生成: 文本 → 图片")
|
|
32
|
+
.helpOption("-h, --help", "show help");
|
|
33
|
+
images
|
|
34
|
+
.command("generate")
|
|
35
|
+
.description("Generate an image (text-to-image or ref-image, depending on model + --image)")
|
|
36
|
+
.helpOption("-h, --help", "show help")
|
|
37
|
+
.requiredOption("-p, --prompt <text>", "text prompt. With --image, reference each ref via 「图 N」/「Image N」.")
|
|
38
|
+
.option("-m, --model <id>", "image model id (rx-image-z | rx-image-flux | rx-image-qwen | rx-image-qwen-edit)")
|
|
39
|
+
.option("--width <n>", "image width", parseInt)
|
|
40
|
+
.option("--height <n>", "image height", parseInt)
|
|
41
|
+
.option("-i, --image <urlOrPath>", "reference image, repeatable up to 3 times. URL / data: URI / local png/jpg/webp path (auto-base64'd). Index N-1 maps to 「图 N」in the prompt. Only honored by edit-capable SKUs (rx-image-qwen-edit).", (val, prev) => [...prev, val], [])
|
|
42
|
+
.option("-o, --output <file>", "download first image to this local path")
|
|
43
|
+
.option("--all-output <dir>", "download ALL generated images into this directory")
|
|
44
|
+
.addHelpText("after", [
|
|
45
|
+
"",
|
|
46
|
+
"Examples:",
|
|
47
|
+
" # plain text-to-image",
|
|
48
|
+
" reelforge images generate -p 'a cat' -m rx-image-flux --width 1024 --height 1024 -o cat.png",
|
|
49
|
+
"",
|
|
50
|
+
" # ref-image edit (Qwen-Image-Edit), 1 ref local file",
|
|
51
|
+
" reelforge images generate -m rx-image-qwen-edit \\",
|
|
52
|
+
" -p '保持图1人物,把背景改成雪山黄昏' \\",
|
|
53
|
+
" -i ./hero.png -o out.png",
|
|
54
|
+
"",
|
|
55
|
+
" # ref-image edit, 2 refs (URL + local), 「图1」+「图2」",
|
|
56
|
+
" reelforge images generate -m rx-image-qwen-edit \\",
|
|
57
|
+
" -p '让图1的人物穿上图2的衣服' \\",
|
|
58
|
+
" -i https://example.com/person.jpg -i ./outfit.png -o composite.png",
|
|
59
|
+
].join("\n"))
|
|
60
|
+
.action(async (opts) => {
|
|
61
|
+
const body = { prompt: opts.prompt };
|
|
62
|
+
if (opts.model)
|
|
63
|
+
body.model = opts.model;
|
|
64
|
+
if (opts.width !== undefined)
|
|
65
|
+
body.width = opts.width;
|
|
66
|
+
if (opts.height !== undefined)
|
|
67
|
+
body.height = opts.height;
|
|
68
|
+
if (opts.image && Array.isArray(opts.image) && opts.image.length > 0) {
|
|
69
|
+
if (opts.image.length > 3) {
|
|
70
|
+
throw new Error(`--image accepts at most 3 refs (got ${opts.image.length})`);
|
|
71
|
+
}
|
|
72
|
+
body.image_urls = await Promise.all(opts.image.map(resolveImageRef));
|
|
73
|
+
}
|
|
74
|
+
const r = await post("/api/v1/images/generate", body);
|
|
75
|
+
if (opts.output && r.images?.[0]) {
|
|
76
|
+
await downloadTo(r.images[0], opts.output);
|
|
77
|
+
success(`Saved → ${opts.output}`);
|
|
78
|
+
}
|
|
79
|
+
if (opts.allOutput) {
|
|
80
|
+
for (let i = 0; i < r.images.length; i++) {
|
|
81
|
+
const dest = `${opts.allOutput}/image_${i + 1}.png`;
|
|
82
|
+
await downloadTo(r.images[i], dest);
|
|
83
|
+
success(`Saved → ${dest}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
print(r);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import { get, post } from "../client.js";
|
|
3
|
+
import { print, table } from "../utils/output.js";
|
|
4
|
+
export function registerLlm(program) {
|
|
5
|
+
const llm = program
|
|
6
|
+
.command("llm")
|
|
7
|
+
.description("LLM 工具: chat (单轮对话) / presets (模型预设清单)")
|
|
8
|
+
.helpOption("-h, --help", "show help");
|
|
9
|
+
llm
|
|
10
|
+
.command("chat")
|
|
11
|
+
.description("Send a single prompt to the configured LLM and print the response")
|
|
12
|
+
.helpOption("-h, --help", "show help")
|
|
13
|
+
.requiredOption("-p, --prompt <text>", "the prompt text (use @file to read from a file)")
|
|
14
|
+
.option("-m, --model <name>", "override model (default: from server config)")
|
|
15
|
+
.option("--base-url <url>", "override LLM base URL")
|
|
16
|
+
.option("--api-key <key>", "override LLM API key")
|
|
17
|
+
.option("-t, --temperature <n>", "sampling temperature (0..2)", parseFloat)
|
|
18
|
+
.option("--max-tokens <n>", "max tokens to generate", parseInt)
|
|
19
|
+
.option("--schema <file>", "JSON schema file for structured output (returns parsed object)")
|
|
20
|
+
.addHelpText("after", [
|
|
21
|
+
"",
|
|
22
|
+
"Examples:",
|
|
23
|
+
" reelforge llm chat -p 'Hello'",
|
|
24
|
+
" reelforge llm chat -p @prompt.txt -m deepseek-v4-flash -t 0.4",
|
|
25
|
+
" reelforge llm chat -p 'movie review of Inception' --schema review.json --json",
|
|
26
|
+
"",
|
|
27
|
+
"Model IDs:",
|
|
28
|
+
" Pass the bare model id (e.g. `model-name`).",
|
|
29
|
+
" The provider/model form returned by `rf models` (e.g. `provider/model-name`)",
|
|
30
|
+
" may need explicit provider access on your account and is more",
|
|
31
|
+
" likely to return 'no permission'.",
|
|
32
|
+
].join("\n"))
|
|
33
|
+
.action(async (opts) => {
|
|
34
|
+
let prompt = opts.prompt;
|
|
35
|
+
if (prompt.startsWith("@"))
|
|
36
|
+
prompt = await fs.readFile(prompt.slice(1), "utf-8");
|
|
37
|
+
const body = { prompt };
|
|
38
|
+
if (opts.model)
|
|
39
|
+
body.model = opts.model;
|
|
40
|
+
if (opts.baseUrl)
|
|
41
|
+
body.base_url = opts.baseUrl;
|
|
42
|
+
if (opts.apiKey)
|
|
43
|
+
body.api_key = opts.apiKey;
|
|
44
|
+
if (opts.temperature !== undefined)
|
|
45
|
+
body.temperature = opts.temperature;
|
|
46
|
+
if (opts.maxTokens !== undefined)
|
|
47
|
+
body.max_tokens = opts.maxTokens;
|
|
48
|
+
if (opts.schema) {
|
|
49
|
+
body.json_schema = JSON.parse(await fs.readFile(opts.schema, "utf-8"));
|
|
50
|
+
}
|
|
51
|
+
const r = await post("/api/v1/llm/chat", body);
|
|
52
|
+
print(r.output);
|
|
53
|
+
});
|
|
54
|
+
llm
|
|
55
|
+
.command("presets")
|
|
56
|
+
.description("List built-in model presets")
|
|
57
|
+
.helpOption("-h, --help", "show help")
|
|
58
|
+
.action(async () => {
|
|
59
|
+
const r = await get("/api/v1/llm/presets");
|
|
60
|
+
table(r.presets.map((p) => ({
|
|
61
|
+
id: p.id,
|
|
62
|
+
label: p.label,
|
|
63
|
+
base_url: p.baseUrl,
|
|
64
|
+
default_model: p.defaultModel,
|
|
65
|
+
})));
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
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 { info, isJson, print } from "../utils/output.js";
|
|
6
|
+
const MEDIA_EXT_TO_MIME = {
|
|
7
|
+
".mp4": "video/mp4",
|
|
8
|
+
".mov": "video/quicktime",
|
|
9
|
+
".webm": "video/webm",
|
|
10
|
+
".mkv": "video/x-matroska",
|
|
11
|
+
".m4v": "video/mp4",
|
|
12
|
+
".mp3": "audio/mpeg",
|
|
13
|
+
".wav": "audio/wav",
|
|
14
|
+
".m4a": "audio/mp4",
|
|
15
|
+
".ogg": "audio/ogg",
|
|
16
|
+
".flac": "audio/flac",
|
|
17
|
+
".png": "image/png",
|
|
18
|
+
".jpg": "image/jpeg",
|
|
19
|
+
".jpeg": "image/jpeg",
|
|
20
|
+
".webp": "image/webp",
|
|
21
|
+
};
|
|
22
|
+
function fmtSize(bytes) {
|
|
23
|
+
if (!bytes)
|
|
24
|
+
return "0";
|
|
25
|
+
if (bytes < 1024)
|
|
26
|
+
return `${bytes} B`;
|
|
27
|
+
if (bytes < 1024 * 1024)
|
|
28
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
29
|
+
if (bytes < 1024 * 1024 * 1024)
|
|
30
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
31
|
+
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
|
32
|
+
}
|
|
33
|
+
function formatHuman(r, label) {
|
|
34
|
+
const lines = [];
|
|
35
|
+
lines.push(`${label} [${r.kind}]`);
|
|
36
|
+
if (r.formatName)
|
|
37
|
+
lines.push(` format: ${r.formatName}`);
|
|
38
|
+
if (r.durationSec) {
|
|
39
|
+
const min = Math.floor(r.durationSec / 60);
|
|
40
|
+
const sec = (r.durationSec % 60).toFixed(2);
|
|
41
|
+
lines.push(` duration: ${r.durationSec.toFixed(2)}s (${min}:${sec.padStart(5, "0")})`);
|
|
42
|
+
}
|
|
43
|
+
if (r.sizeBytes)
|
|
44
|
+
lines.push(` size: ${fmtSize(r.sizeBytes)}`);
|
|
45
|
+
if (r.bitrate)
|
|
46
|
+
lines.push(` bitrate: ${(r.bitrate / 1000).toFixed(0)} kbps`);
|
|
47
|
+
if (r.video) {
|
|
48
|
+
lines.push(` video: ${r.video.codec} ${r.video.width}×${r.video.height} @ ${r.video.fps} fps` +
|
|
49
|
+
(r.video.pixFmt ? ` (${r.video.pixFmt})` : ""));
|
|
50
|
+
}
|
|
51
|
+
if (r.audio) {
|
|
52
|
+
lines.push(` audio: ${r.audio.codec} ${r.audio.sampleRate} Hz ${r.audio.channels}ch` +
|
|
53
|
+
(r.audio.channelLayout ? ` (${r.audio.channelLayout})` : ""));
|
|
54
|
+
}
|
|
55
|
+
const counts = r.streamCounts;
|
|
56
|
+
if (counts.subtitle > 0 || counts.other > 0) {
|
|
57
|
+
lines.push(` streams: ${counts.video}v + ${counts.audio}a + ${counts.subtitle}sub + ${counts.other}other`);
|
|
58
|
+
}
|
|
59
|
+
return lines;
|
|
60
|
+
}
|
|
61
|
+
export function registerMedia(program) {
|
|
62
|
+
const mediaCmd = program
|
|
63
|
+
.command("media")
|
|
64
|
+
.description("媒体处理原子能力。目前包含: probe (ffprobe 探针)")
|
|
65
|
+
.helpOption("-h, --help", "show help");
|
|
66
|
+
mediaCmd
|
|
67
|
+
.command("probe <input>")
|
|
68
|
+
.description("ffprobe 一个媒体文件 / URL → JSON metadata。常用于 agent 在决定下游参数前查媒体长度/编码/分辨率/帧率。")
|
|
69
|
+
.helpOption("-h, --help", "show help")
|
|
70
|
+
.addHelpText("after", [
|
|
71
|
+
"",
|
|
72
|
+
"Input 类型 (自动识别):",
|
|
73
|
+
" 本地路径 ./video.mp4 CLI base64 上传到服务端探针",
|
|
74
|
+
" http(s) URL https://example.com/x.mp4 服务端 ffprobe 直读",
|
|
75
|
+
"",
|
|
76
|
+
"支持的扩展名:",
|
|
77
|
+
" 视频: .mp4 .mov .webm .mkv .m4v",
|
|
78
|
+
" 音频: .mp3 .wav .m4a .ogg .flac",
|
|
79
|
+
" 图片: .png .jpg .jpeg .webp",
|
|
80
|
+
"",
|
|
81
|
+
"Examples:",
|
|
82
|
+
" # 本地 MP4",
|
|
83
|
+
" rf media probe ./video.mp4",
|
|
84
|
+
"",
|
|
85
|
+
" # 远程 URL (服务端直读, 不下载到本地)",
|
|
86
|
+
" rf media probe 'https://example.com/clip.mp4'",
|
|
87
|
+
"",
|
|
88
|
+
" # JSON 输出 (agent 解析)",
|
|
89
|
+
" rf media probe ./video.mp4 --json",
|
|
90
|
+
"",
|
|
91
|
+
" # 抽特定字段",
|
|
92
|
+
" rf media probe ./video.mp4 --json | jq '.durationSec'",
|
|
93
|
+
" rf media probe ./video.mp4 --json | jq '.video.fps'",
|
|
94
|
+
"",
|
|
95
|
+
"成本: $0 (纯 ffprobe, 无 LLM/ASR)",
|
|
96
|
+
].join("\n"))
|
|
97
|
+
.action(async (input) => {
|
|
98
|
+
const isRemote = /^https?:\/\//i.test(input);
|
|
99
|
+
let body;
|
|
100
|
+
let label;
|
|
101
|
+
if (isRemote) {
|
|
102
|
+
body = { url: input };
|
|
103
|
+
label = input;
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
const abs = path.resolve(input);
|
|
107
|
+
if (!fsSync.existsSync(abs))
|
|
108
|
+
throw new Error(`file not found: ${abs}`);
|
|
109
|
+
const ext = path.extname(abs).toLowerCase();
|
|
110
|
+
const mime = MEDIA_EXT_TO_MIME[ext];
|
|
111
|
+
if (!mime) {
|
|
112
|
+
throw new Error(`unsupported extension "${ext}". Supported: ${Object.keys(MEDIA_EXT_TO_MIME).join(" / ")}, ` +
|
|
113
|
+
`or pass a URL.`);
|
|
114
|
+
}
|
|
115
|
+
const buf = await fs.readFile(abs);
|
|
116
|
+
body = { data_uri: `data:${mime};base64,${buf.toString("base64")}` };
|
|
117
|
+
label = path.basename(abs);
|
|
118
|
+
}
|
|
119
|
+
const r = await post("/api/v1/media/probe", body);
|
|
120
|
+
if (isJson()) {
|
|
121
|
+
print(r);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const lines = formatHuman(r, label);
|
|
125
|
+
for (const line of lines)
|
|
126
|
+
info(line);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { get } from "../client.js";
|
|
2
|
+
import { table } from "../utils/output.js";
|
|
3
|
+
export function registerModels(program) {
|
|
4
|
+
program
|
|
5
|
+
.command("models")
|
|
6
|
+
.description("列出可用模型目录 (LLM / TTS / 图片 / ASR) 含价格")
|
|
7
|
+
.helpOption("-h, --help", "show help")
|
|
8
|
+
.option("--modality <m>", "filter by modality: llm | tts | image | asr")
|
|
9
|
+
.option("--refresh", "bypass the 5-minute server-side cache")
|
|
10
|
+
.addHelpText("after", [
|
|
11
|
+
"",
|
|
12
|
+
"Examples:",
|
|
13
|
+
" reelforge models # all modalities",
|
|
14
|
+
" reelforge models --modality llm # only chat models",
|
|
15
|
+
" reelforge models --modality tts # only TTS models",
|
|
16
|
+
" reelforge models --refresh # force a re-fetch",
|
|
17
|
+
].join("\n"))
|
|
18
|
+
.action(async (opts) => {
|
|
19
|
+
const qs = new URLSearchParams();
|
|
20
|
+
if (opts.modality)
|
|
21
|
+
qs.set("modality", opts.modality);
|
|
22
|
+
if (opts.refresh)
|
|
23
|
+
qs.set("refresh", "1");
|
|
24
|
+
const path = `/api/v1/models${qs.toString() ? `?${qs.toString()}` : ""}`;
|
|
25
|
+
const r = await get(path);
|
|
26
|
+
table(r.models.map((m) => ({
|
|
27
|
+
id: m.id,
|
|
28
|
+
modality: m.modality,
|
|
29
|
+
owned_by: m.owned_by,
|
|
30
|
+
context: m.context_length ?? "",
|
|
31
|
+
input_per_1m: m.pricing.input_per_1m ?? "",
|
|
32
|
+
output_per_1m: m.pricing.output_per_1m ?? "",
|
|
33
|
+
per_1m_chars: m.pricing.per_1m_chars ?? "",
|
|
34
|
+
})));
|
|
35
|
+
});
|
|
36
|
+
}
|