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.
- package/dist/commands/assets.js +15 -4
- package/dist/commands/clip.js +139 -0
- package/dist/commands/compose.js +20 -15
- package/dist/commands/compositions.js +23 -18
- package/dist/commands/cover.js +36 -12
- package/dist/commands/create.js +45 -15
- package/dist/commands/dub.js +74 -8
- package/dist/commands/extract.js +21 -8
- package/dist/commands/images.js +24 -5
- package/dist/commands/media.js +11 -3
- package/dist/index.js +2 -0
- package/dist/utils/file-upload.js +43 -0
- package/dist/utils/oss-upload.js +43 -0
- package/package.json +1 -1
package/dist/commands/assets.js
CHANGED
|
@@ -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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/commands/compose.js
CHANGED
|
@@ -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
|
-
|
|
183
|
+
const fileKeys = {};
|
|
192
184
|
for (const p of specPaths) {
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
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 (${
|
|
198
|
-
const body = { spec
|
|
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,
|
|
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
|
|
56
|
+
const { resolved, totalBytes } = await resolveUploadInputs(videos, {
|
|
56
57
|
flagName: "<videos...>",
|
|
58
|
+
onFallback: onOssFallback,
|
|
57
59
|
});
|
|
58
60
|
const bgm = opts.bgm
|
|
59
|
-
? await
|
|
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:
|
|
68
|
+
videos: videoUris,
|
|
69
|
+
video_keys: videoKeys,
|
|
65
70
|
method: opts.method,
|
|
66
|
-
|
|
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
|
|
87
|
-
const b = await
|
|
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
|
|
111
|
-
const aud = await
|
|
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
|
|
133
|
-
const o = await
|
|
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}`);
|
package/dist/commands/cover.js
CHANGED
|
@@ -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
|
|
41
|
-
|
|
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
|
|
259
|
-
|
|
260
|
-
|
|
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(
|
|
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" };
|
package/dist/commands/create.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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
|
-
|
|
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) {
|
package/dist/commands/dub.js
CHANGED
|
@@ -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)
|
package/dist/commands/extract.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
36
|
-
|
|
37
|
+
try {
|
|
38
|
+
const key = await uploadToOss(abs);
|
|
39
|
+
return { source_key: key };
|
|
40
|
+
}
|
|
41
|
+
catch (e) {
|
|
42
|
+
const sz = (await fs.stat(abs)).size;
|
|
43
|
+
if (sz > MAX_BYTES) {
|
|
44
|
+
throw new Error(`${path.basename(abs)} 直传 OSS 失败 (${e instanceof Error ? e.message : e}),且文件超过内联回退上限 ${MAX_BYTES} 字节。`);
|
|
45
|
+
}
|
|
46
|
+
warn(`source: OSS 直传不可用,回退内联上传 (${e instanceof Error ? e.message : e})`);
|
|
47
|
+
const buf = await fs.readFile(abs);
|
|
48
|
+
return { source: `data:${mime};base64,${buf.toString("base64")}` };
|
|
49
|
+
}
|
|
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", {
|
|
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 });
|
package/dist/commands/images.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
|
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
|
-
|
|
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]) {
|
package/dist/commands/media.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
116
|
-
|
|
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
|
+
}
|