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,107 +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 { downloadTo } from "../utils/download.js";
7
- import { print, success, warn } from "../utils/output.js";
8
- const MAX_REF_BYTES = 15 * 1024 * 1024;
9
- async function resolveImageRef(input) {
10
- const t = input.trim();
11
- if (!t)
12
- throw new Error("--image: empty value");
13
- if (/^https?:\/\//i.test(t) || t.startsWith("data:"))
14
- return { url: t };
15
- const abs = path.resolve(t);
16
- if (!fsSync.existsSync(abs)) {
17
- throw new Error(`--image: local file not found: ${abs}`);
18
- }
19
- const ext = path.extname(abs).toLowerCase();
20
- const mime = ext === ".jpg" || ext === ".jpeg" ? "image/jpeg" :
21
- ext === ".webp" ? "image/webp" :
22
- ext === ".png" ? "image/png" :
23
- null;
24
- if (!mime) {
25
- throw new Error(`--image: unsupported extension ${ext} (use png/jpg/jpeg/webp)`);
26
- }
27
- try {
28
- const key = await uploadToOss(abs);
29
- return { key };
30
- }
31
- catch (e) {
32
- const sz = (await fs.stat(abs)).size;
33
- if (sz > MAX_REF_BYTES) {
34
- throw new Error(`--image ${path.basename(abs)} 直传 OSS 失败 (${e instanceof Error ? e.message : e}),且文件超过内联回退上限 ${MAX_REF_BYTES} 字节。`);
35
- }
36
- warn(`--image ${path.basename(abs)}: OSS 直传不可用,回退内联上传 (${e instanceof Error ? e.message : e})`);
37
- const buf = await fs.readFile(abs);
38
- return { url: `data:${mime};base64,${buf.toString("base64")}` };
39
- }
40
- }
41
- export function registerImages(program) {
42
- const images = program
43
- .command("images")
44
- .description("图像生成: 文本 → 图片")
45
- .helpOption("-h, --help", "show help");
46
- images
47
- .command("generate")
48
- .description("Generate an image (text-to-image or ref-image, depending on model + --image)")
49
- .helpOption("-h, --help", "show help")
50
- .requiredOption("-p, --prompt <text>", "text prompt. With --image, reference each ref via 「图 N」/「Image N」.")
51
- .option("-m, --model <id>", "image model id (rx-image-z | rx-image-flux | rx-image-qwen | rx-image-qwen-edit)")
52
- .option("--width <n>", "image width", parseInt)
53
- .option("--height <n>", "image height", parseInt)
54
- .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], [])
55
- .option("-o, --output <file>", "download first image to this local path")
56
- .option("--all-output <dir>", "download ALL generated images into this directory")
57
- .addHelpText("after", [
58
- "",
59
- "Examples:",
60
- " # plain text-to-image",
61
- " reelforge images generate -p 'a cat' -m rx-image-flux --width 1024 --height 1024 -o cat.png",
62
- "",
63
- " # ref-image edit (Qwen-Image-Edit), 1 ref local file",
64
- " reelforge images generate -m rx-image-qwen-edit \\",
65
- " -p '保持图1人物,把背景改成雪山黄昏' \\",
66
- " -i ./hero.png -o out.png",
67
- "",
68
- " # ref-image edit, 2 refs (URL + local), 「图1」+「图2」",
69
- " reelforge images generate -m rx-image-qwen-edit \\",
70
- " -p '让图1的人物穿上图2的衣服' \\",
71
- " -i https://example.com/person.jpg -i ./outfit.png -o composite.png",
72
- ].join("\n"))
73
- .action(async (opts) => {
74
- const body = { prompt: opts.prompt };
75
- if (opts.model)
76
- body.model = opts.model;
77
- if (opts.width !== undefined)
78
- body.width = opts.width;
79
- if (opts.height !== undefined)
80
- body.height = opts.height;
81
- if (opts.image && Array.isArray(opts.image) && opts.image.length > 0) {
82
- if (opts.image.length > 3) {
83
- throw new Error(`--image accepts at most 3 refs (got ${opts.image.length})`);
84
- }
85
- const resolved = await Promise.all(opts.image.map(resolveImageRef));
86
- const urls = resolved.filter((r) => r.url).map((r) => r.url);
87
- const keys = resolved.filter((r) => r.key).map((r) => r.key);
88
- if (urls.length)
89
- body.image_urls = urls;
90
- if (keys.length)
91
- body.image_keys = keys;
92
- }
93
- const r = await post("/api/v1/images/generate", body);
94
- if (opts.output && r.images?.[0]) {
95
- await downloadTo(r.images[0], opts.output);
96
- success(`Saved → ${opts.output}`);
97
- }
98
- if (opts.allOutput) {
99
- for (let i = 0; i < r.images.length; i++) {
100
- const dest = `${opts.allOutput}/image_${i + 1}.png`;
101
- await downloadTo(r.images[i], dest);
102
- success(`Saved → ${dest}`);
103
- }
104
- }
105
- print(r);
106
- });
107
- }
@@ -1,67 +0,0 @@
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
- }
@@ -1,136 +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, warn } from "../utils/output.js";
7
- const MEDIA_EXT_TO_MIME = {
8
- ".mp4": "video/mp4",
9
- ".mov": "video/quicktime",
10
- ".webm": "video/webm",
11
- ".mkv": "video/x-matroska",
12
- ".m4v": "video/mp4",
13
- ".mp3": "audio/mpeg",
14
- ".wav": "audio/wav",
15
- ".m4a": "audio/mp4",
16
- ".ogg": "audio/ogg",
17
- ".flac": "audio/flac",
18
- ".png": "image/png",
19
- ".jpg": "image/jpeg",
20
- ".jpeg": "image/jpeg",
21
- ".webp": "image/webp",
22
- };
23
- function fmtSize(bytes) {
24
- if (!bytes)
25
- return "0";
26
- if (bytes < 1024)
27
- return `${bytes} B`;
28
- if (bytes < 1024 * 1024)
29
- return `${(bytes / 1024).toFixed(1)} KB`;
30
- if (bytes < 1024 * 1024 * 1024)
31
- return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
32
- return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
33
- }
34
- function formatHuman(r, label) {
35
- const lines = [];
36
- lines.push(`${label} [${r.kind}]`);
37
- if (r.formatName)
38
- lines.push(` format: ${r.formatName}`);
39
- if (r.durationSec) {
40
- const min = Math.floor(r.durationSec / 60);
41
- const sec = (r.durationSec % 60).toFixed(2);
42
- lines.push(` duration: ${r.durationSec.toFixed(2)}s (${min}:${sec.padStart(5, "0")})`);
43
- }
44
- if (r.sizeBytes)
45
- lines.push(` size: ${fmtSize(r.sizeBytes)}`);
46
- if (r.bitrate)
47
- lines.push(` bitrate: ${(r.bitrate / 1000).toFixed(0)} kbps`);
48
- if (r.video) {
49
- lines.push(` video: ${r.video.codec} ${r.video.width}×${r.video.height} @ ${r.video.fps} fps` +
50
- (r.video.pixFmt ? ` (${r.video.pixFmt})` : ""));
51
- }
52
- if (r.audio) {
53
- lines.push(` audio: ${r.audio.codec} ${r.audio.sampleRate} Hz ${r.audio.channels}ch` +
54
- (r.audio.channelLayout ? ` (${r.audio.channelLayout})` : ""));
55
- }
56
- const counts = r.streamCounts;
57
- if (counts.subtitle > 0 || counts.other > 0) {
58
- lines.push(` streams: ${counts.video}v + ${counts.audio}a + ${counts.subtitle}sub + ${counts.other}other`);
59
- }
60
- return lines;
61
- }
62
- export function registerMedia(program) {
63
- const mediaCmd = program
64
- .command("media")
65
- .description("媒体处理原子能力。目前包含: probe (ffprobe 探针)")
66
- .helpOption("-h, --help", "show help");
67
- mediaCmd
68
- .command("probe <input>")
69
- .description("ffprobe 一个媒体文件 / URL → JSON metadata。常用于 agent 在决定下游参数前查媒体长度/编码/分辨率/帧率。")
70
- .helpOption("-h, --help", "show help")
71
- .addHelpText("after", [
72
- "",
73
- "Input 类型 (自动识别):",
74
- " 本地路径 ./video.mp4 CLI base64 上传到服务端探针",
75
- " http(s) URL https://example.com/x.mp4 服务端 ffprobe 直读",
76
- "",
77
- "支持的扩展名:",
78
- " 视频: .mp4 .mov .webm .mkv .m4v",
79
- " 音频: .mp3 .wav .m4a .ogg .flac",
80
- " 图片: .png .jpg .jpeg .webp",
81
- "",
82
- "Examples:",
83
- " # 本地 MP4",
84
- " rf media probe ./video.mp4",
85
- "",
86
- " # 远程 URL (服务端直读, 不下载到本地)",
87
- " rf media probe 'https://example.com/clip.mp4'",
88
- "",
89
- " # JSON 输出 (agent 解析)",
90
- " rf media probe ./video.mp4 --json",
91
- "",
92
- " # 抽特定字段",
93
- " rf media probe ./video.mp4 --json | jq '.durationSec'",
94
- " rf media probe ./video.mp4 --json | jq '.video.fps'",
95
- "",
96
- "成本: $0 (纯 ffprobe, 无 LLM/ASR)",
97
- ].join("\n"))
98
- .action(async (input) => {
99
- const isRemote = /^https?:\/\//i.test(input);
100
- let body;
101
- let label;
102
- if (isRemote) {
103
- body = { url: input };
104
- label = input;
105
- }
106
- else {
107
- const abs = path.resolve(input);
108
- if (!fsSync.existsSync(abs))
109
- throw new Error(`file not found: ${abs}`);
110
- const ext = path.extname(abs).toLowerCase();
111
- const mime = MEDIA_EXT_TO_MIME[ext];
112
- if (!mime) {
113
- throw new Error(`unsupported extension "${ext}". Supported: ${Object.keys(MEDIA_EXT_TO_MIME).join(" / ")}, ` +
114
- `or pass a URL.`);
115
- }
116
- try {
117
- const key = await uploadToOss(abs);
118
- body = { key };
119
- }
120
- catch (e) {
121
- warn(`${path.basename(abs)}: OSS 直传不可用,回退内联上传 (${e instanceof Error ? e.message : e})`);
122
- const buf = await fs.readFile(abs);
123
- body = { data_uri: `data:${mime};base64,${buf.toString("base64")}` };
124
- }
125
- label = path.basename(abs);
126
- }
127
- const r = await post("/api/v1/media/probe", body);
128
- if (isJson()) {
129
- print(r);
130
- return;
131
- }
132
- const lines = formatHuman(r, label);
133
- for (const line of lines)
134
- info(line);
135
- });
136
- }
@@ -1,36 +0,0 @@
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
- }
@@ -1,142 +0,0 @@
1
- import fs from "node:fs/promises";
2
- import { post } from "../client.js";
3
- import { waitForTask } from "../utils/task-waiter.js";
4
- import { downloadTo } from "../utils/download.js";
5
- import { print, success, warn } from "../utils/output.js";
6
- async function submitAndMaybeWait(endpoint, body, common) {
7
- const { task_id, status } = await post(endpoint, body);
8
- if (common.wait === false) {
9
- print({ task_id, status });
10
- return;
11
- }
12
- const t = await waitForTask(task_id, {
13
- pollMs: common.pollMs ?? 1500,
14
- timeoutMs: common.timeoutMs,
15
- });
16
- if (t.status !== "completed") {
17
- throw new Error(t.error || `Task ended with status ${t.status}`);
18
- }
19
- const result = t.result;
20
- if (common.output && result?.video_url) {
21
- await downloadTo(result.video_url, common.output);
22
- success(`Saved → ${common.output}`);
23
- }
24
- print({ task_id: t.id, status: t.status, ...result });
25
- }
26
- function commonOptions(cmd) {
27
- return cmd
28
- .option("--no-wait", "submit and return task_id immediately (do not poll)")
29
- .option("-o, --output <file>", "save the final video to this path (only when waiting)")
30
- .option("--poll-ms <ms>", "poll interval while waiting", (v) => parseInt(v, 10), 1500)
31
- .option("--timeout-ms <ms>", "max wait time before aborting (default unlimited)", (v) => parseInt(v, 10));
32
- }
33
- export function registerPipelines(program) {
34
- const pl = program
35
- .command("pipelines")
36
- .alias("pipeline")
37
- .description("端到端视频流水线 (与 rf create 等价, 暴露更多底层参数)")
38
- .helpOption("-h, --help", "show help");
39
- commonOptions(pl
40
- .command("standard")
41
- .description("Audio-first pipeline: topic|script → master TTS → ASR → scene/subtitle layers → final MP4")
42
- .helpOption("-h, --help", "show help")
43
- .option("-t, --topic <text>", "video topic (mode=generate). Use @file to read from disk.")
44
- .option("--script <text>", "your own master script text (mode=fixed). Use @file to read from disk.")
45
- .option("--title <text>", "hard-override video title; LLM will NOT auto-summarize. Useful for compliance-sensitive content. Use @file to read from disk. Pass --title '' (empty) to explicitly suppress title rendering. Omit to keep LLM auto-title.")
46
- .option("-d, --duration <sec>", "target video duration in seconds (generate mode; default 45)", (v) => parseInt(v, 10))
47
- .option("-p, --pace <pace>", "visual rhythm hint: slow | normal | fast (default normal)")
48
- .option("--motion <preset>", "per-scene image animation: off | lite (default) | max")
49
- .option("--layout <preset>", "image layout: full (default) | blur-bg | letterbox. See below.")
50
- .option("--layout-matte-color <css>", "letterbox matte color (CSS). Ignored unless --layout letterbox. Default: black.")
51
- .option("--subtitle-style <preset>", "subtitle visual style: plate (default) | stroke | cinema")
52
- .option("--preview-only", "只生成可预览的网页版,不出 MP4(快很多)。后续用 `rf render <task-id>` 再出 MP4。")
53
- .option("--image-model <id>", "image model (rx-image-z | rx-image-flux | rx-image-qwen | rx-image-qwen-edit)")
54
- .option("--prompt-prefix <text>", "style prefix prepended to every image prompt")
55
- .option("--character-ref <urlOrPath>", "main character ref for cross-scene identity lock")
56
- .option("--voice-id <id>", "TTS voice id (default 专业解说); see `rf tts voices`")
57
- .option("--tts-speed <n>", "speech speed (0.5..2; default 1.0). Only honored by some TTS backends; the default cloud model ignores it.", parseFloat)
58
- .option("--video-fps <n>", "output video fps (default 24 — cinema-standard, ~20% faster render vs 30; pass 30 if you want smoother motion)", (v) => parseInt(v, 10))
59
- .option("--segment-min-chars <N>", "subtitle SEGMENT (分段) min chars (default 10). Segment = one on-screen subtitle unit, not a visual line.", (v) => parseInt(v, 10))
60
- .option("--segment-max-chars <N>", "subtitle SEGMENT (分段) max chars (default 28 ≈ 2 visual lines at 820px safe width). HTML clamps to 2 lines + ellipsis.", (v) => parseInt(v, 10))
61
- .addHelpText("after", [
62
- "",
63
- "Two content modes (exactly one required):",
64
- " generate AI writes the script. --topic / -t <text> + optional --duration -d",
65
- " fixed You supply the script. --script <text-or-@file>",
66
- "",
67
- "Pace (LLM visual rhythm hint): slow | normal | fast",
68
- "Motion (per-scene animation): off | lite | max",
69
- "Subtitle style: plate | stroke | cinema",
70
- "",
71
- "Layout — how the AI image sits in the 1080×1920 canvas:",
72
- " full image fills the whole canvas (default; punchy, 9:16-native content).",
73
- " blur-bg image at 1080×1080 centered + same image scaled/blurred as background",
74
- " (小红书 / 抖音 style; best for charts, screenshots, non-9:16 source).",
75
- " letterbox image at 1080×1080 centered + solid matte top/bottom (cinematic).",
76
- " tweak with --layout-matte-color (default 'black').",
77
- " · Image is generated at the actual on-screen size, so you don't pay for cropped pixels.",
78
- "",
79
- "Examples:",
80
- " rf pipelines standard -t 'why we explore space' -d 60 -o space.mp4",
81
- " rf pipelines standard --script @script.txt -p slow --motion max -o out.mp4",
82
- " rf pipelines standard -t '财经日报' --layout blur-bg --subtitle-style plate -o out.mp4",
83
- " rf pipelines standard -t '纪录片' --layout letterbox --motion max -o film.mp4",
84
- "",
85
- "Tip: `rf create` is a more ergonomic wrapper around the same endpoint.",
86
- ].join("\n"))).action(async (opts) => {
87
- const hasTopic = typeof opts.topic === "string" && opts.topic.length > 0;
88
- const hasScript = typeof opts.script === "string" && opts.script.length > 0;
89
- if (!hasTopic && !hasScript) {
90
- throw new Error("either --topic / -t or --script is required");
91
- }
92
- if (hasTopic && hasScript) {
93
- throw new Error("--topic and --script are mutually exclusive");
94
- }
95
- if (opts.pace && !["slow", "normal", "fast"].includes(opts.pace)) {
96
- throw new Error(`--pace must be one of slow|normal|fast (got: ${opts.pace})`);
97
- }
98
- if (opts.motion && !["off", "lite", "max"].includes(opts.motion)) {
99
- throw new Error(`--motion must be one of off|lite|max (got: ${opts.motion})`);
100
- }
101
- if (opts.layout && !["full", "blur-bg", "letterbox"].includes(opts.layout)) {
102
- throw new Error(`--layout must be one of full|blur-bg|letterbox (got: ${opts.layout})`);
103
- }
104
- if (opts.subtitleStyle && !["plate", "stroke", "cinema"].includes(opts.subtitleStyle)) {
105
- throw new Error(`--subtitle-style must be one of plate|stroke|cinema (got: ${opts.subtitleStyle})`);
106
- }
107
- if (opts.ttsSpeed !== undefined &&
108
- (!opts.ttsModel || opts.ttsModel.startsWith("vox/"))) {
109
- const m = opts.ttsModel || "default";
110
- warn(`--tts-speed=${opts.ttsSpeed} will be ignored by TTS model "${m}". Speed control is only honored by Edge-TTS-backed models.`);
111
- }
112
- let topic = opts.topic;
113
- let script = opts.script;
114
- let title = opts.title;
115
- if (topic?.startsWith("@"))
116
- topic = await fs.readFile(topic.slice(1), "utf-8");
117
- if (script?.startsWith("@"))
118
- script = await fs.readFile(script.slice(1), "utf-8");
119
- if (title?.startsWith("@"))
120
- title = await fs.readFile(title.slice(1), "utf-8");
121
- await submitAndMaybeWait("/api/v1/pipelines/standard", {
122
- topic,
123
- script,
124
- title,
125
- duration: opts.duration,
126
- pace: opts.pace,
127
- motion: opts.motion,
128
- layout: opts.layout,
129
- layout_matte_color: opts.layoutMatteColor,
130
- subtitle_style: opts.subtitleStyle,
131
- image_model: opts.imageModel,
132
- prompt_prefix: opts.promptPrefix,
133
- character_ref: opts.characterRef,
134
- voice_id: opts.voiceId,
135
- tts_speed: opts.ttsSpeed,
136
- video_fps: opts.videoFps,
137
- segment_min_chars: opts.segmentMinChars,
138
- segment_max_chars: opts.segmentMaxChars,
139
- preview_only: opts.previewOnly,
140
- }, { wait: opts.wait, output: opts.output, pollMs: opts.pollMs, timeoutMs: opts.timeoutMs });
141
- });
142
- }