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.
Files changed (39) hide show
  1. package/README.md +295 -0
  2. package/bin/reelforge.js +8 -0
  3. package/dist/client.js +120 -0
  4. package/dist/commands/assets-workflow-text.js +22 -0
  5. package/dist/commands/assets.js +231 -0
  6. package/dist/commands/audio.js +73 -0
  7. package/dist/commands/auth.js +170 -0
  8. package/dist/commands/bgm.js +45 -0
  9. package/dist/commands/compose.js +293 -0
  10. package/dist/commands/compositions.js +143 -0
  11. package/dist/commands/config.js +62 -0
  12. package/dist/commands/content.js +66 -0
  13. package/dist/commands/cover.js +397 -0
  14. package/dist/commands/create.js +629 -0
  15. package/dist/commands/extract.js +102 -0
  16. package/dist/commands/fetch.js +129 -0
  17. package/dist/commands/files.js +56 -0
  18. package/dist/commands/health.js +12 -0
  19. package/dist/commands/history.js +44 -0
  20. package/dist/commands/images.js +88 -0
  21. package/dist/commands/llm.js +67 -0
  22. package/dist/commands/media.js +128 -0
  23. package/dist/commands/models.js +36 -0
  24. package/dist/commands/pipelines.js +142 -0
  25. package/dist/commands/platform.js +218 -0
  26. package/dist/commands/regen.js +134 -0
  27. package/dist/commands/render.js +82 -0
  28. package/dist/commands/script.js +128 -0
  29. package/dist/commands/styles.js +113 -0
  30. package/dist/commands/subtitles.js +246 -0
  31. package/dist/commands/tasks.js +59 -0
  32. package/dist/commands/tts.js +134 -0
  33. package/dist/index.js +173 -0
  34. package/dist/utils/config-file.js +37 -0
  35. package/dist/utils/download.js +13 -0
  36. package/dist/utils/file-upload.js +59 -0
  37. package/dist/utils/output.js +91 -0
  38. package/dist/utils/task-waiter.js +40 -0
  39. package/package.json +44 -0
