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.
Files changed (45) hide show
  1. package/README.md +8 -295
  2. package/package.json +7 -44
  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,160 +0,0 @@
1
- # rf vlog · 宠物赛道剧本(pet)
2
-
3
- > 给**上层 agent**(你,正在帮用户做视频的 Claude Code / Cursor 等)读的工作指南。
4
- > 教你怎么把用户的宠物素材 + 一句话,做成一条第一视角搞笑竖屏 vlog。
5
- >
6
- > **角色边界(先记死)**
7
- > - 引擎只给你两样**你做不到**的能力:`rf assets describe`(给视频「眼睛」,你看不到视频帧)、`rf compose`(渲染 + 配音 + 字幕对齐)。
8
- > - **写旁白 / 脚本是你的活,不是引擎的。** 本剧本只给你「怎么写」的最佳实践,绝不替你写一个字。
9
- > - 字段 / 参数的精确用法一律以 `rf compose -h`、`rf cover -h` 为准。
10
-
11
- ---
12
-
13
- ## Step 0 · 动手前先确认输入(别让用户从 0 摸索)
14
-
15
- 宠物赛道的输入清单 + 默认值。**只问灵魂 2 问,其余用默认,别拿用户看不懂的选项去烦他**:
16
-
17
- | 输入 | 默认 | 要问吗 |
18
- |---|---|---|
19
- | 素材目录 | — | 必给(让用户指一个文件夹) |
20
- | **故事方向 / 今天发生了啥** | — | **灵魂问 1** |
21
- | **音色** | 熊小二(推荐,非强制) | **灵魂问 2**:挑 2-3 个候选给用户选,别让他盲选 |
22
- | 封面 | 自动取一张高光素材 | 用户想要精致封面时,把模板预览图发他挑 |
23
- | 字幕风格 | stroke(描边,短视频爆款风) | 默认,不问 |
24
- | BGM / 分辨率 / 画幅 | 默认 BGM / 1080×1920 / blur-bg | 默认,不问 |
25
-
26
- ---
27
-
28
- ## Step 1 · 看素材(必做,这是你「看到」素材的唯一方式)
29
-
30
- ```bash
31
- rf assets describe ./素材目录/ -o assets.json
32
- ```
33
-
34
- 返回每个素材的客观描述(图片 ~30 字 / 视频 ~80 字含动作与氛围)+ 视频时长。
35
- **这是事实地基。后面写旁白,只能基于这里描述出来的真实动作。**
36
-
37
- ## Step 2 · 你来写旁白 + 给用户过故事板
38
-
39
- 引擎不替你写。按下面《写作最佳实践》自己写,然后用**人话故事板**(不是 JSON)给用户确认。
40
-
41
- ## Step 3 · 渲染
42
-
43
- ```bash
44
- rf compose pet.compose.json -o ./final.mp4
45
- ```
46
-
47
- ---
48
-
49
- ## ★ 写作最佳实践(宠物第一视角搞笑)
50
-
51
- ### 人设 brief
52
- - 第一人称,宠物自己开口。称主人「铲屎的」,自称「本喵 / 本汪」。
53
- - 调性:**痞帅自信 → 怂的反差**。先吹牛,翻车了立刻甩锅装无辜。
54
- - 多内心戏、多吐槽;句子口语、短、有梗。
55
-
56
- ### 🚫 反虚构铁律(最重要,违反 = 废片)
57
- **旁白只能解说画面里 `describe` 出来的真实动作。**
58
- - ✅ 可以编:情绪、内心戏、动机、夸张比喻(画面在踱步 → 写「围着鱼缸转了八圈」可以)。
59
- - ❌ 不准编:画面没发生的物理事件。
60
- - 画面是「伸爪够、够不着」→ 不能写「一爪把鱼捞上来」「鱼归我了」。
61
- - 画面是「趴着不动」→ 不能写「它一跃而起」。
62
- - 不确定画面里到底发生没发生 → 当作没发生,别写。
63
-
64
- ### 句子写法
65
- - 写**完整句子、句末带标点**(。!?)。每句 10~30 字都行。
66
- - **别为字幕节奏手动拆短**——字幕切分是引擎的活(compose 内部按真实配音时间对齐)。
67
- - 每条旁白是一个独立句子,放进对应 scene 的 `narration` 数组里。
68
-
69
- ### 范例(同一段「够鱼」画面)
70
- - ✅ 「想吃,真想吃,可这缸里装的居然是水?本喵金贵爪子凭啥沾水。」
71
- - ❌ 「本喵一爪下去,鱼就归我了。」(画面根本没捞到——虚构)
72
-
73
- ---
74
-
75
- ## 音色怎么选(不强制熊小二)
76
-
77
- 熊小二只是**推荐默认**(痞帅搞笑,贴宠物号),用户想用别的随时换。
78
-
79
- 查全部音色:
80
-
81
- ```bash
82
- rf tts voices --provider relayx --model vox/index-tts-2
83
- ```
84
-
85
- 按风格筛,例如想要解说腔:
86
-
87
- ```bash
88
- rf tts voices --provider relayx --model vox/index-tts-2 | grep 解说
89
- ```
90
-
91
- **别让用户从一长串里盲选**:你按他要的调性挑 2-3 个候选报给他,他点一个。
92
- 选定后填进 spec 的 `audio.tts_voice`(就是音色名,如 `"熊小二"`)。
93
-
94
- ---
95
-
96
- ## 封面怎么选
97
-
98
- compose 的 `cover.image` 只收**一张图**。两种做法:
99
-
100
- **A. 省事**:直接挑一张高光素材当封面 → `"cover": { "image": "高光帧.jpg" }`。
101
-
102
- **B. 套模板(精致)**:引擎内置多个封面模板。先看长啥样——每个模板都带一条预览图链接:
103
-
104
- ```bash
105
- rf cover templates # 人读表格 + 预览链接
106
- rf cover templates --json # agent 消费,含每个模板的 preview_url
107
- ```
108
-
109
- **把这些预览裸链接发给用户,让他看图挑**(别凭模板名猜)。用户选定后渲封面:
110
-
111
- ```bash
112
- rf cover -i 高光帧.jpg -t "橘子大战鱼缸" --template <模板id> -o cover.png
113
- ```
114
-
115
- (`--subtitle` / `--badge` / `--param` 等精确用法见 `rf cover -h`。)
116
- 然后填进 spec:`"cover": { "image": "cover.png" }`。
117
-
118
- ---
119
-
120
- ## compose.v3 spec 模板(pet 默认值已填好)
121
-
122
- ```json
123
- {
124
- "experimental": "compose.v3",
125
- "output": { "width": 1080, "height": 1920, "fps": 30, "layout": "blur-bg" },
126
- "audio": { "tts_model": "vox/index-tts-2", "tts_voice": "熊小二", "bgm_volume": 0.12 },
127
- "subtitle_style": "stroke",
128
- "title": "可选,片头标题",
129
- "cover": { "image": "封面.jpg" },
130
- "scenes": [
131
- { "video": "clip1.mp4", "muted": true, "narration": ["句1。", "句2。"] },
132
- { "image": "photo1.jpg", "motion": "zoom-in", "narration": ["句1。"] }
133
- ]
134
- }
135
- ```
136
-
137
- - **横屏视频用 `layout: "blur-bg"`**:视频缩放居中 + 模糊垫底,绝不裁掉主角。
138
- - spec 里**没有任何秒数**;时间由引擎量真实配音派生。
139
- - 旁白尽量 ≤ 视频时长,省掉片头黑卡;真比视频长时引擎会自动前置一张标题卡。
140
-
141
- ### 素材 & 画幅(短视频节奏)
142
- - **优先视频片段,尽量少用静图**:静图即便加 Ken Burns 也会拖慢节奏,不合短视频快感。
143
- - 竖拍照片用 `fit:"cover"` 铺满(别缩在中间显小);横屏视频要么 `layout:"blur-bg"`,要么先烤成 9:16 再走 `layout:"full"` 统一全屏。
144
- - 完整的通用画幅 / 朝向 / 音色 / 封面最佳实践见 `rf vlog general`。
145
-
146
- ### ⚠️ 照片朝向
147
- 手机竖拍照片常带旋转标记,直接喂可能是横躺的。喂前先摆正、抽帧确认方向:
148
-
149
- ```bash
150
- ffmpeg -noautorotate -i in.jpg -vf "transpose=1" up.jpg
151
- ```
152
-
153
- ---
154
-
155
- ## 提交前自查
156
- - [ ] 跑过 `rf assets describe`?
157
- - [ ] 旁白每一句都对得上画面真实动作?(过一遍反虚构铁律)
158
- - [ ] 给用户看的是**人话故事板**,不是裸 JSON?
159
- - [ ] 照片朝向已摆正?
160
- - [ ] output 1080×1920、横屏视频 blur-bg?
package/dist/index.js DELETED
@@ -1,183 +0,0 @@
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 { registerDub } from "./commands/dub.js";
38
- import { registerClip } from "./commands/clip.js";
39
- import { registerVlog } from "./commands/vlog.js";
40
- import { error as logError } from "./utils/output.js";
41
- import { ApiCallError } from "./client.js";
42
- const program = new Command();
43
- program
44
- .name("reelforge")
45
- .description([
46
- "ReelForge — AI 视频生成与媒体处理 CLI。一句话主题或自己的脚本 → 抖音/TikTok/视频号竖屏 MP4。",
47
- "",
48
- "本 CLI 是工具型原子能力集合 — 解析 / 文字 / 语音 / 视觉 / 媒体处理 / 渲染六层全覆盖,",
49
- "做的是确定性原子操作。脚本写作、多轮创作迭代等创意性工作请在上层(自己脑子或 Claude Code)做完再来调命令。",
50
- "",
51
- "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
52
- "做视频前的决策指南",
53
- "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
54
- "你手头有什么 用哪个命令",
55
- " 一句话主题 / 想法 `rf create -t \"<topic>\"`",
56
- " (AI 全权: 写文案 + 配音 + 出视觉)",
57
- " 自己写好的文案脚本 `rf create --script @file.txt`",
58
- " (AI 配视觉 / 配音 / 字幕)",
59
- " 现成视频/图片素材 + 仅要拼接 + BGM `rf compositions concat ...`",
60
- " (底层 ffmpeg, 不做 AI)",
61
- " 现成素材 + 想 AI 加文案配音 分两步: 先 `rf create --script` 出 AI",
62
- " 那段,再 `rf compositions concat`",
63
- " 把 AI 段跟现成素材拼起来",
64
- " 别人的视频/链接做参考 先 `rf extract <url>` 拿文案 →",
65
- " 整理脚本 → `rf create --script ...`",
66
- " 完全自定义每帧 `rf compose <spec.json>` (EXPERIMENTAL)",
67
- " 想看『你们有哪些风格 / 封面?』 `rf styles list --json` /",
68
- " `rf cover templates --json`",
69
- " (输出含 preview URL,可用 markdown ",
70
- " `![](url)` 在聊天界面渲染成缩略图)",
71
- "",
72
- "→ 如果不确定哪种方式适合,可以从下面 4 个常见场景里选一个:",
73
- " (A) AI 写全文案+场景, 我只挑风格 → rf create -t",
74
- " (B) 我有文案, AI 配视觉/配音 → rf create --script",
75
- " (C) 我有素材, 只要拼接+BGM → rf compositions concat",
76
- " (D) 我有素材+脚本, 想 AI 配视觉到我的素材上 → 暂不支持,分 (B)+(C) 两段",
77
- "",
78
- " 默认服务器: 托管服务(无需配置)",
79
- " 自定义服务器: --server <url> 或 REELFORGE_SERVER=<url>",
80
- " 短别名: `rf` 等价于 `reelforge`(如 `rf create ...`)",
81
- "",
82
- "任意子命令加 --help 看用法。例: `rf cover prepend --help` / `rf media probe --help`。",
83
- ].join("\n"))
84
- .version(pkgVersion, "-v, --version", "显示 CLI 版本")
85
- .helpOption("-h, --help", "显示帮助")
86
- .addHelpCommand("help [command]", "查看某个子命令的帮助")
87
- .showHelpAfterError("(用 `reelforge <command> --help` 查看用法)")
88
- .showSuggestionAfterError(true)
89
- .option("-s, --server <url>", "服务器地址 (默认托管服务, 或环境变量 $REELFORGE_SERVER, 或 login 保存的)")
90
- .option("-k, --api-key <key>", "API key (覆盖 $REELFORGE_API_KEY 和 login 保存的 key)")
91
- .option("--json", "输出原始 JSON 而不是格式化文本")
92
- .option("--quiet", "静默模式 - 不打 stderr 的提示信息")
93
- .hook("preAction", (thisCommand) => {
94
- const opts = thisCommand.optsWithGlobals();
95
- if (opts.server)
96
- setServer(opts.server);
97
- if (opts.apiKey)
98
- setApiKey(opts.apiKey);
99
- setOutputOptions({ json: !!opts.json, quiet: !!opts.quiet });
100
- });
101
- program.addHelpText("after", [
102
- "",
103
- "示例:",
104
- " rf create '为什么我们还没有找到外星文明?' 自动保存到 ./<title>-<id>.mp4",
105
- " rf create '...' -o ./videos/space.mp4 指定输出路径",
106
- " rf create --script @story.txt 用自己的脚本生成",
107
- " rf llm chat -p '帮我解释一下反脆弱' 直通 LLM 单轮对话",
108
- " rf tts relayx --text 'hello' -o out.mp3 生成语音",
109
- " rf images generate -p '一只橘猫' -o cat.png 生成图片",
110
- " rf extract <PDF|抖音|URL|github> 解析素材为文本",
111
- " rf fetch '<抖音分享链接>' -t 下载抖音 MP4 + ASR 转写",
112
- " rf cover -i scene.png -t '标题' -o cover.png 单图渲染封面",
113
- " rf assets describe ./photo.jpg 图片 / 视频 → 一句话描述",
114
- " rf dub ./clip.mp4 一个视频 → 自动解说配音+字幕成片",
115
- " rf history list 看之前生成的视频",
116
- " rf tasks list --status running 看进行中的任务",
117
- "",
118
- "做 vlog?(用户拍的素材 → 视频)先看赛道剧本:",
119
- " rf vlog 列出可用赛道",
120
- " rf vlog pet 宠物第一视角搞笑 SOP",
121
- " rf vlog general 通用 SOP(没专门赛道就用它兜底)",
122
- " → 拿到 SOP 后照着走:rf assets describe(看素材) → 写旁白 → rf compose(渲染)",
123
- ].join("\n"));
124
- registerAuth(program);
125
- registerCreate(program);
126
- registerRender(program);
127
- registerRegen(program);
128
- registerLlm(program);
129
- registerModels(program);
130
- registerTts(program);
131
- registerImages(program);
132
- registerContent(program);
133
- registerAudio(program);
134
- registerSubtitles(program);
135
- registerCompositions(program);
136
- registerPipelines(program);
137
- registerBgm(program);
138
- registerFiles(program);
139
- registerTasks(program);
140
- registerHistory(program);
141
- registerConfig(program);
142
- registerHealth(program);
143
- registerPlatform(program);
144
- registerFetch(program);
145
- registerCover(program);
146
- registerStyles(program);
147
- registerAssets(program);
148
- registerExtract(program);
149
- registerMedia(program);
150
- registerScript(program);
151
- registerCompose(program);
152
- registerDub(program);
153
- registerClip(program);
154
- registerVlog(program);
155
- async function main() {
156
- if (process.argv.length <= 2) {
157
- program.outputHelp();
158
- process.exit(0);
159
- }
160
- try {
161
- await program.parseAsync(process.argv);
162
- process.exit(0);
163
- }
164
- catch (err) {
165
- if (err instanceof ApiCallError) {
166
- const hint = err.status === 401
167
- ? " → Run `reelforge login <api_key>` first, or pass --api-key <key>."
168
- : err.status === 402
169
- ? " → Insufficient balance. Top up via admin."
170
- : err.status === 403
171
- ? " → Key revoked or account disabled. Contact admin."
172
- : err.status === 429
173
- ? " → Rate-limited by jarvis-auth. Try again shortly."
174
- : "";
175
- logError(`${err.status ? `[${err.status}] ` : ""}${err.message}${hint ? `\n${hint}` : ""}`);
176
- }
177
- else {
178
- logError(err instanceof Error ? err.message : String(err));
179
- }
180
- process.exit(1);
181
- }
182
- }
183
- main();
@@ -1,37 +0,0 @@
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
- }
@@ -1,13 +0,0 @@
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
- }
@@ -1,102 +0,0 @@
1
- import fs from "node:fs/promises";
2
- import fsSync from "node:fs";
3
- import path from "node:path";
4
- import { uploadToOss } from "./oss-upload.js";
5
- export const EXT_TO_MIME = {
6
- ".png": "image/png",
7
- ".jpg": "image/jpeg",
8
- ".jpeg": "image/jpeg",
9
- ".webp": "image/webp",
10
- ".mp3": "audio/mpeg",
11
- ".wav": "audio/wav",
12
- ".m4a": "audio/mp4",
13
- ".aac": "audio/aac",
14
- ".ogg": "audio/ogg",
15
- ".flac": "audio/flac",
16
- ".mp4": "video/mp4",
17
- ".mov": "video/quicktime",
18
- ".webm": "video/webm",
19
- ".mkv": "video/x-matroska",
20
- };
21
- export async function resolveFileInput(input, opts) {
22
- const t = input.trim();
23
- if (!t)
24
- throw new Error(`${opts.flagName}: empty value`);
25
- if (/^https?:\/\//i.test(t))
26
- return { url: t, kind: "http-url" };
27
- if (t.startsWith("data:"))
28
- return { url: t, kind: "data-uri" };
29
- const abs = path.resolve(t);
30
- if (!fsSync.existsSync(abs)) {
31
- throw new Error(`${opts.flagName}: local file not found: ${abs}\n` +
32
- ` (Acceptable forms: local path / https:// URL / data: URI. ` +
33
- `Server-side paths like 'data/uploads/...' do NOT work — CLI commands ` +
34
- `auto-upload local files, so just pass the local path directly.)`);
35
- }
36
- const ext = path.extname(abs).toLowerCase();
37
- const map = opts.extToMime ?? EXT_TO_MIME;
38
- const mime = map[ext];
39
- if (!mime) {
40
- throw new Error(`${opts.flagName}: unsupported file extension "${ext}" (path: ${abs}). ` +
41
- `Supported: ${Object.keys(map).join(" / ")}`);
42
- }
43
- const buf = await fs.readFile(abs);
44
- const url = `data:${mime};base64,${buf.toString("base64")}`;
45
- return { url, bytes: buf.byteLength, kind: "local-file" };
46
- }
47
- export async function resolveFileInputs(inputs, opts) {
48
- const resolved = await Promise.all(inputs.map((input) => resolveFileInput(input, opts)));
49
- const totalBytes = resolved.reduce((n, r) => n + (r.bytes ?? 0), 0);
50
- return { resolved, totalBytes };
51
- }
52
- export function fmtSize(bytes) {
53
- if (!bytes)
54
- return "0";
55
- if (bytes < 1024)
56
- return `${bytes} B`;
57
- if (bytes < 1024 * 1024)
58
- return `${(bytes / 1024).toFixed(1)} KB`;
59
- return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
60
- }
61
- export async function resolveUploadInput(input, opts) {
62
- const t = input.trim();
63
- if (!t)
64
- throw new Error(`${opts.flagName}: empty value`);
65
- if (/^https?:\/\//i.test(t))
66
- return { url: t, kind: "http-url" };
67
- if (t.startsWith("data:"))
68
- return { url: t, kind: "data-uri" };
69
- const abs = path.resolve(t);
70
- if (!fsSync.existsSync(abs)) {
71
- throw new Error(`${opts.flagName}: local file not found: ${abs}\n` +
72
- ` (Acceptable forms: local path / https:// URL / data: URI. ` +
73
- `Server-side paths like 'data/uploads/...' do NOT work — CLI commands ` +
74
- `auto-upload local files, so just pass the local path directly.)`);
75
- }
76
- const ext = path.extname(abs).toLowerCase();
77
- const map = opts.extToMime ?? EXT_TO_MIME;
78
- const mime = map[ext];
79
- if (!mime) {
80
- throw new Error(`${opts.flagName}: unsupported file extension "${ext}" (path: ${abs}). ` +
81
- `Supported: ${Object.keys(map).join(" / ")}`);
82
- }
83
- const bytes = (await fs.stat(abs)).size;
84
- try {
85
- const key = await uploadToOss(abs);
86
- return { key, bytes, kind: "oss-key" };
87
- }
88
- catch (e) {
89
- const reason = e instanceof Error ? e.message : String(e);
90
- opts.onFallback?.(opts.flagName, reason);
91
- const buf = await fs.readFile(abs);
92
- return { url: `data:${mime};base64,${buf.toString("base64")}`, bytes, kind: "local-file-inline" };
93
- }
94
- }
95
- export async function resolveUploadInputs(inputs, opts) {
96
- const resolved = [];
97
- for (const input of inputs) {
98
- resolved.push(await resolveUploadInput(input, opts));
99
- }
100
- const totalBytes = resolved.reduce((n, r) => n + (r.bytes ?? 0), 0);
101
- return { resolved, totalBytes };
102
- }
@@ -1,44 +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
- const EXT_TO_MIME = {
6
- ".mp4": "video/mp4",
7
- ".mov": "video/quicktime",
8
- ".webm": "video/webm",
9
- ".mkv": "video/x-matroska",
10
- ".m4v": "video/mp4",
11
- ".jpg": "image/jpeg",
12
- ".jpeg": "image/jpeg",
13
- ".png": "image/png",
14
- ".webp": "image/webp",
15
- ".mp3": "audio/mpeg",
16
- ".wav": "audio/wav",
17
- ".m4a": "audio/mp4",
18
- ".ogg": "audio/ogg",
19
- ".flac": "audio/flac",
20
- ".pdf": "application/pdf",
21
- };
22
- export function mimeForFile(filePath) {
23
- return EXT_TO_MIME[path.extname(filePath).toLowerCase()] || "application/octet-stream";
24
- }
25
- export async function uploadToOss(filePath) {
26
- if (!fsSync.existsSync(filePath))
27
- throw new Error(`file not found: ${filePath}`);
28
- const ext = path.extname(filePath).toLowerCase();
29
- const contentType = mimeForFile(filePath);
30
- const signed = await post("/api/v1/uploads/sign", { ext, content_type: contentType });
31
- if (!signed?.put_url || !signed?.key)
32
- throw new Error("uploads/sign returned no put_url/key");
33
- const bytes = await fs.readFile(filePath);
34
- const res = await fetch(signed.put_url, {
35
- method: "PUT",
36
- headers: { "Content-Type": contentType },
37
- body: bytes,
38
- });
39
- if (!res.ok) {
40
- const detail = await res.text().catch(() => "");
41
- throw new Error(`OSS PUT failed: ${res.status} ${res.statusText} ${detail.slice(0, 200)}`);
42
- }
43
- return signed.key;
44
- }
@@ -1,91 +0,0 @@
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
- }
@@ -1,40 +0,0 @@
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
- }