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.
Files changed (45) hide show
  1. package/README.md +4 -291
  2. package/package.json +4 -41
  3. package/bin/reelforge.js +0 -8
  4. package/dist/client.js +0 -120
  5. package/dist/commands/assets-workflow-text.js +0 -22
  6. package/dist/commands/assets.js +0 -243
  7. package/dist/commands/audio.js +0 -91
  8. package/dist/commands/auth.js +0 -170
  9. package/dist/commands/bgm.js +0 -59
  10. package/dist/commands/clip.js +0 -139
  11. package/dist/commands/compose.js +0 -298
  12. package/dist/commands/compositions.js +0 -148
  13. package/dist/commands/config.js +0 -62
  14. package/dist/commands/content.js +0 -66
  15. package/dist/commands/cover.js +0 -421
  16. package/dist/commands/create.js +0 -638
  17. package/dist/commands/dub.js +0 -220
  18. package/dist/commands/extract.js +0 -115
  19. package/dist/commands/fetch.js +0 -129
  20. package/dist/commands/files.js +0 -70
  21. package/dist/commands/health.js +0 -12
  22. package/dist/commands/history.js +0 -44
  23. package/dist/commands/images.js +0 -107
  24. package/dist/commands/llm.js +0 -67
  25. package/dist/commands/media.js +0 -136
  26. package/dist/commands/models.js +0 -36
  27. package/dist/commands/pipelines.js +0 -142
  28. package/dist/commands/platform.js +0 -218
  29. package/dist/commands/regen.js +0 -134
  30. package/dist/commands/render.js +0 -82
  31. package/dist/commands/script.js +0 -128
  32. package/dist/commands/styles.js +0 -113
  33. package/dist/commands/subtitles.js +0 -246
  34. package/dist/commands/tasks.js +0 -59
  35. package/dist/commands/tts.js +0 -134
  36. package/dist/commands/vlog.js +0 -91
  37. package/dist/data/vlog/general.md +0 -129
  38. package/dist/data/vlog/pet.md +0 -160
  39. package/dist/index.js +0 -183
  40. package/dist/utils/config-file.js +0 -37
  41. package/dist/utils/download.js +0 -13
  42. package/dist/utils/file-upload.js +0 -102
  43. package/dist/utils/oss-upload.js +0 -44
  44. package/dist/utils/output.js +0 -91
  45. package/dist/utils/task-waiter.js +0 -40