@@ -0,0 +1,128 @@
1
+ import fs from "node:fs/promises";
2
+ import { info, isJson, print } from "../utils/output.js";
3
+ const CHARS_PER_SEC_ZH = 5;
4
+ const WORDS_PER_SEC_EN = 2.7;
5
+ const TTS_PRICES_USD_PER_CHAR = {
6
+ "vox/index-tts-2": 1.575 / 1e6,
7
+ "edge": 0,
8
+ };
9
+ function countCjk(s) {
10
+ const re = /[一-鿿㐀-䶿豈-﫿 -〿＀-￯]/gu;
11
+ return (s.match(re) || []).length;
12
+ }
13
+ function countAscii(s) {
14
+ const re = /[A-Za-z0-9]/g;
15
+ return (s.match(re) || []).length;
16
+ }
17
+ function countWords(s) {
18
+ return s.split(/\s+/).filter(Boolean).length;
19
+ }
20
+ function detectLang(text) {
21
+ const cjk = countCjk(text);
22
+ const ascii = countAscii(text);
23
+ return cjk * 5 >= ascii ? "zh" : "en";
24
+ }
25
+ function estimate(text, langOverride) {
26
+ const lang = langOverride ?? detectLang(text);
27
+ const cjk = countCjk(text);
28
+ const ascii = countAscii(text);
29
+ const words = countWords(text);
30
+ const chars = cjk + ascii;
31
+ const seconds = lang === "zh"
32
+ ? chars / CHARS_PER_SEC_ZH
33
+ : words > 0 ? words / WORDS_PER_SEC_EN : chars / 12;
34
+ const billedChars = text.length;
35
+ const tts = {};
36
+ for (const [model, pricePerChar] of Object.entries(TTS_PRICES_USD_PER_CHAR)) {
37
+ tts[model] = Number((billedChars * pricePerChar).toFixed(6));
38
+ }
39
+ const totalSec = Math.round(seconds);
40
+ const min = Math.floor(totalSec / 60);
41
+ const sec = totalSec % 60;
42
+ const hhmmss = `${min}:${String(sec).padStart(2, "0")}`;
43
+ const notes = lang === "zh"
44
+ ? `中文口播按 ${CHARS_PER_SEC_ZH} 字 / 秒估算 (普通播报速度)`
45
+ : `English narration estimated at ${WORDS_PER_SEC_EN} words / sec`;
46
+ return {
47
+ lang,
48
+ chars,
49
+ ascii_chars: ascii,
50
+ cjk_chars: cjk,
51
+ words,
52
+ seconds: Number(seconds.toFixed(2)),
53
+ hhmmss,
54
+ tts_cost_usd_by_model: tts,
55
+ notes,
56
+ };
57
+ }
58
+ async function loadText(input) {
59
+ if (input.startsWith("@")) {
60
+ const file = input.slice(1);
61
+ return (await fs.readFile(file, "utf-8")).trim();
62
+ }
63
+ return input;
64
+ }
65
+ export function registerScript(program) {
66
+ const scriptCmd = program
67
+ .command("script")
68
+ .description("脚本相关原子能力 (零 LLM / 零网络的本地工具)。")
69
+ .helpOption("-h, --help", "show help");
70
+ scriptCmd
71
+ .command("estimate <text-or-@file>")
72
+ .description("估算脚本字数 / 口播时长 / TTS 成本。零网络, 零调用 — 在送 TTS 或 scene-plan 之前快速判断 \"这段念出来多久 / 多少钱\"。")
73
+ .helpOption("-h, --help", "show help")
74
+ .option("--lang <zh|en>", "强制语言 (默认按 CJK 比例自动判断)")
75
+ .addHelpText("after", [
76
+ "",
77
+ "Examples:",
78
+ " # 字符串直传",
79
+ " rf script estimate '为什么我们还没有找到外星文明?我们在听, 也在被听。'",
80
+ "",
81
+ " # 从文件读 (适合长脚本)",
82
+ " rf script estimate @./draft.txt",
83
+ "",
84
+ " # JSON 模式 (agent 解析)",
85
+ " rf script estimate @./draft.txt --json",
86
+ "",
87
+ " # 强制按英文估时",
88
+ " rf script estimate @./english.txt --lang en",
89
+ "",
90
+ "速率假设:",
91
+ " 中文: 5 字 / 秒 (普通抖音口播速度)",
92
+ " 英文: 2.7 words / sec (~ 160 wpm)",
93
+ "",
94
+ "TTS 成本估算 (按云端 list price):",
95
+ " 云端 TTS: $1.575 / 1M chars",
96
+ " 本地 TTS: $0",
97
+ "",
98
+ "成本: $0 (纯本地计算, 无服务端调用)",
99
+ ].join("\n"))
100
+ .action(async (input, opts) => {
101
+ const text = await loadText(input);
102
+ if (!text)
103
+ throw new Error("script is empty");
104
+ let langOverride;
105
+ if (opts.lang !== undefined) {
106
+ if (opts.lang !== "zh" && opts.lang !== "en") {
107
+ throw new Error(`--lang must be zh or en (got: ${opts.lang})`);
108
+ }
109
+ langOverride = opts.lang;
110
+ }
111
+ const r = estimate(text, langOverride);
112
+ if (isJson()) {
113
+ print(r);
114
+ return;
115
+ }
116
+ info(`lang: ${r.lang}`);
117
+ info(`chars: ${r.chars} (CJK ${r.cjk_chars} + ASCII letters/digits ${r.ascii_chars})`);
118
+ if (r.lang === "en")
119
+ info(`words: ${r.words}`);
120
+ info(`narration: ${r.seconds.toFixed(1)}s (${r.hhmmss})`);
121
+ info("TTS cost estimate:");
122
+ for (const [model, cost] of Object.entries(r.tts_cost_usd_by_model)) {
123
+ const costStr = cost === 0 ? "$0 (free)" : `$${cost.toFixed(6)}`;
124
+ info(` ${model.padEnd(20)} ${costStr}`);
125
+ }
126
+ info(`note: ${r.notes}`);
127
+ });
128
+ }
@@ -0,0 +1,113 @@
1
+ import { get } from "../client.js";
2
+ import { isJson, print } from "../utils/output.js";
3
+ import kleur from "kleur";
4
+ function displayWidth(s) {
5
+ let w = 0;
6
+ for (const c of s)
7
+ w += c.charCodeAt(0) > 0x7f ? 2 : 1;
8
+ return w;
9
+ }
10
+ function padDisplay(s, width) {
11
+ const pad = Math.max(0, width - displayWidth(s));
12
+ return s + " ".repeat(pad);
13
+ }
14
+ function renderTable(presets) {
15
+ if (!presets.length) {
16
+ process.stdout.write("(empty)\n");
17
+ return;
18
+ }
19
+ const idW = Math.max(2, ...presets.map((p) => p.id.length));
20
+ const labelW = Math.max(5, ...presets.map((p) => displayWidth(p.label)));
21
+ const sceneW = Math.max(5, ...presets.map((p) => displayWidth(p.scene)));
22
+ const hasPreview = presets.some((p) => p.preview_url);
23
+ const header = hasPreview
24
+ ? `${padDisplay("id", idW)} ${padDisplay("label", labelW)} ${padDisplay("scene", sceneW)} preview`
25
+ : `${padDisplay("id", idW)} ${padDisplay("label", labelW)} ${padDisplay("scene", sceneW)} prefix`;
26
+ process.stdout.write(kleur.bold(header) + "\n");
27
+ process.stdout.write(`${"-".repeat(idW)} ${"-".repeat(labelW)} ${"-".repeat(sceneW)} ${"-".repeat(7)}\n`);
28
+ for (const p of presets) {
29
+ const last = hasPreview ? p.preview_url || "" : p.prefix;
30
+ process.stdout.write(`${padDisplay(p.id, idW)} ${padDisplay(p.label, labelW)} ${padDisplay(p.scene, sceneW)} ${last}\n`);
31
+ }
32
+ }
33
+ function renderDetail(p, preview) {
34
+ process.stdout.write(`\n● ${p.id} ${p.label}\n`);
35
+ if (p.preview_url) {
36
+ process.stdout.write(` 预览 (直接浏览器打开): ${p.preview_url}\n`);
37
+ }
38
+ if (preview) {
39
+ process.stdout.write(` 样图基线 → subject="${preview.subject}"\n`);
40
+ if (preview.model || preview.size) {
41
+ process.stdout.write(` model=${preview.model || "?"} · size=${preview.size || "?"}\n`);
42
+ }
43
+ }
44
+ process.stdout.write(` scene: ${p.scene}\n`);
45
+ process.stdout.write(` prefix: ${p.prefix}\n`);
46
+ }
47
+ export function registerStyles(program) {
48
+ const group = program
49
+ .command("styles")
50
+ .description("图像风格预设清单 (供 --style / body.style 使用)")
51
+ .helpOption("-h, --help", "show help");
52
+ group
53
+ .command("list")
54
+ .description("List all image style presets from the server (with preview URLs).")
55
+ .helpOption("-h, --help", "show help")
56
+ .option("--id <styleId>", "只看某个风格的详细信息 (id, label, scene, prefix, preview_url)")
57
+ .option("--json", "JSON 输出 (默认人读表格); 适合 agent / 脚本消费")
58
+ .addHelpText("after", [
59
+ "",
60
+ "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
61
+ "AGENT 帮用户选风格的标准流程 (LLM 必读)",
62
+ "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
63
+ " 当用户没明确指定 --style,或说『我不知道选哪个』时,不要替用户拍板,",
64
+ " 也不要按 prefix 文字给用户脑补描述 — 直接走这条路径:",
65
+ "",
66
+ " Step 1. 拉 catalog (含 preview_url):",
67
+ " rf styles list --json",
68
+ "",
69
+ " Step 2. 把 17 张 preview_url 列表丢给用户 (markdown 图片格式效果最好,",
70
+ " 用户在你的聊天界面里能直接看到缩略图):",
71
+ " ![cinematic](https://rf-cdn.oss-cn-hangzhou.aliyuncs.com/image-style-samples/cinematic.png)",
72
+ " ![watercolor](https://rf-cdn.oss-cn-hangzhou.aliyuncs.com/image-style-samples/watercolor.png)",
73
+ " ... (其余 15 个同样格式)",
74
+ "",
75
+ " Step 3. 用户照图选,把 id 喂给 `rf create --style <id>`.",
76
+ "",
77
+ " 17 张 preview 都是同一个 subject (一座黄昏悬崖上的灯塔) 在不同风格下的",
78
+ " 渲染样本,只有风格变量在动 — 用户看一眼立刻知道每个风格实际长啥样,",
79
+ " 不需要现场跑图.",
80
+ "",
81
+ "Use the id with `rf create --style <id>` or write it into a recipe:",
82
+ " rf create --script @x.txt --style flat",
83
+ ' { "voice_id": "熊小二", "style": "flat" } # recipe',
84
+ "",
85
+ "Examples:",
86
+ " rf styles list # 全部 17 个风格 + preview URL 表格",
87
+ " rf styles list --id watercolor # 单看 watercolor 的详情 (含 prefix)",
88
+ " rf styles list --json # JSON 输出 (agent 必跑这条)",
89
+ ].join("\n"))
90
+ .action(async (opts) => {
91
+ const data = await get("/api/v1/styles");
92
+ const presets = Array.isArray(data.styles) ? data.styles : [];
93
+ const filtered = opts.id ? presets.filter((s) => s.id === opts.id) : presets;
94
+ if (opts.id && filtered.length === 0) {
95
+ throw new Error(`unknown style id "${opts.id}". Known: ${presets.map((s) => s.id).join(", ")}`);
96
+ }
97
+ if (opts.json || isJson()) {
98
+ const payload = opts.id ? filtered[0] : { preview: data.preview, styles: filtered };
99
+ print(payload);
100
+ return;
101
+ }
102
+ if (opts.id) {
103
+ renderDetail(filtered[0], data.preview);
104
+ }
105
+ else {
106
+ if (data.preview?.subject) {
107
+ process.stdout.write(`\n样图基线 (preview_url 都是按这个 subject 渲的):\n ${data.preview.subject}\n\n`);
108
+ }
109
+ renderTable(filtered);
110
+ process.stdout.write(`\n(用 \`rf styles list --id <id>\` 看单个风格的详情,或 --json 拿结构化输出)\n\n`);
111
+ }
112
+ });
113
+ }
@@ -0,0 +1,246 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { getApiKey, getServer, post } from "../client.js";
4
+ import { info, print, success } from "../utils/output.js";
5
+ export function registerSubtitles(program) {
6
+ const sub = program
7
+ .command("subtitles")
8
+ .alias("subtitle")
9
+ .description("字幕原子能力: split (确定性 token-atomic 分段, 单条/批量) + translate (双语第二行) + ledger (历史切分质量数据)")
10
+ .helpOption("-h, --help", "show help");
11
+ sub
12
+ .command("split")
13
+ .description("把文本切成字幕分段 (分段). 单条 (--text) 或批量分镜列表 (--scenes). 与线上视频 pipeline 同一个切分器。")
14
+ .helpOption("-h, --help", "show help")
15
+ .option("-t, --text <text>", "单条: 要切分的文本. 用 @file 从磁盘读。")
16
+ .option("-S, --scenes <input>", "批量: 分镜列表. JSON 字符串数组 / [{text}] / storyboard.json ({scenes:[{text}]}). 用 @file。")
17
+ .option("--bilingual", "双语模式: min/max 缺省时用 6/14 (而非单语 10/28), 与线上双语视频一致")
18
+ .option("--min <N>", "最小分段字数 (缺省: 单语 10 / 双语 6)", (v) => parseInt(v, 10))
19
+ .option("--max <N>", "最大分段字数 (缺省: 单语 28 / 双语 14 ≈ 2/1 视觉行)", (v) => parseInt(v, 10))
20
+ .addHelpText("after", [
21
+ "",
22
+ "Segment vs line:",
23
+ " 一个 segment 是一条上屏字幕单元 (一张 PNG / 一个时间窗), 不是视觉行。",
24
+ " 视觉换行是模板的事 (默认 clamp 2 行)。",
25
+ "",
26
+ "Examples:",
27
+ " # 单条",
28
+ " rf subtitles split -t '雨水缓缓滑落在玻璃窗上,像是无声的泪珠。'",
29
+ "",
30
+ " # 批量: 一个分镜列表 → 二级 list (每个分镜一组字幕分段)",
31
+ " rf subtitles split --scenes '[\"第一镜旁白\",\"第二镜旁白\"]' --json",
32
+ "",
33
+ " # 直接喂真实 storyboard.json (取 scenes[].text), 双语参数验证线上切分",
34
+ " rf subtitles split --scenes @storyboard.json --bilingual --json",
35
+ ].join("\n"))
36
+ .action(async (opts) => {
37
+ const body = {};
38
+ if (opts.min !== undefined)
39
+ body.min_chars = opts.min;
40
+ if (opts.max !== undefined)
41
+ body.max_chars = opts.max;
42
+ if (opts.bilingual)
43
+ body.bilingual = true;
44
+ if (opts.scenes) {
45
+ body.scenes = await parseScenesInput(await readInput(opts.scenes));
46
+ const r = await post("/api/v1/subtitles/split", body);
47
+ print({
48
+ scene_count: r.scene_count,
49
+ scenes: r.scenes.map((s, i) => ({
50
+ scene: i,
51
+ count: s.count,
52
+ segments: s.segments.map((g) => g.text),
53
+ })),
54
+ });
55
+ return;
56
+ }
57
+ if (!opts.text)
58
+ throw new Error("提供 --text (单条) 或 --scenes (批量) 其一");
59
+ let text = opts.text;
60
+ if (text.startsWith("@"))
61
+ text = (await fs.readFile(text.slice(1), "utf-8")).trim();
62
+ body.text = text;
63
+ const r = await post("/api/v1/subtitles/split", body);
64
+ print({ count: r.count, segments: r.segments });
65
+ });
66
+ sub
67
+ .command("translate")
68
+ .description("字幕分段翻译 (双语字幕第二行). 接受一个分段数组 + 目标语言, 返回一一对应的译文。")
69
+ .helpOption("-h, --help", "show help")
70
+ .option("-s, --segments <input>", "字幕分段数组. 支持: @file (JSON 数组 或每行一段) / JSON 字符串 / 多段用 \\n 分隔")
71
+ .option("--from-split <input>", "直接接 rf subtitles split 的 JSON 输出 (file 或 stdin) — 自动提取 segments 字段")
72
+ .option("-l, --lang <code>", "目标语言, 如 en / ja / English / 日本語 (必填)")
73
+ .option("--master <text>", "完整旁白脚本 (上下文, 帮助术语一致). @file 读文件。可选, 缺省用 segments 拼接")
74
+ .option("--llm-model <id>", "覆盖默认 LLM 模型")
75
+ .addHelpText("after", [
76
+ "",
77
+ "Input 三种形式:",
78
+ " --segments @file.json 分段 JSON 数组",
79
+ " --segments @lines.txt 每行一段",
80
+ " --from-split @split.json rf subtitles split 的输出直接喂进来",
81
+ "",
82
+ "Examples:",
83
+ " # 直接给分段数组",
84
+ " rf subtitles translate -s '[\"第一段\",\"第二段\"]' -l en",
85
+ "",
86
+ " # 链式: split → translate (agent 工作流)",
87
+ " rf subtitles split -t @./script.txt --json > /tmp/segs.json",
88
+ " rf subtitles translate --from-split @/tmp/segs.json -l en",
89
+ "",
90
+ " # 加完整 master 让术语一致",
91
+ " rf subtitles translate --from-split @/tmp/segs.json -l ja --master @/tmp/script.txt",
92
+ "",
93
+ "成本估算: 单次 LLM 调用 (~$0.001-0.003 取决于段数 + 模型)",
94
+ ].join("\n"))
95
+ .action(async (opts) => {
96
+ if (!opts.lang)
97
+ throw new Error("--lang is required (e.g. en, ja, English)");
98
+ let segments;
99
+ if (opts.fromSplit) {
100
+ const raw = await readInput(opts.fromSplit);
101
+ const parsed = JSON.parse(raw);
102
+ const arr = Array.isArray(parsed?.segments) ? parsed.segments : parsed;
103
+ if (!Array.isArray(arr)) {
104
+ throw new Error("--from-split: expected an object with `segments` array or a bare array");
105
+ }
106
+ segments = arr.map((s) => typeof s === "string" ? s : (s?.text ?? "")).filter((s) => s.length > 0);
107
+ }
108
+ else if (opts.segments) {
109
+ const raw = await readInput(opts.segments);
110
+ segments = parseSegmentsInput(raw);
111
+ }
112
+ else {
113
+ throw new Error("--segments or --from-split is required");
114
+ }
115
+ if (segments.length === 0)
116
+ throw new Error("no segments to translate");
117
+ let master;
118
+ if (opts.master) {
119
+ master = await readInput(opts.master);
120
+ }
121
+ const body = {
122
+ segments,
123
+ target_lang: opts.lang,
124
+ };
125
+ if (master)
126
+ body.master_script = master;
127
+ if (opts.llmModel)
128
+ body.llm_model = opts.llmModel;
129
+ const r = await post("/api/v1/subtitles/translate", body);
130
+ print({
131
+ count: r.translations.length,
132
+ translations: r.translations.map((t, i) => ({
133
+ id: i + 1,
134
+ src: segments[i],
135
+ translated: t,
136
+ })),
137
+ cost_usd: r.cost_usd,
138
+ duration_ms: r.duration_ms,
139
+ });
140
+ });
141
+ const ledger = sub
142
+ .command("ledger")
143
+ .description("Production subtitle ledger (每段字幕一行 JSONL,跨任务汇总)")
144
+ .helpOption("-h, --help", "show help");
145
+ ledger
146
+ .command("dump")
147
+ .description("从服务器下载字幕 ledger 到本地 JSONL 用于离线分析(每段一行,含切分质量评分)")
148
+ .helpOption("-h, --help", "show help")
149
+ .option("--since <iso>", "只拉 ts >= 这个时间的记录(ISO 8601,如 2026-06-01 或 2026-06-01T00:00:00Z)")
150
+ .option("--until <iso>", "只拉 ts <= 这个时间的记录")
151
+ .option("--task-id <id>", "只拉某一个 task 的记录")
152
+ .option("--limit <n>", "最多拉多少条(默认 10000, 上限 100000)", (v) => parseInt(v, 10))
153
+ .option("-o, --output <file>", "输出文件路径(默认 ./subtitle-ledger.jsonl)", "./subtitle-ledger.jsonl")
154
+ .addHelpText("after", [
155
+ "",
156
+ "字段:",
157
+ " ts / task_id / scene_idx / seg_idx 数据点定位",
158
+ " input_text 该 scene 切分前完整文本",
159
+ " display_text / text_secondary 实际上字幕的文字 + 译文",
160
+ " length / start_sec / end_sec 出现时长",
161
+ " cut_reason llm | tail | fallback-*",
162
+ " split_score (0-100) 该 scene LLM 切分评分",
163
+ " split_attempts / split_source 重试次数 / llm or fallback",
164
+ " splitter_version / llm_model 生成这条记录的版本组合",
165
+ " subtitle_translate_to en / null / 等",
166
+ "",
167
+ "Examples:",
168
+ " # 全量(上限 10000)",
169
+ " rf subtitles ledger dump -o ./ledger.jsonl",
170
+ "",
171
+ " # 只看最近一周",
172
+ " rf subtitles ledger dump --since 2026-06-01 -o ./recent.jsonl",
173
+ "",
174
+ " # 单个 task 复盘",
175
+ " rf subtitles ledger dump --task-id 0eebc40f -o ./one-task.jsonl",
176
+ "",
177
+ "拉完后用 jq / awk / 自己写脚本分析。常见查询:",
178
+ " jq 'select(.split_score < 80)' ledger.jsonl # 低质量 case",
179
+ " jq 'select(.length > 25)' ledger.jsonl # 偏长 segment",
180
+ " jq -s 'group_by(.task_id) | length' ledger.jsonl # task 数",
181
+ ].join("\n"))
182
+ .action(async (opts) => {
183
+ const server = getServer().replace(/\/+$/, "");
184
+ const apiKey = getApiKey();
185
+ const qs = new URLSearchParams({ format: "jsonl" });
186
+ if (opts.since)
187
+ qs.set("since", opts.since);
188
+ if (opts.until)
189
+ qs.set("until", opts.until);
190
+ if (opts.taskId)
191
+ qs.set("task_id", opts.taskId);
192
+ if (opts.limit)
193
+ qs.set("limit", String(opts.limit));
194
+ const headers = {};
195
+ if (apiKey)
196
+ headers["authorization"] = `Bearer ${apiKey}`;
197
+ info(`Fetching ledger from ${server}/api/v1/subtitles/ledger?${qs.toString()}`);
198
+ const resp = await fetch(`${server}/api/v1/subtitles/ledger?${qs.toString()}`, { headers });
199
+ if (!resp.ok) {
200
+ const text = await resp.text().catch(() => "");
201
+ throw new Error(`Ledger fetch failed: HTTP ${resp.status}\n${text.slice(0, 500)}`);
202
+ }
203
+ const text = await resp.text();
204
+ const count = parseInt(resp.headers.get("x-ledger-count") || "0", 10);
205
+ const totalBytes = parseInt(resp.headers.get("x-ledger-bytes") || "0", 10);
206
+ const outAbs = path.resolve(opts.output);
207
+ await fs.mkdir(path.dirname(outAbs), { recursive: true });
208
+ await fs.writeFile(outAbs, text);
209
+ success(`Saved ${count} entries → ${outAbs} ` +
210
+ `(${(text.length / 1024).toFixed(1)} KB; server ledger size: ${(totalBytes / 1024).toFixed(1)} KB)`);
211
+ });
212
+ }
213
+ async function readInput(input) {
214
+ if (input.startsWith("@")) {
215
+ return (await fs.readFile(input.slice(1), "utf-8")).trim();
216
+ }
217
+ return input;
218
+ }
219
+ async function parseScenesInput(raw) {
220
+ let parsed;
221
+ try {
222
+ parsed = JSON.parse(raw);
223
+ }
224
+ catch {
225
+ throw new Error("--scenes 必须是 JSON (数组 或 含 scenes 数组的对象, 如 storyboard.json)");
226
+ }
227
+ const arr = Array.isArray(parsed)
228
+ ? parsed
229
+ : Array.isArray(parsed?.scenes)
230
+ ? parsed.scenes
231
+ : null;
232
+ if (!arr)
233
+ throw new Error("--scenes: 期望 JSON 数组, 或含 `scenes` 数组的对象 (storyboard.json)");
234
+ return arr.map((s) => ({ text: typeof s === "string" ? s : (s?.text ?? "") }));
235
+ }
236
+ function parseSegmentsInput(raw) {
237
+ const trimmed = raw.trim();
238
+ if (trimmed.startsWith("[")) {
239
+ const parsed = JSON.parse(trimmed);
240
+ if (!Array.isArray(parsed))
241
+ throw new Error("--segments JSON must be an array");
242
+ return parsed.filter((s) => typeof s === "string" && s.length > 0);
243
+ }
244
+ const unescaped = trimmed.replace(/\\n/g, "\n");
245
+ return unescaped.split("\n").map((s) => s.trim()).filter(Boolean);
246
+ }
@@ -0,0 +1,59 @@
1
+ import { del, get } from "../client.js";
2
+ import { waitForTask } from "../utils/task-waiter.js";
3
+ import { print, table } from "../utils/output.js";
4
+ export function registerTasks(program) {
5
+ const tasks = program
6
+ .command("tasks")
7
+ .alias("task")
8
+ .description("任务队列管理: 查看 / 等待 / 取消")
9
+ .helpOption("-h, --help", "show help");
10
+ tasks
11
+ .command("list")
12
+ .description("List recent tasks")
13
+ .helpOption("-h, --help", "show help")
14
+ .option("--status <s>", "filter by status: pending | running | completed | failed | cancelled")
15
+ .option("--limit <n>", "max number of tasks", parseInt)
16
+ .action(async (opts) => {
17
+ const qs = new URLSearchParams();
18
+ if (opts.status)
19
+ qs.set("status", opts.status);
20
+ if (opts.limit)
21
+ qs.set("limit", String(opts.limit));
22
+ const r = await get(`/api/v1/tasks${qs.toString() ? `?${qs}` : ""}`);
23
+ table(r.tasks.map((t) => ({
24
+ id: t.id.slice(0, 8),
25
+ kind: t.kind,
26
+ status: t.status,
27
+ progress: `${(t.progress * 100).toFixed(0)}%`,
28
+ created_at: t.created_at,
29
+ })));
30
+ });
31
+ tasks
32
+ .command("get <id>")
33
+ .description("Show full task detail (status, progress, events, result)")
34
+ .helpOption("-h, --help", "show help")
35
+ .action(async (id) => {
36
+ const r = await get(`/api/v1/tasks/${id}`);
37
+ print(r);
38
+ });
39
+ tasks
40
+ .command("wait <id>")
41
+ .description("Poll a task until it reaches a terminal state (live progress on stderr)")
42
+ .helpOption("-h, --help", "show help")
43
+ .option("--poll-ms <ms>", "poll interval", parseInt, 1500)
44
+ .option("--timeout-ms <ms>", "max wait time", parseInt)
45
+ .action(async (id, opts) => {
46
+ const t = await waitForTask(id, { pollMs: opts.pollMs, timeoutMs: opts.timeoutMs });
47
+ print({ id: t.id, status: t.status, error: t.error, result: t.result });
48
+ if (t.status !== "completed")
49
+ process.exit(1);
50
+ });
51
+ tasks
52
+ .command("cancel <id>")
53
+ .description("Cancel a running task")
54
+ .helpOption("-h, --help", "show help")
55
+ .action(async (id) => {
56
+ const r = await del(`/api/v1/tasks/${id}`);
57
+ print(r);
58
+ });
59
+ }