reelforge 1.23.0 → 1.24.1

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.
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
2
2
  import fsSync from "node:fs";
3
3
  import path from "node:path";
4
4
  import { post } from "../client.js";
5
+ import { uploadToOss } from "../utils/oss-upload.js";
5
6
  import { info, isJson, print, success, warn } from "../utils/output.js";
6
7
  import { ASSETS_WORKFLOW_TEXT } from "./assets-workflow-text.js";
7
8
  const IMAGE_EXT = new Set([".jpg", ".jpeg", ".png", ".webp"]);
@@ -63,13 +64,23 @@ async function resolveAsset(input) {
63
64
  const supported = [...IMAGE_EXT, ...VIDEO_EXT].join(" / ");
64
65
  throw new Error(`unsupported extension ${ext} (supported: ${supported})`);
65
66
  }
66
- const buf = await fs.readFile(abs);
67
- return { url: `data:${mime};base64,${buf.toString("base64")}`, type };
67
+ try {
68
+ const key = await uploadToOss(abs);
69
+ return { key, type };
70
+ }
71
+ catch (e) {
72
+ warn(`${path.basename(abs)}: OSS 直传不可用,回退内联上传 (${e instanceof Error ? e.message : e})`);
73
+ const buf = await fs.readFile(abs);
74
+ return { url: `data:${mime};base64,${buf.toString("base64")}`, type };
75
+ }
68
76
  }