@@ -1,220 +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 { waitForTask } from "../utils/task-waiter.js";
7
- import { downloadTo } from "../utils/download.js";
8
- import { formatUsd, humanDuration, info, isJson, print, success, warn } from "../utils/output.js";
9
- const VIDEO_EXT_TO_MIME = {
10
- ".mp4": "video/mp4",
11
- ".mov": "video/quicktime",
12
- ".webm": "video/webm",
13
- ".mkv": "video/x-matroska",
14
- ".m4v": "video/mp4",
15
- };
16
- const MAX_BYTES = 55 * 1024 * 1024;
17
- function fmtSize(bytes) {
18
- if (!bytes)
19
- return "0";
20
- if (bytes < 1024)
21
- return `${bytes} B`;
22
- if (bytes < 1024 * 1024)
23
- return `${(bytes / 1024).toFixed(1)} KB`;
24
- return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
25
- }
26
- export function registerDub(program) {
27
- program
28
- .command("dub <video>")
29
- .description("⚠️ EXPERIMENTAL · 一个原始视频 → 自动反推内容 + 写解说 + 熊小二配音 + 字幕,输出成片")
30
- .helpOption("-h, --help", "show help")
31
- .option("-o, --output <file>", "输出 MP4 路径 (默认: <视频名>-dub.mp4, 放在当前目录)")
32
- .option("--voice <id>", "配音音色 id (index-tts-2 音色名, 默认 熊小二)", "熊小二")
33
- .option("--tts-model <id>", "TTS 模型 (默认 vox/index-tts-2)", "vox/index-tts-2")
34
- .option("--game-volume <n>", "原视频自带声音的音量 0..1 (默认 0.6; 设 0 = 完全静音原声, 只留配音)", (v) => parseFloat(v), 0.6)
35
- .option("--no-subtitles", "不烧录字幕 (默认烧录)")
36
- .option("--subtitle-style <style>", "字幕样式: plate | stroke | cinema (默认 plate)", "plate")
37
- .option("--no-cover", "不加封面 (默认自动生成封面)")
38
- .option("--cover-template <id>", "封面模板 (默认 paper-band; 见 `rf cover templates`)", "paper-band")
39
- .option("--cover-title <text>", "封面标题 (默认自动生成 ≤10 字钩子)")
40
- .option("--cover-subtitle <text>", "封面副标题 (默认无)")
41
- .option("--cover-badge <text>", "封面角标 (默认无)")
42
- .option("--append <file>", "在成片末尾拼接一段视频 (如 `rf clip` 出的关注片尾; 可多次传, 按顺序拼)", (val, acc) => {
43
- acc.push(val);
44
- return acc;
45
- }, [])
46
- .option("--theme <id>", "可选主题 id (影响字幕底板 / 配色风格)")
47
- .option("--vision-model <id>", "反推用的视觉模型 (默认 qwen/qwen3-vl-flash)")
48
- .option("--script-model <id>", "写解说脚本用的 LLM (默认服务端配置模型)")
49
- .option("--preview-only", "只生成 preview, 不渲染最终 MP4 (更快更省, 用于试效果)")
50
- .option("--poll-ms <ms>", "轮询间隔", (v) => parseInt(v, 10), 1500)
51
- .option("--timeout-ms <ms>", "最长等待时间", (v) => parseInt(v, 10))
52
- .addHelpText("after", [
53
- "",
54
- "它做了什么 (全部在服务端完成):",
55
- " 1. 反推视频内容 — 详细、带时间段的分镜 + 屏幕文字",
56
- " 2. 根据视频时长写一段贴合画面的解说脚本 (卡住时长, 画面演到哪讲到哪)",
57
- " 3. 用熊小二音色合成配音 (可 --voice 指定其它音色)",
58
- " 4. 把配音叠到原视频上, 原视频自带声音压到 60% 垫底 (可 --game-volume 调)",
59
- " 5. 烧录字幕 (复用 compose 的字幕样式 + 切分逻辑; --no-subtitles 关闭)",
60
- " 6. 自动生成封面 — 选画面最炸的一帧当底图 + LLM 钩子标题, 套 paper-band 模板",
61
- " (--no-cover 关; --cover-template / --cover-title 自定; 见 `rf cover templates`)",
62
- " 7. (可选) 在末尾拼接片尾 — --append <clip.mp4>, 可多次, 按顺序拼",
63
- " (片尾用 `rf clip` 先生成, 比如固定的「关注」引导卡, 生成一次反复用)",
64
- "",
65
- "认证:",
66
- " 需要 API key — `rf login <key>` 或 $REELFORGE_API_KEY 或 --api-key <key>。",
67
- " 没有 key 服务端会拒绝 (401)。",
68
- "",
69
- "支持的输入扩展名: .mp4 .mov .webm .mkv .m4v (单文件 ≤ 55MB)",
70
- "",
71
- "可用音色: `rf tts voices` (熊小二 / 激情熊大 / 专业解说 等 149 个)",
72
- "",
73
- "示例:",
74
- " # 最简单: 一个视频进, 配音+字幕成片出 (默认熊小二, 原声 60%)",
75
- " rf dub ./clip.mp4",
76
- "",
77
- " # 指定输出路径 + 换个音色 + 原声调小到 30%",
78
- " rf dub ./clip.mp4 -o ./out.mp4 --voice 激情熊大 --game-volume 0.3",
79
- "",
80
- " # 不要字幕",
81
- " rf dub ./clip.mp4 --no-subtitles",
82
- "",
83
- " # 批量 (PowerShell): 对一个目录里所有 mp4 串行处理",
84
- " Get-ChildItem *.mp4 | ForEach-Object { rf dub $_.FullName }",
85
- "",
86
- " # 先看效果再决定 (preview, 不渲染 MP4)",
87
- " rf dub ./clip.mp4 --preview-only",
88
- "",
89
- " # 配上固定片尾「关注卡」(先用 rf clip 生成一次, 之后反复拼)",
90
- " rf clip --component kinetic-text --spec '{\"text\":\"关注 机械制造师\"}' \\",
91
- " --narration \"关注机械制造师官方账号,每天一个离谱发明\" -o outro.mp4",
92
- " rf dub ./clip.mp4 --append outro.mp4",
93
- ].join("\n"))
94
- .action(async (videoArg, opts) => {
95
- const abs = path.resolve(videoArg);
96
- if (!fsSync.existsSync(abs))
97
- throw new Error(`video not found: ${abs}`);
98
- const ext = path.extname(abs).toLowerCase();
99
- const mime = VIDEO_EXT_TO_MIME[ext];
100
- if (!mime) {
101
- throw new Error(`unsupported video extension "${ext}" (supported: ${Object.keys(VIDEO_EXT_TO_MIME).join(" / ")})`);
102
- }
103
- const gameVolume = opts.gameVolume ?? 0.6;
104
- if (!Number.isFinite(gameVolume) || gameVolume < 0 || gameVolume > 1) {
105
- throw new Error(`--game-volume must be between 0 and 1 (got ${opts.gameVolume})`);
106
- }
107
- const subtitleStyle = (opts.subtitleStyle || "plate").toLowerCase();
108
- if (!["plate", "stroke", "cinema"].includes(subtitleStyle)) {
109
- throw new Error(`--subtitle-style must be plate | stroke | cinema (got ${opts.subtitleStyle})`);
110
- }
111
- const body = {
112
- voice: opts.voice || "熊小二",
113
- tts_model: opts.ttsModel || "vox/index-tts-2",
114
- game_audio_volume: gameVolume,
115
- subtitles: opts.subtitles !== false,
116
- subtitle_style: subtitleStyle,
117
- cover: opts.cover !== false,
118
- };
119
- if (opts.coverTemplate)
120
- body.cover_template = opts.coverTemplate;
121
- if (opts.coverTitle)
122
- body.cover_title = opts.coverTitle;
123
- if (opts.coverSubtitle)
124
- body.cover_subtitle = opts.coverSubtitle;
125
- if (opts.coverBadge)
126
- body.cover_badge = opts.coverBadge;
127
- if (opts.theme)
128
- body.theme = opts.theme;
129
- const uploadOrInline = async (fileAbs, fileMime, label) => {
130
- try {
131
- const key = await uploadToOss(fileAbs);
132
- return { key };
133
- }
134
- catch (e) {
135
- const sz = (await fs.stat(fileAbs)).size;
136
- if (sz > MAX_BYTES) {
137
- throw new Error(`${label} 直传 OSS 失败 (${e instanceof Error ? e.message : e}),且文件 ${fmtSize(sz)} 超过内联回退上限 ${fmtSize(MAX_BYTES)}。`);
138
- }
139
- warn(`${label}: OSS 直传不可用,回退内联上传 (${e instanceof Error ? e.message : e})`);
140
- const buf = await fs.readFile(fileAbs);
141
- return { uri: `data:${fileMime};base64,${buf.toString("base64")}` };
142
- }
143
- };
144
- info(`Uploading ${path.basename(abs)} (${fmtSize((await fs.stat(abs)).size)}) → 直传对象存储...`);
145
- const mainUp = await uploadOrInline(abs, mime, "video");
146
- if (mainUp.key)
147
- body.video_key = mainUp.key;
148
- else
149
- body.video = mainUp.uri;
150
- const appendFiles = opts.append ?? [];
151
- if (appendFiles.length) {
152
- const appendKeys = [];
153
- const appendUris = [];
154
- for (const f of appendFiles) {
155
- const aAbs = path.resolve(f);
156
- if (!fsSync.existsSync(aAbs))
157
- throw new Error(`--append file not found: ${aAbs}`);
158
- const aExt = path.extname(aAbs).toLowerCase();
159
- const aMime = VIDEO_EXT_TO_MIME[aExt];
160
- if (!aMime)
161
- throw new Error(`--append: unsupported extension "${aExt}" (${path.basename(aAbs)})`);
162
- info(` + append ${path.basename(aAbs)} (${fmtSize((await fs.stat(aAbs)).size)})`);
163
- const up = await uploadOrInline(aAbs, aMime, `--append ${path.basename(aAbs)}`);
164
- if (up.key)
165
- appendKeys.push(up.key);
166
- else if (up.uri)
167
- appendUris.push(up.uri);
168
- }
169
- if (appendKeys.length)
170
- body.append_keys = appendKeys;
171
- if (appendUris.length)
172
- body.append = appendUris;
173
- }
174
- if (opts.visionModel)
175
- body.vision_model = opts.visionModel;
176
- if (opts.scriptModel)
177
- body.script_model = opts.scriptModel;
178
- if (opts.previewOnly)
179
- body.preview_only = true;
180
- const submitted = await post("/api/v1/dub", body);
181
- info(`Submitted task: ${submitted.task_id} — 反推 + 写脚本 + TTS + 渲染, 请稍候...`);
182
- const t = await waitForTask(submitted.task_id, { pollMs: opts.pollMs, timeoutMs: opts.timeoutMs });
183
- if (t.status !== "completed") {
184
- throw new Error(t.error || `Task ended with status ${t.status}`);
185
- }
186
- const result = t.result;
187
- if (result?.script?.length) {
188
- info(`配音脚本 (${result.voice || opts.voice}):`);
189
- for (const line of result.script)
190
- info(` ${line}`);
191
- }
192
- if (result?.render_pending) {
193
- info(`MP4 render skipped (--preview-only). Run \`rf render ${t.id}\` to finalize.`);
194
- }
195
- else if (result?.video_url) {
196
- const stdoutIsPipe = !process.stdout.isTTY;
197
- const skipDownload = stdoutIsPipe && !opts.output;
198
- let savedPath;
199
- if (opts.output) {
200
- savedPath = path.resolve(opts.output);
201
- }
202
- else if (!skipDownload) {
203
- const stem = path.parse(abs).name;
204
- savedPath = path.resolve(`${stem}-dub.mp4`);
205
- }
206
- if (savedPath) {
207
- await downloadTo(result.video_url, savedPath);
208
- success(`Saved → ${savedPath}`);
209
- }
210
- }
211
- if (result?.generation_ms != null) {
212
- const playback = result.duration != null ? `, video ${result.duration.toFixed(1)}s` : "";
213
- info(`generated in ${humanDuration(result.generation_ms)}${playback}`);
214
- }
215
- if (result?.price_usd != null)
216
- info(`charge: ${formatUsd(result.price_usd)}`);
217
- if (isJson())
218
- print({ task_id: t.id, status: t.status, ...result });
219
- });
220
- }
@@ -1,115 +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
- const MAX_BYTES = 25 * 1024 * 1024;
8
- const FILE_EXT_TO_MIME = {
9
- ".pdf": "application/pdf",
10
- ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
11
- ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
12
- };
13
- async function resolveSource(input) {
14
- const t = input.trim();
15
- if (!t)
16
- throw new Error("--reference / source is empty");
17
- if (t.startsWith("data:"))
18
- return { source: t };
19
- if (/^https?:\/\//i.test(t))
20
- return { source: t };
21
- if (/v\.douyin\.com\//i.test(t) || /复制此链接/.test(t) || /打开抖音/.test(t))
22
- return { source: t };
23
- if (/^[a-z0-9][a-z0-9-._]*\/[a-z0-9][a-z0-9-._]*$/i.test(t) && !t.split("/")[0].includes(".")) {
24
- return { source: t };
25
- }
26
- const abs = path.resolve(t);
27
- if (!fsSync.existsSync(abs)) {
28
- throw new Error(`source not found: ${abs}\n` +
29
- `For documents pass a PDF/DOCX/XLSX path; for videos / articles / repos pass a URL or owner/repo.`);
30
- }
31
- const ext = path.extname(abs).toLowerCase();
32
- const mime = FILE_EXT_TO_MIME[ext];
33
- if (!mime) {
34
- throw new Error(`unsupported file extension "${ext}". ` +
35
- `Supported: ${Object.keys(FILE_EXT_TO_MIME).join(" / ")}, or pass a URL / github owner/repo / 抖音 link.`);
36
- }
37
- try {
38
- const key = await uploadToOss(abs);
39
- return { source_key: key };
40
- }
41
- catch (e) {
42
- const sz = (await fs.stat(abs)).size;
43
- if (sz > MAX_BYTES) {
44
- throw new Error(`${path.basename(abs)} 直传 OSS 失败 (${e instanceof Error ? e.message : e}),且文件超过内联回退上限 ${MAX_BYTES} 字节。`);
45
- }
46
- warn(`source: OSS 直传不可用,回退内联上传 (${e instanceof Error ? e.message : e})`);
47
- const buf = await fs.readFile(abs);
48
- return { source: `data:${mime};base64,${buf.toString("base64")}` };
49
- }
50
- }
51
- export function registerExtract(program) {
52
- program
53
- .command("extract <source>")
54
- .description("素材解析: 抖音 / 网页 / GitHub repo / PDF / DOCX / XLSX → 纯文本")
55
- .helpOption("-h, --help", "show help")
56
- .option("-o, --output <file>", "write extracted text to file instead of stdout")
57
- .addHelpText("after", [
58
- "",
59
- "Supported sources (auto-detected by the server):",
60
- " 抖音 link v.douyin.com/... or full 复制口令 paste → ASR transcript",
61
- " web URL https://... article / 知乎 / 公众号 → article body",
62
- " github owner/repo or https://github.com/owner/repo → README + metadata",
63
- " PDF / DOCX / local file path; auto base64-uploaded → extracted text",
64
- " XLSX ",
65
- "",
66
- "Examples (tip: `rf` works wherever you see `reelforge`):",
67
- " # 抖音视频 → ASR 文字",
68
- " rf extract 'https://v.douyin.com/abc/'",
69
- " rf extract '<整段复制此链接 paste>'",
70
- "",
71
- " # 网页文章",
72
- " rf extract 'https://zhuanlan.zhihu.com/p/123456'",
73
- "",
74
- " # GitHub 项目",
75
- " rf extract 'anthropics/claude-code'",
76
- " rf extract 'https://github.com/openai/openai-cookbook'",
77
- "",
78
- " # 本地文档",
79
- " rf extract ./paper.pdf -o /tmp/ref.txt",
80
- " rf extract ./report.docx",
81
- " rf extract ./sales.xlsx",
82
- "",
83
- " # JSON 模式 (拿到 kind + meta + text 完整 payload)",
84
- " rf extract ./paper.pdf --json | jq",
85
- "",
86
- "Common flow (agent-driven):",
87
- " rf extract <source> -o /tmp/ref.txt # 1) parse",
88
- " # → (agent / 你的 LLM 用 /tmp/ref.txt 作素材, 写出脚本到 /tmp/final.txt)",
89
- " rf create --script @/tmp/final.txt -d 60 # 2) render",
90
- "",
91
- "Cost reference:",
92
- " douyin ~$0.005 (ASR cost; depends on video duration)",
93
- " web/github/pdf/docx/xlsx $0 (pure parsing, no LLM/ASR)",
94
- ].join("\n"))
95
- .action(async (source, opts) => {
96
- const resolved = await resolveSource(source);
97
- const r = await post("/api/v1/reference/extract", { ...resolved });
98
- if (opts.output) {
99
- const outAbs = path.resolve(opts.output);
100
- await fs.mkdir(path.dirname(outAbs), { recursive: true });
101
- await fs.writeFile(outAbs, r.text, "utf-8");
102
- success(`Saved → ${outAbs} (${r.kind}, ${r.text.length} chars, ` +
103
- `parse ${(r.duration_ms / 1000).toFixed(1)}s` +
104
- (r.cost_usd > 0 ? `, $${r.cost_usd.toFixed(6)})` : ")"));
105
- return;
106
- }
107
- if (isJson()) {
108
- print(r);
109
- return;
110
- }
111
- print(r.text);
112
- info(`${r.kind} · ${r.text.length} chars · parse ${(r.duration_ms / 1000).toFixed(1)}s` +
113
- (r.cost_usd > 0 ? ` · $${r.cost_usd.toFixed(6)}` : ""));
114
- });
115
- }
@@ -1,129 +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 { 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
- }
@@ -1,70 +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 { downloadTo } from "../utils/download.js";
6
- import { print, table, success, bytes, warn } from "../utils/output.js";
7
- export function registerFiles(program) {
8
- const files = program
9
- .command("files")
10
- .alias("file")
11
- .description("用户文件管理: 上传 / 列表 / 下载 / 删除")
12
- .helpOption("-h, --help", "show help");
13
- files
14
- .command("list")
15
- .description("List all uploaded files")
16
- .helpOption("-h, --help", "show help")
17
- .action(async () => {
18
- const r = await get("/api/v1/files");
19
- table(r.files.map((f) => ({
20
- name: f.name,
21
- size: bytes(f.size_bytes),
22
- modified: f.modified_at,
23
- path: f.relative_path,
24
- })));
25
- });
26
- files
27
- .command("upload <file>")
28
- .description("Upload a local file (saved under data/uploads/, returns its server path)")
29
- .helpOption("-h, --help", "show help")
30
- .action(async (file) => {
31
- const abs = path.resolve(file);
32
- const name = path.basename(abs);
33
- let key = null;
34
- try {
35
- key = await uploadToOss(abs);
36
- }
37
- catch (e) {
38
- warn(`OSS 直传不可用,回退 multipart 上传 (${e instanceof Error ? e.message : e})`);
39
- }
40
- let r;
41
- if (key) {
42
- r = await post("/api/v1/files", { key, filename: name });
43
- }
44
- else {
45
- const buf = fs.readFileSync(abs);
46
- const fileObj = new File([new Uint8Array(buf)], name);
47
- r = await uploadMultipart("/api/v1/files", { file: fileObj });
48
- }
49
- success(`Uploaded → ${r.relative_path}`);
50
- print(r);
51
- });
52
- files
53
- .command("download <relativePath>")
54
- .description("Download a server file to a local path")
55
- .helpOption("-h, --help", "show help")
56
- .requiredOption("-o, --output <file>", "local destination path")
57
- .action(async (rel, opts) => {
58
- await downloadTo(`/api/v1/files/${rel}`, opts.output);
59
- success(`Saved → ${opts.output}`);
60
- });
61
- files
62
- .command("delete <relativePath>")
63
- .alias("rm")
64
- .description("Delete an uploaded file (only data/uploads/ and output/ paths)")
65
- .helpOption("-h, --help", "show help")
66
- .action(async (rel) => {
67
- const r = await del(`/api/v1/files/${rel}`);
68
- print(r);
69
- });
70
- }
@@ -1,12 +0,0 @@
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
- }
@@ -1,44 +0,0 @@
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
- }