69
77
  async function describeOne(input, opts) {
70
- const { url, type } = await resolveAsset(input);
78
+ const { url, key, type } = await resolveAsset(input);
71
79
  const body = {};
72
- body[`${type}_url`] = url;
80
+ if (key)
81
+ body[`${type}_key`] = key;
82
+ else
83
+ body[`${type}_url`] = url;
73
84
  if (opts.model)
74
85
  body.model = opts.model;
75
86
  if (opts.prompt)
@@ -0,0 +1,139 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { post } from "../client.js";
4
+ import { waitForTask } from "../utils/task-waiter.js";
5
+ import { downloadTo } from "../utils/download.js";
6
+ import { formatUsd, humanDuration, info, isJson, print, success } from "../utils/output.js";
7
+ async function loadSpec(raw) {
8
+ let text = raw;
9
+ if (raw.startsWith("@")) {
10
+ text = await fs.readFile(path.resolve(raw.slice(1)), "utf8");
11
+ }
12
+ let parsed;
13
+ try {
14
+ parsed = JSON.parse(text);
15
+ }
16
+ catch (e) {
17
+ throw new Error(`--spec is not valid JSON: ${e instanceof Error ? e.message : e}`);
18
+ }
19
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
20
+ throw new Error("--spec must be a JSON object (the component's render spec)");
21
+ }
22
+ return parsed;
23
+ }
24
+ export function registerClip(program) {
25
+ program
26
+ .command("clip")
27
+ .description("一个 scene 组件 → 独立竖屏 MP4 (可选配音; 给 `rf dub --append` 拼片尾用)")
28
+ .helpOption("-h, --help", "show help")
29
+ .requiredOption("--component <id>", "组件 kind id (如 kinetic-text / quote-card / index-board)")
30
+ .requiredOption("--spec <json>", "组件 spec,内联 JSON 或 @文件路径")
31
+ .option("--theme <id>", "主题皮肤 id (缺省 = 组件所属 profile;general 组件用 general)")
32
+ .option("--narration <text>", "配音文案 (传了才有声音;不传 = 无声片段)")
33
+ .option("--voice <id>", "配音音色 (index-tts-2 音色名, 默认 熊小二)", "熊小二")
34
+ .option("--tts-model <id>", "TTS 模型 (默认 vox/index-tts-2)", "vox/index-tts-2")
35
+ .option("--duration <sec>", "片段时长 (秒);缺省 = 组件自然时长(无声)或配音时长(有声)", (v) => parseFloat(v))
36
+ .option("--subtitles", "烧录字幕 (默认不烧;组件通常自带文字)")
37
+ .option("--subtitle-style <style>", "字幕样式: plate | stroke | cinema (默认 plate)", "plate")
38
+ .option("-o, --output <file>", "输出 MP4 路径 (默认: clip-<组件>.mp4, 放在当前目录)")
39
+ .option("--preview-only", "只生成 preview, 不渲染最终 MP4")
40
+ .option("--poll-ms <ms>", "轮询间隔", (v) => parseInt(v, 10), 1500)
41
+ .option("--timeout-ms <ms>", "最长等待时间", (v) => parseInt(v, 10))
42
+ .addHelpText("after", [
43
+ "",
44
+ "它是什么:",
45
+ " 把单个 scene 组件渲染成一段独立的 1080×1920 竖屏 MP4。通用命令 —— 任何",
46
+ " 想单独跑的组件都走这里;最常见用途是给 `rf dub --append` 做固定片尾(关注卡 /",
47
+ " 赞助板 / 口播尾板),生成一次反复拼,省去每条视频重复 TTS。",
48
+ "",
49
+ "配音 (可选):",
50
+ " · 不传 --narration → 无声片段,时长 = 组件自然时长 (或 --duration)。",
51
+ " · 传 --narration → 熊小二配音 (可 --voice 换),时长 = max(配音, 组件自然时长),",
52
+ " 即配音短也不会截断动画;--subtitles 可另烧字幕。",
53
+ "",
54
+ "组件 (--component) + spec (--spec):",
55
+ " · --component 传组件的 kind id。常用通用组件: kinetic-text(一句大字) /",
56
+ " quote-card(金句) / comparison-vs(对比) / stat-counter(数字) /",
57
+ " bullet-list(要点) / timeline(时间线)。finance / mindset 垂直组件也可,",
58
+ " 会自动套对应主题皮肤 (也可 --theme 指定)。",
59
+ " · --spec 是该组件的渲染字段 (JSON 对象),内联或 @文件。字段随组件而定,",
60
+ " 例: kinetic-text = {text, emphasis};quote-card = {quote, author, context}。",
61
+ "",
62
+ "认证: 需要 API key — `rf login <key>` / $REELFORGE_API_KEY / --api-key。",
63
+ "可用音色: `rf tts voices`。",
64
+ "",
65
+ "示例:",
66
+ " # 无声金句卡 (用组件自然时长)",
67
+ ' rf clip --component quote-card --spec \'{"quote":"慢即是快","author":"老张"}\'',
68
+ "",
69
+ " # 带配音的关注片尾,再拼到 dub 后面 (典型用法)",
70
+ ' rf clip --component kinetic-text --spec \'{"text":"关注 机械制造师","emphasis":"机械制造师"}\' \\',
71
+ ' --narration "关注机械制造师官方账号,每天一个离谱发明" -o outro.mp4',
72
+ " rf dub gameplay.mp4 --append outro.mp4",
73
+ "",
74
+ " # 指定时长 + 字幕",
75
+ ' rf clip --component kinetic-text --spec \'{"text":"上车!"}\' --narration "都给我上车" --subtitles --duration 4',
76
+ "",
77
+ " # 财经组件 (spec 从文件读, 自动用 finance 皮肤)",
78
+ " rf clip --component index-board --spec @board.json",
79
+ ].join("\n"))
80
+ .action(async (opts) => {
81
+ const spec = await loadSpec(opts.spec);
82
+ const subtitleStyle = (opts.subtitleStyle || "plate").toLowerCase();
83
+ if (!["plate", "stroke", "cinema"].includes(subtitleStyle)) {
84
+ throw new Error(`--subtitle-style must be plate | stroke | cinema (got ${opts.subtitleStyle})`);
85
+ }
86
+ if (opts.duration !== undefined && (!Number.isFinite(opts.duration) || opts.duration <= 0)) {
87
+ throw new Error(`--duration must be a positive number (got ${opts.duration})`);
88
+ }
89
+ const body = {
90
+ component: opts.component,
91
+ spec,
92
+ voice: opts.voice || "熊小二",
93
+ tts_model: opts.ttsModel || "vox/index-tts-2",
94
+ subtitles: opts.subtitles === true,
95
+ subtitle_style: subtitleStyle,
96
+ };
97
+ if (opts.theme)
98
+ body.theme = opts.theme;
99
+ if (opts.narration)
100
+ body.narration = opts.narration;
101
+ if (opts.duration !== undefined)
102
+ body.duration_sec = opts.duration;
103
+ if (opts.previewOnly)
104
+ body.preview_only = true;
105
+ info(`Rendering component "${opts.component}"${opts.narration ? " (voiced)" : " (silent)"}...`);
106
+ const submitted = await post("/api/v1/clip", body);
107
+ info(`Submitted task: ${submitted.task_id} — 渲染中, 请稍候...`);
108
+ const t = await waitForTask(submitted.task_id, { pollMs: opts.pollMs, timeoutMs: opts.timeoutMs });
109
+ if (t.status !== "completed") {
110
+ throw new Error(t.error || `Task ended with status ${t.status}`);
111
+ }
112
+ const result = t.result;
113
+ if (result?.render_pending) {
114
+ info(`MP4 render skipped (--preview-only). Run \`rf render ${t.id}\` to finalize.`);
115
+ }
116
+ else if (result?.video_url) {
117
+ const stdoutIsPipe = !process.stdout.isTTY;
118
+ const skipDownload = stdoutIsPipe && !opts.output;
119
+ let savedPath;
120
+ if (opts.output)
121
+ savedPath = path.resolve(opts.output);
122
+ else if (!skipDownload)
123
+ savedPath = path.resolve(`clip-${opts.component}.mp4`);
124
+ if (savedPath) {
125
+ await downloadTo(result.video_url, savedPath);
126
+ success(`Saved → ${savedPath}`);
127
+ }
128
+ }
129
+ if (result?.generation_ms != null) {
130
+ const playback = result.duration != null ? `, ${result.duration.toFixed(1)}s` : "";
131
+ const skin = result.theme ? `, theme ${result.theme}` : "";
132
+ info(`generated in ${humanDuration(result.generation_ms)}${playback}${skin}`);
133
+ }
134
+ if (result?.price_usd != null)
135
+ info(`charge: ${formatUsd(result.price_usd)}`);
136
+ if (isJson())
137
+ print({ task_id: t.id, status: t.status, ...result });
138
+ });
139
+ }
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
2
2
  import fsSync from "node:fs";
3
3
  import path from "node:path";
4
4
  import { post, getServer } from "../client.js";
5
+ import { uploadToOss } from "../utils/oss-upload.js";
5
6
  import { waitForTask } from "../utils/task-waiter.js";
6
7
  import { downloadTo } from "../utils/download.js";
7
8
  import { formatUsd, humanDuration, info, print, success, warn } from "../utils/output.js";
@@ -18,15 +19,6 @@ const EXT_TO_MIME = {
18
19
  ".mov": "video/quicktime",
19
20
  ".webm": "video/webm",
20
21
  };
21
- function fmtSize(bytes) {
22
- if (!bytes)
23
- return "0";
24
- if (bytes < 1024)
25
- return `${bytes} B`;
26
- if (bytes < 1024 * 1024)
27
- return `${(bytes / 1024).toFixed(1)} KB`;
28
- return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
29
- }
30
22
  function listSpecPaths(spec) {
31
23
  const paths = new Set();
32
24
  if (spec.audio.narration)
@@ -188,14 +180,27 @@ async function renderComposeOnce(specPath, opts) {
188
180
  info(`Reading spec from ${specPath}...`);
189
181
  const specPaths = listSpecPaths(spec);
190
182
  const files = {};
191
- let totalBytes = 0;
183
+ const fileKeys = {};
192
184
  for (const p of specPaths) {
193
- const r = await readAsDataUri(p, baseDir);
194
- files[p] = r.dataUri;
195
- totalBytes += r.bytes;
185
+ const abs = path.isAbsolute(p) ? p : path.resolve(baseDir, p);
186
+ if (!fsSync.existsSync(abs)) {
187
+ throw new Error(`compose: spec references "${p}" but file not found at ${abs}`);
188
+ }
189
+ try {
190
+ fileKeys[p] = await uploadToOss(abs);
191
+ }
192
+ catch (e) {
193
+ warn(`${p}: OSS 直传不可用,回退内联上传 (${e instanceof Error ? e.message : e})`);
194
+ const r = await readAsDataUri(p, baseDir);
195
+ files[p] = r.dataUri;
196
+ }
196
197
  }
197
- info(`Bundling ${specPaths.length} files (${fmtSize(totalBytes)} total)...`);
198
- const body = { spec, files };
198
+ info(`Bundling ${specPaths.length} files (${Object.keys(fileKeys).length} 直传 OSS, ${Object.keys(files).length} 内联)...`);
199
+ const body = { spec };
200
+ if (Object.keys(fileKeys).length)
201
+ body.file_keys = fileKeys;
202
+ if (Object.keys(files).length)
203
+ body.files = files;
199
204
  if (opts.previewOnly)
200
205
  body.preview_only = true;
201
206
  const submitted = await post("/api/v1/compose", body);
@@ -1,7 +1,8 @@
1
1
  import { post } from "../client.js";
2
2
  import { downloadTo } from "../utils/download.js";
3
- import { print, success, info } from "../utils/output.js";
4
- import { fmtSize, resolveFileInput, resolveFileInputs } from "../utils/file-upload.js";
3
+ import { print, success, info, warn } from "../utils/output.js";
4
+ import { fmtSize, resolveUploadInput, resolveUploadInputs } from "../utils/file-upload.js";
5
+ const onOssFallback = (flagName, reason) => warn(`${flagName}: OSS 直传不可用,回退内联上传 (${reason})`);
5
6
  const WHEN_TO_USE = [
6
7
  "",
7
8
  "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
@@ -52,18 +53,22 @@ export function registerCompositions(program) {
52
53
  .action(async (videos, opts) => {
53
54
  if (!opts.output)
54
55
  throw new Error("--output is required");
55
- const { resolved, totalBytes } = await resolveFileInputs(videos, {
56
+ const { resolved, totalBytes } = await resolveUploadInputs(videos, {
56
57
  flagName: "<videos...>",
58
+ onFallback: onOssFallback,
57
59
  });
58
60
  const bgm = opts.bgm
59
- ? await resolveFileInput(opts.bgm, { flagName: "--bgm" })
61
+ ? await resolveUploadInput(opts.bgm, { flagName: "--bgm", onFallback: onOssFallback })
60
62
  : null;
61
63
  if (totalBytes)
62
64
  info(`Uploading ${resolved.length} videos (${fmtSize(totalBytes + (bgm?.bytes ?? 0))})...`);
65
+ const videoKeys = resolved.filter((r) => r.key).map((r) => r.key);
66
+ const videoUris = resolved.filter((r) => r.url).map((r) => r.url);
63
67
  const r = await post("/api/v1/compositions/concat", {
64
- videos: resolved.map((r) => r.url),
68
+ videos: videoUris,
69
+ video_keys: videoKeys,
65
70
  method: opts.method,
66
- bgm_path: bgm?.url,
71
+ ...(bgm?.key ? { bgm_key: bgm.key } : bgm?.url ? { bgm_path: bgm.url } : {}),
67
72
  bgm_volume: opts.bgmVolume,
68
73
  bgm_mode: opts.bgmMode,
69
74
  });
@@ -83,12 +88,12 @@ export function registerCompositions(program) {
83
88
  .action(async (opts) => {
84
89
  if (!opts.output)
85
90
  throw new Error("--output is required");
86
- const v = await resolveFileInput(opts.input, { flagName: "-i / --input" });
87
- const b = await resolveFileInput(opts.bgm, { flagName: "--bgm" });
91
+ const v = await resolveUploadInput(opts.input, { flagName: "-i / --input", onFallback: onOssFallback });
92
+ const b = await resolveUploadInput(opts.bgm, { flagName: "--bgm", onFallback: onOssFallback });
88
93
  info(`Uploading video (${fmtSize(v.bytes ?? 0)}) + bgm (${fmtSize(b.bytes ?? 0)})...`);
89
94
  const r = await post("/api/v1/compositions/bgm", {
90
- video: v.url,
91
- bgm: b.url,
95
+ ...(v.key ? { video_key: v.key } : { video: v.url }),
96
+ ...(b.key ? { bgm_key: b.key } : { bgm: b.url }),
92
97
  volume: opts.volume,
93
98
  mode: opts.mode,
94
99
  });
@@ -107,12 +112,12 @@ export function registerCompositions(program) {
107
112
  .action(async (opts) => {
108
113
  if (!opts.output)
109
114
  throw new Error("--output is required");
110
- const img = await resolveFileInput(opts.image, { flagName: "-i / --image" });
111
- const aud = await resolveFileInput(opts.audio, { flagName: "-a / --audio" });
115
+ const img = await resolveUploadInput(opts.image, { flagName: "-i / --image", onFallback: onOssFallback });
116
+ const aud = await resolveUploadInput(opts.audio, { flagName: "-a / --audio", onFallback: onOssFallback });
112
117
  info(`Uploading image (${fmtSize(img.bytes ?? 0)}) + audio (${fmtSize(aud.bytes ?? 0)})...`);
113
118
  const r = await post("/api/v1/compositions/image-to-video", {
114
- image: img.url,
115
- audio: aud.url,
119
+ ...(img.key ? { image_key: img.key } : { image: img.url }),
120
+ ...(aud.key ? { audio_key: aud.key } : { audio: aud.url }),
116
121
  fps: opts.fps,
117
122
  });
118
123
  await downloadTo(r.url, opts.output);
@@ -129,12 +134,12 @@ export function registerCompositions(program) {
129
134
  .action(async (opts) => {
130
135
  if (!opts.output)
131
136
  throw new Error("--output is required");
132
- const v = await resolveFileInput(opts.video, { flagName: "-v / --video" });
133
- const o = await resolveFileInput(opts.overlay, { flagName: "--overlay" });
137
+ const v = await resolveUploadInput(opts.video, { flagName: "-v / --video", onFallback: onOssFallback });
138
+ const o = await resolveUploadInput(opts.overlay, { flagName: "--overlay", onFallback: onOssFallback });
134
139
  info(`Uploading video (${fmtSize(v.bytes ?? 0)}) + overlay (${fmtSize(o.bytes ?? 0)})...`);
135
140
  const r = await post("/api/v1/compositions/overlay", {
136
- video: v.url,
137
- overlay_image: o.url,
141
+ ...(v.key ? { video_key: v.key } : { video: v.url }),
142
+ ...(o.key ? { overlay_key: o.key } : { overlay_image: o.url }),
138
143
  });
139
144
  await downloadTo(r.url, opts.output);
140
145
  success(`Saved → ${opts.output}`);
@@ -2,7 +2,9 @@ import fs from "node:fs/promises";
2
2
  import fsSync from "node:fs";
3
3
  import path from "node:path";
4
4
  import { getServer, getApiKey } from "../client.js";
5
+ import { uploadToOss } from "../utils/oss-upload.js";
5
6
  import { info, success, warn } from "../utils/output.js";
7
+ const MAX_BYTES = 50 * 1024 * 1024;
6
8
  const TEMPLATES = [
7
9
  { id: "paper-band", label: "牛皮纸条 横卡", supports: "title, subtitle, badge", useFor: "财经 / 干货 / 商业分析" },
8
10
  { id: "carbon-copy", label: "复写纸 暗调", supports: "title, subtitle", useFor: "情感 / 哲思 / 治愈(默认)" },
@@ -17,6 +19,21 @@ const TEMPLATES = [
17
19
  const TEMPLATE_IDS = new Set(TEMPLATES.map((t) => t.id));
18
20
  const DEFAULT_TEMPLATE = "carbon-copy";
19
21
  const SMALL_IMAGE_WARN_BYTES = 100 * 1024;
22
+ async function uploadOrInline(fileAbs, fileMime, label) {
23
+ const bytes = (await fs.stat(fileAbs)).size;
24
+ try {
25
+ const key = await uploadToOss(fileAbs);
26
+ return { key, bytes };
27
+ }
28
+ catch (e) {
29
+ if (bytes > MAX_BYTES) {
30
+ throw new Error(`${label} 直传 OSS 失败 (${e instanceof Error ? e.message : e}),且文件 ${fmtSize(bytes)} 超过内联回退上限 ${fmtSize(MAX_BYTES)}。`);
31
+ }
32
+ warn(`${label}: OSS 直传不可用,回退内联上传 (${e instanceof Error ? e.message : e})`);
33
+ const buf = await fs.readFile(fileAbs);
34
+ return { uri: `data:${fileMime};base64,${buf.toString("base64")}`, bytes };
35
+ }
36
+ }
20
37
  async function resolveImage(input) {
21
38
  const t = input.trim();
22
39
  if (!t)
@@ -37,9 +54,8 @@ async function resolveImage(input) {
37
54
  if (!mime) {
38
55
  throw new Error(`--image: unsupported extension ${ext} (use png/jpg/jpeg/webp)`);
39
56
  }
40
- const buf = await fs.readFile(abs);
41
- const url = `data:${mime};base64,${buf.toString("base64")}`;
42
- return { url, bytes: buf.byteLength };
57
+ const up = await uploadOrInline(abs, mime, "--image");
58
+ return { key: up.key, url: up.uri, bytes: up.bytes };
43
59
  }
44
60
  function fmtSize(b) {
45
61
  if (b < 1024)
@@ -177,7 +193,7 @@ export function registerCover(program) {
177
193
  if (missing.length > 0) {
178
194
  throw new Error(`required: ${missing.join(", ")}`);
179
195
  }
180
- const { url: imageUrl, bytes } = await resolveImage(opts.image);
196
+ const { key: imageKey, url: imageUrl, bytes } = await resolveImage(opts.image);
181
197
  if (typeof bytes === "number" && bytes < SMALL_IMAGE_WARN_BYTES) {
182
198
  warn(`--image is ${fmtSize(bytes)} — that's quite small, the cover may look blurry. ` +
183
199
  `For best results use a source image of at least 1080×1920.`);
@@ -188,10 +204,13 @@ export function registerCover(program) {
188
204
  }
189
205
  const params = buildParamsPayload(opts.param, opts.params);
190
206
  const body = {
191
- image_url: imageUrl,
192
207
  template,
193
208
  title: opts.title,
194
209
  };
210
+ if (imageKey)
211
+ body.image_key = imageKey;
212
+ else
213
+ body.image_url = imageUrl;
195
214
  if (opts.subtitle)
196
215
  body.subtitle = opts.subtitle;
197
216
  if (opts.badge)
@@ -251,17 +270,22 @@ export function registerCover(program) {
251
270
  if (!fsSync.existsSync(coverAbs)) {
252
271
  throw new Error(`cover not found: ${coverAbs}`);
253
272
  }
254
- const coverBuf = await fs.readFile(coverAbs);
255
- const videoBuf = await fs.readFile(videoAbs);
256
273
  const coverExt = path.extname(coverAbs).toLowerCase();
257
274
  const coverMime = coverExt === ".jpg" || coverExt === ".jpeg" ? "image/jpeg" : "image/png";
258
- const body = {
259
- cover_data_uri: `data:${coverMime};base64,${coverBuf.toString("base64")}`,
260
- video_data_uri: `data:video/mp4;base64,${videoBuf.toString("base64")}`,
261
- };
275
+ const coverUp = await uploadOrInline(coverAbs, coverMime, "cover");
276
+ const videoUp = await uploadOrInline(videoAbs, "video/mp4", "video");
277
+ const body = {};
278
+ if (coverUp.key)
279
+ body.cover_key = coverUp.key;
280
+ else
281
+ body.cover_data_uri = coverUp.uri;
282
+ if (videoUp.key)
283
+ body.video_key = videoUp.key;
284
+ else
285
+ body.video_data_uri = videoUp.uri;
262
286
  if (typeof opts.fps === "number")
263
287
  body.fps = opts.fps;
264
- info(`Prepending cover (${fmtSize(coverBuf.byteLength)}) → video (${fmtSize(videoBuf.byteLength)})...`);
288
+ info(`Prepending cover (${fmtSize(coverUp.bytes)}) → video (${fmtSize(videoUp.bytes)})...`);
265
289
  const server = getServer().replace(/\/+$/, "");
266
290
  const apiKey = getApiKey();
267
291
  const headers = { "content-type": "application/json" };
@@ -3,6 +3,7 @@ import fsSync from "node:fs";
3
3
  import path from "node:path";
4
4
  import os from "node:os";
5
5
  import { post, getServer } from "../client.js";
6
+ import { uploadToOss } from "../utils/oss-upload.js";
6
7
  import { waitForTask } from "../utils/task-waiter.js";
7
8
  import { downloadTo } from "../utils/download.js";
8
9
  import { formatUsd, humanDuration, info, print, success, warn } from "../utils/output.js";
@@ -38,14 +39,15 @@ async function resolveTextOrFile(input) {
38
39
  }
39
40
  return input;
40
41
  }
41
- async function resolveRefImage(input, flagName) {
42
+ const REF_IMAGE_MAX_BYTES = 15 * 1024 * 1024;
43
+ async function resolveRefImageUpload(input, flagName) {
42
44
  if (input === undefined)
43
45
  return undefined;
44
46
  const t = input.trim();
45
47
  if (!t)
46
48
  return undefined;
47
49
  if (/^https?:\/\//i.test(t) || t.startsWith("data:"))
48
- return t;
50
+ return { passthrough: t };
49
51
  const abs = path.resolve(t);
50
52
  if (!fsSync.existsSync(abs)) {
51
53
  throw new Error(`${flagName}: local file not found: ${abs}`);
@@ -58,8 +60,19 @@ async function resolveRefImage(input, flagName) {
58
60
  if (!mime) {
59
61
  throw new Error(`${flagName}: unsupported extension ${ext} (use png/jpg/jpeg/webp)`);
60
62
  }
61
- const buf = await fs.readFile(abs);
62
- return `data:${mime};base64,${buf.toString("base64")}`;
63
+ try {
64
+ const key = await uploadToOss(abs);
65
+ return { key };
66
+ }
67
+ catch (e) {
68
+ const sz = (await fs.stat(abs)).size;
69
+ if (sz > REF_IMAGE_MAX_BYTES) {
70
+ throw new Error(`${flagName} 直传 OSS 失败 (${e instanceof Error ? e.message : e}),且文件超过内联回退上限 ${REF_IMAGE_MAX_BYTES} 字节。`);
71
+ }
72
+ warn(`${flagName}: OSS 直传不可用,回退内联上传 (${e instanceof Error ? e.message : e})`);
73
+ const buf = await fs.readFile(abs);
74
+ return { passthrough: `data:${mime};base64,${buf.toString("base64")}` };
75
+ }
63
76
  }
64
77
  async function loadRecipe(recipePath) {
65
78
  const raw = await fs.readFile(recipePath, "utf-8");
@@ -512,17 +525,30 @@ export function registerCreate(program) {
512
525
  " # (compose your script using /tmp/ref.txt as raw material in your agent)\n" +
513
526
  " rf create --script @final.txt -d 60 # render it");
514
527
  }
515
- const resolvedChar = await resolveRefImage(body.character_ref, "--character-ref");
516
- if (resolvedChar !== undefined)
517
- body.character_ref = resolvedChar;
518
- else
519
- delete body.character_ref;
528
+ const resolvedChar = await resolveRefImageUpload(body.character_ref, "--character-ref");
529
+ delete body.character_ref;
530
+ delete body.character_ref_key;
531
+ if (resolvedChar?.key)
532
+ body.character_ref_key = resolvedChar.key;
533
+ else if (resolvedChar?.passthrough)
534
+ body.character_ref = resolvedChar.passthrough;
520
535
  if (body.brand?.logo_url) {
521
- const resolvedLogo = await resolveRefImage(body.brand.logo_url, "--brand-logo");
522
- if (resolvedLogo !== undefined)
523
- body.brand.logo_url = resolvedLogo;
524
- else
525
- delete body.brand.logo_url;
536
+ const resolvedLogo = await resolveRefImageUpload(body.brand.logo_url, "--brand-logo");
537
+ delete body.brand.logo_url;
538
+ delete body.brand_logo_key;
539
+ if (resolvedLogo?.key)
540
+ body.brand_logo_key = resolvedLogo.key;
541
+ else if (resolvedLogo?.passthrough)
542
+ body.brand.logo_url = resolvedLogo.passthrough;
543
+ }
544
+ if (body.cover?.base) {
545
+ const resolvedBase = await resolveRefImageUpload(body.cover.base, "--cover-base");
546
+ delete body.cover.base;
547
+ delete body.cover_base_key;
548
+ if (resolvedBase?.key)
549
+ body.cover_base_key = resolvedBase.key;
550
+ else if (resolvedBase?.passthrough)
551
+ body.cover.base = resolvedBase.passthrough;
526
552
  }
527
553
  const hasTopic = typeof body.topic === "string" && body.topic.trim().length > 0;
528
554
  const hasScript = typeof body.script === "string" && body.script.trim().length > 0;
@@ -545,7 +571,11 @@ export function registerCreate(program) {
545
571
  }
546
572
  info(`Submitting create task (≈ ${formatUsd(estimate)})...`);
547
573
  const submitted = await post("/api/v1/pipelines/standard", finalBody);
548
- await saveLastCreate(finalBody).catch((e) => {
574
+ const toSave = { ...finalBody };
575
+ delete toSave.character_ref_key;
576
+ delete toSave.brand_logo_key;
577
+ delete toSave.cover_base_key;
578
+ await saveLastCreate(toSave).catch((e) => {
549
579
  warn(`Could not save last-create.json: ${e.message}`);
550
580
  });
551
581
  if (opts.wait === false) {
@@ -2,9 +2,10 @@ import fs from "node:fs/promises";
2
2
  import fsSync from "node:fs";
3
3
  import path from "node:path";
4
4
  import { post } from "../client.js";
5
+ import { uploadToOss } from "../utils/oss-upload.js";
5
6
  import { waitForTask } from "../utils/task-waiter.js";
6
7
  import { downloadTo } from "../utils/download.js";
7
- import { formatUsd, humanDuration, info, isJson, print, success } from "../utils/output.js";
8
+ import { formatUsd, humanDuration, info, isJson, print, success, warn } from "../utils/output.js";
8
9
  const VIDEO_EXT_TO_MIME = {
9
10
  ".mp4": "video/mp4",
10
11
  ".mov": "video/quicktime",
@@ -33,6 +34,15 @@ export function registerDub(program) {
33
34
  .option("--game-volume <n>", "原视频自带声音的音量 0..1 (默认 0.6; 设 0 = 完全静音原声, 只留配音)", (v) => parseFloat(v), 0.6)
34
35
  .option("--no-subtitles", "不烧录字幕 (默认烧录)")
35
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
+ }, [])
36
46
  .option("--theme <id>", "可选主题 id (影响字幕底板 / 配色风格)")
37
47
  .option("--vision-model <id>", "反推用的视觉模型 (默认 qwen/qwen3-vl-flash)")
38
48
  .option("--script-model <id>", "写解说脚本用的 LLM (默认服务端配置模型)")
@@ -47,6 +57,10 @@ export function registerDub(program) {
47
57
  " 3. 用熊小二音色合成配音 (可 --voice 指定其它音色)",
48
58
  " 4. 把配音叠到原视频上, 原视频自带声音压到 60% 垫底 (可 --game-volume 调)",
49
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` 先生成, 比如固定的「关注」引导卡, 生成一次反复用)",
50
64
  "",
51
65
  "认证:",
52
66
  " 需要 API key — `rf login <key>` 或 $REELFORGE_API_KEY 或 --api-key <key>。",
@@ -71,6 +85,11 @@ export function registerDub(program) {
71
85
  "",
72
86
  " # 先看效果再决定 (preview, 不渲染 MP4)",
73
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",
74
93
  ].join("\n"))
75
94
  .action(async (videoArg, opts) => {
76
95
  const abs = path.resolve(videoArg);
@@ -89,22 +108,69 @@ export function registerDub(program) {
89
108
  if (!["plate", "stroke", "cinema"].includes(subtitleStyle)) {
90
109
  throw new Error(`--subtitle-style must be plate | stroke | cinema (got ${opts.subtitleStyle})`);
91
110
  }
92
- const buf = await fs.readFile(abs);
93
- if (buf.byteLength > MAX_BYTES) {
94
- throw new Error(`video is ${fmtSize(buf.byteLength)} — exceeds the ${fmtSize(MAX_BYTES)} upload cap. ` +
95
- `Compress / trim it first (e.g. ffmpeg -crf 28).`);
96
- }
97
- info(`Uploading ${path.basename(abs)} (${fmtSize(buf.byteLength)})...`);
98
111
  const body = {
99
- video: `data:${mime};base64,${buf.toString("base64")}`,
100
112
  voice: opts.voice || "熊小二",
101
113
  tts_model: opts.ttsModel || "vox/index-tts-2",
102
114
  game_audio_volume: gameVolume,
103
115
  subtitles: opts.subtitles !== false,
104
116
  subtitle_style: subtitleStyle,
117
+ cover: opts.cover !== false,
105
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;
106
127
  if (opts.theme)
107
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
+ }
108
174
  if (opts.visionModel)
109
175
  body.vision_model = opts.visionModel;
110
176
  if (opts.scriptModel)
@@ -2,7 +2,9 @@ import fs from "node:fs/promises";
2
2
  import fsSync from "node:fs";
3
3
  import path from "node:path";
4
4
  import { post } from "../client.js";
5
- import { info, isJson, print, success } from "../utils/output.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;
6
8
  const FILE_EXT_TO_MIME = {
7
9
  ".pdf": "application/pdf",
8
10
  ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
@@ -13,13 +15,13 @@ async function resolveSource(input) {
13
15
  if (!t)
14
16
  throw new Error("--reference / source is empty");
15
17
  if (t.startsWith("data:"))
16
- return t;
18
+ return { source: t };
17
19
  if (/^https?:\/\//i.test(t))
18
- return t;
20
+ return { source: t };
19
21
  if (/v\.douyin\.com\//i.test(t) || /复制此链接/.test(t) || /打开抖音/.test(t))
20
- return t;
22
+ return { source: t };
21
23
  if (/^[a-z0-9][a-z0-9-._]*\/[a-z0-9][a-z0-9-._]*$/i.test(t) && !t.split("/")[0].includes(".")) {
22
- return t;
24
+ return { source: t };
23
25
  }
24
26
  const abs = path.resolve(t);
25
27
  if (!fsSync.existsSync(abs)) {
@@ -32,8 +34,19 @@ async function resolveSource(input) {
32
34
  throw new Error(`unsupported file extension "${ext}". ` +
33
35
  `Supported: ${Object.keys(FILE_EXT_TO_MIME).join(" / ")}, or pass a URL / github owner/repo / 抖音 link.`);
34
36
  }
35
- const buf = await fs.readFile(abs);
36
- return `data:${mime};base64,${buf.toString("base64")}`;
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
+ }
37
50
  }
38
51
  export function registerExtract(program) {
39
52
  program
@@ -81,7 +94,7 @@ export function registerExtract(program) {
81
94
  ].join("\n"))
82
95
  .action(async (source, opts) => {
83
96
  const resolved = await resolveSource(source);
84
- const r = await post("/api/v1/reference/extract", { source: resolved });
97
+ const r = await post("/api/v1/reference/extract", { ...resolved });
85
98
  if (opts.output) {
86
99
  const outAbs = path.resolve(opts.output);
87
100
  await fs.mkdir(path.dirname(outAbs), { recursive: true });
@@ -2,14 +2,16 @@ import fs from "node:fs/promises";
2
2
  import fsSync from "node:fs";
3
3
  import path from "node:path";
4
4
  import { post } from "../client.js";
5
+ import { uploadToOss } from "../utils/oss-upload.js";
5
6
  import { downloadTo } from "../utils/download.js";
6
- import { print, success } from "../utils/output.js";
7
+ import { print, success, warn } from "../utils/output.js";
8
+ const MAX_REF_BYTES = 15 * 1024 * 1024;
7
9
  async function resolveImageRef(input) {
8
10
  const t = input.trim();
9
11
  if (!t)
10
12
  throw new Error("--image: empty value");
11
13
  if (/^https?:\/\//i.test(t) || t.startsWith("data:"))
12
- return t;
14
+ return { url: t };
13
15
  const abs = path.resolve(t);
14
16
  if (!fsSync.existsSync(abs)) {
15
17
  throw new Error(`--image: local file not found: ${abs}`);
@@ -22,8 +24,19 @@ async function resolveImageRef(input) {
22
24
  if (!mime) {
23
25
  throw new Error(`--image: unsupported extension ${ext} (use png/jpg/jpeg/webp)`);
24
26
  }
25
- const buf = await fs.readFile(abs);
26
- return `data:${mime};base64,${buf.toString("base64")}`;
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
+ }
27
40
  }
28
41
  export function registerImages(program) {
29
42
  const images = program
@@ -69,7 +82,13 @@ export function registerImages(program) {
69
82
  if (opts.image.length > 3) {
70
83
  throw new Error(`--image accepts at most 3 refs (got ${opts.image.length})`);
71
84
  }
72
- body.image_urls = await Promise.all(opts.image.map(resolveImageRef));
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;
73
92
  }
74
93
  const r = await post("/api/v1/images/generate", body);
75
94
  if (opts.output && r.images?.[0]) {
@@ -2,7 +2,8 @@ import fs from "node:fs/promises";
2
2
  import fsSync from "node:fs";
3
3
  import path from "node:path";
4
4
  import { post } from "../client.js";
5
- import { info, isJson, print } from "../utils/output.js";
5
+ import { uploadToOss } from "../utils/oss-upload.js";
6
+ import { info, isJson, print, warn } from "../utils/output.js";
6
7
  const MEDIA_EXT_TO_MIME = {
7
8
  ".mp4": "video/mp4",
8
9
  ".mov": "video/quicktime",
@@ -112,8 +113,15 @@ export function registerMedia(program) {
112
113
  throw new Error(`unsupported extension "${ext}". Supported: ${Object.keys(MEDIA_EXT_TO_MIME).join(" / ")}, ` +
113
114
  `or pass a URL.`);
114
115
  }
115
- const buf = await fs.readFile(abs);
116
- body = { data_uri: `data:${mime};base64,${buf.toString("base64")}` };
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
+ }
117
125
  label = path.basename(abs);
118
126
  }
119
127
  const r = await post("/api/v1/media/probe", body);
package/dist/index.js CHANGED
@@ -35,6 +35,7 @@ import { registerMedia } from "./commands/media.js";
35
35
  import { registerScript } from "./commands/script.js";
36
36
  import { registerCompose } from "./commands/compose.js";
37
37
  import { registerDub } from "./commands/dub.js";
38
+ import { registerClip } from "./commands/clip.js";
38
39
  import { registerVlog } from "./commands/vlog.js";
39
40
  import { error as logError } from "./utils/output.js";
40
41
  import { ApiCallError } from "./client.js";
@@ -149,6 +150,7 @@ registerMedia(program);
149
150
  registerScript(program);
150
151
  registerCompose(program);
151
152
  registerDub(program);
153
+ registerClip(program);
152
154
  registerVlog(program);
153
155
  async function main() {
154
156
  if (process.argv.length <= 2) {
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import fsSync from "node:fs";
3
3
  import path from "node:path";
4
+ import { uploadToOss } from "./oss-upload.js";
4
5
  export const EXT_TO_MIME = {
5
6
  ".png": "image/png",
6
7
  ".jpg": "image/jpeg",
@@ -57,3 +58,45 @@ export function fmtSize(bytes) {
57
58
  return `${(bytes / 1024).toFixed(1)} KB`;
58
59
  return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
59
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
+ }
@@ -0,0 +1,43 @@
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
+ ".pdf": "application/pdf",
20
+ };
21
+ export function mimeForFile(filePath) {
22
+ return EXT_TO_MIME[path.extname(filePath).toLowerCase()] || "application/octet-stream";
23
+ }
24
+ export async function uploadToOss(filePath) {
25
+ if (!fsSync.existsSync(filePath))
26
+ throw new Error(`file not found: ${filePath}`);
27
+ const ext = path.extname(filePath).toLowerCase();
28
+ const contentType = mimeForFile(filePath);
29
+ const signed = await post("/api/v1/uploads/sign", { ext, content_type: contentType });
30
+ if (!signed?.put_url || !signed?.key)
31
+ throw new Error("uploads/sign returned no put_url/key");
32
+ const bytes = await fs.readFile(filePath);
33
+ const res = await fetch(signed.put_url, {
34
+ method: "PUT",
35
+ headers: { "Content-Type": contentType },
36
+ body: bytes,
37
+ });
38
+ if (!res.ok) {
39
+ const detail = await res.text().catch(() => "");
40
+ throw new Error(`OSS PUT failed: ${res.status} ${res.statusText} ${detail.slice(0, 200)}`);
41
+ }
42
+ return signed.key;
43
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reelforge",
3
- "version": "1.23.0",
3
+ "version": "1.24.1",
4
4
  "description": "AI 视频生成 CLI。一句话主题或自己的脚本 → 自动出抖音/TikTok/视频号竖屏 MP4。安装即用:`reelforge` 或短别名 `rf`。",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",