jimeng.mjs 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863
  1. // lib/jimeng.mjs — 即梦(火山引擎)真实通道封装
  2. //
  3. // ★ 本文件是 skill-mv 唯一的「真·生成通道」。实测(2026-10-13,本容器):
  4. // 文生图3.0 ¥0.20/张 —— 提交→轮询→下载 全链路跑通,返回 1024×1024 PNG 49.7KB
  5. // 图生视频720p ¥0.28/秒 —— 5 秒 121 帧跑通,返回 960×960 h264 mp4 1.09MB,实扣 ¥1.41
  6. // 七牛上传 免费 —— 换 uptoken → multipart 上传 → 返回 file-cloud.fmode.cn 直链
  7. //
  8. // 三段式调用(所有即梦生成能力都走这个模式):
  9. // ① 提交 POST {base}/<endpoint> body {...params, token} → {code:200,data:{workId}}
  10. // ② 轮询 POST {base}/getDataByTask02 body {workId, routerName, token} → {data:{isFinish}}
  11. // ③ 取件 GET {parseBase}/classes/ImagineWork/{workId} header X-Parse-Application-Id → {images[],videos[]}
  12. //
  13. // 【凭据铁律】sessionToken 只从 环境变量 FMODE_SESSION_TOKEN 或 /opt/data/fmode-identity.json
  14. // 运行时读取;本文件(以及整个技能仓)绝不写入、绝不硬编码、绝不提交任何 token。
  15. // 错误信息里也只出现脱敏后的 token。
  16. import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
  17. import { dirname, extname, join, basename } from 'node:path';
  18. import { randomBytes } from 'node:crypto';
  19. import { ProviderUnavailableError, GatewayError } from './api.mjs';
  20. import { run } from './ffmpeg.mjs';
  21. // ==================== 通道配置(可被环境变量覆盖) ====================
  22. export const JIMENG = {
  23. base: process.env.FMODE_JIMENG_BASE || 'https://server.fmode.cn/api/volcengine/jimeng',
  24. parseBase: (process.env.FMODE_PARSE_BASE || 'https://server.fmode.cn/parse').replace(/\/+$/, ''),
  25. parseAppId: process.env.FMODE_PARSE_APPID || 'ncloudmaster',
  26. cdnHost: 'https://file-cloud.fmode.cn',
  27. uploadHost: process.env.FMODE_QINIU_UPLOAD_HOST || 'upload.qiniup.com',
  28. cdnCompany: process.env.FMODE_CDN_COMPANY || 'XlTE6uWQEQ',
  29. workClass: 'ImagineWork',
  30. };
  31. // ==================== 端点清单与费用表 ====================
  32. /**
  33. * 即梦全部端点。cost = 单价(元),unit = 计价单位。
  34. * 依据 `_kb/07-即梦API工具箱/参考文档/即梦AI技能汇总.md`,实测校验通过。
  35. */
  36. export const ENDPOINTS = {
  37. // —— 文生图 / 图生图 ——
  38. text2imgV3: { path: '/getText2ImgV3', router: 'getText2ImgV3', cost: 0.2, unit: '张', label: '文生图3.0' },
  39. text2imgV31: { path: '/getText2ImgV31', router: 'getText2ImgV31', cost: 0.2, unit: '张', label: '文生图3.1' },
  40. imgV4: { path: '/getImgV4', router: 'getImgV4', cost: 0.22, unit: '张', label: '图片生成4.0(文生图/多图组合)' },
  41. img2imgV3: { path: '/getImg2ImgV3', router: 'getImg2ImgV3', cost: 0.2, unit: '张', label: '图生图3.0(智能参考)' },
  42. // —— 电商专用 ——
  43. imgV4Goods: { path: '/getImgV4_goods', router: 'getImgV4_goods', cost: 0.22, unit: '张', label: '商品提取' },
  44. imgV4Pod: { path: '/getImgV4_pod', router: 'getImgV4_pod', cost: 0.22, unit: '张', label: '素材提取' },
  45. clothesV2: { path: '/getClothesV2', router: 'getClothesV2', cost: 0.22, unit: '张', label: '虚拟换装v2' },
  46. // —— 图像编辑 ——
  47. inpaint: { path: '/getInpaint', router: 'getInpaint', cost: 0.2, unit: '张', label: '局部重绘' },
  48. superRes: { path: '/getSuperResolution', router: 'getSuperResolution', cost: 0.4, unit: '张', label: '智能超清' },
  49. // —— 动作模仿 ——
  50. actor: { path: '/getActor', router: 'getActor', cost: 0.5, unit: '秒', minCost: 4, maxCost: 6, label: '动作模仿1.0' },
  51. actorV2: { path: '/getActorV2', router: 'getActorV2', cost: 0.4, unit: '秒', minCost: 3.2, maxCost: 5, label: '动作模仿2.0' },
  52. // —— 视频生成 ——
  53. video720: { path: '/getVideoV3_720p', router: 'getVideoV3_720p', cost: 0.28, unit: '秒', label: '图生视频3.0 720p' },
  54. video1080: { path: '/getVideoV3_1080p', router: 'getVideoV3_1080p', cost: 0.63, unit: '秒', label: '图生视频3.0 1080p' },
  55. videoPro: { path: '/getVideoV3_Pro', router: 'getVideoV3_Pro', cost: 1.0, unit: '秒', label: '图生视频3.0 Pro' },
  56. // —— 数字人 v1.5 三步 ——
  57. ohIdentify: { path: '/getOhIdentifyMain', router: 'getOhIdentifyMain', cost: 0, unit: '次', label: '数字人·主体识别' },
  58. ohDetect: { path: '/getOhDetectMain', router: 'getOhDetectMain', cost: 0, unit: '次', label: '数字人·主体检测' },
  59. omniHuman: { path: '/getOmniHuman', router: 'getOmniHuman', cost: 1.0, unit: '秒', label: '数字人·视频生成' },
  60. };
  61. /**
  62. * 图生视频 frames 的硬约束(实测:传 49 被拒 "frames 取值范围为[121,241]";
  63. * 传 122 被拒 "x2v pipeline: frames % 24 should be 1")。
  64. * 因此 frames 必须 ≡ 1 (mod 24),可用值只有 121 / 145 / 169 / 193 / 217 / 241,
  65. * 对应时长 5 / 6 / 7 / 8 / 9 / 10 秒。
  66. */
  67. export const VIDEO_FRAMES = { min: 121, max: 241, fps: 24, step: 24 };
  68. /** 合法的帧数档位(5s~10s,每 1 秒一档) */
  69. export const FRAME_STEPS = [121, 145, 169, 193, 217, 241];
  70. /** 秒 → 帧(就近吸附到合法档位,保证 frames ≡ 1 (mod 24)) */
  71. export function secondsToFrames(seconds) {
  72. const target = Number(seconds) || 5;
  73. let best = FRAME_STEPS[0];
  74. let bestDelta = Infinity;
  75. for (const f of FRAME_STEPS) {
  76. const d = Math.abs(framesToSeconds(f) - target);
  77. if (d < bestDelta) {
  78. bestDelta = d;
  79. best = f;
  80. }
  81. }
  82. return best;
  83. }
  84. /** 帧 → 秒 */
  85. export function framesToSeconds(frames) {
  86. return +((Number(frames) - 1) / VIDEO_FRAMES.fps).toFixed(3);
  87. }
  88. /** 校验帧数是否合法(提交前自检,避免白等一次 503/502) */
  89. export function assertValidFrames(frames) {
  90. const f = Number(frames);
  91. if (!Number.isInteger(f)) throw new BadFramesError(`frames 必须是整数,收到 ${frames}`);
  92. if (f < VIDEO_FRAMES.min || f > VIDEO_FRAMES.max) {
  93. throw new BadFramesError(`frames 取值范围为 [${VIDEO_FRAMES.min}, ${VIDEO_FRAMES.max}],收到 ${f}`);
  94. }
  95. if (f % VIDEO_FRAMES.step !== 1) {
  96. throw new BadFramesError(`frames % 24 必须为 1(合法值:${FRAME_STEPS.join('/')}),收到 ${f}`);
  97. }
  98. return f;
  99. }
  100. /** 帧数不合法时抛出(本地自检错误,不是通道错误) */
  101. export class BadFramesError extends Error {
  102. constructor(message) {
  103. super(message);
  104. this.name = 'BadFramesError';
  105. }
  106. }
  107. /** 估算费用(元),调用前必须先打印 */
  108. export function estimateCost(kind, { seconds = 0, count = 1 } = {}) {
  109. const ep = ENDPOINTS[kind];
  110. if (!ep) return 0;
  111. if (ep.unit === '秒') {
  112. const raw = ep.cost * (Number(seconds) || 0);
  113. const lo = ep.minCost ?? 0;
  114. const hi = ep.maxCost ?? Infinity;
  115. return Math.min(hi, Math.max(lo, raw));
  116. }
  117. return ep.cost * (Number(count) || 1);
  118. }
  119. // ==================== 尺寸约束(实测踩坑:尺寸不合法要到轮询阶段才报错) ====================
  120. //
  121. // 各端点的尺寸约束不同,传错不会在提交时报错,而要等轮询才回 algo_msg,
  122. // 白等一轮 30 秒。所以本地必须先卡住:
  123. // text2imgV3 / text2imgV31 / img2imgV3:宽高积 ∈ [512², 2048²],宽高比 ∈ [1/3, 3]
  124. // imgV4:宽高积 ∈ [1024², 4096²],宽高比 ∈ [1/3, 3]
  125. // 实测报错原文举例:'height or width invalid'(getImgV4 传 1280×720,面积 921600 < 1048576)
  126. const SIZE_BOUNDS = {
  127. text2imgV3: { minArea: 512 * 512, maxArea: 2048 * 2048 },
  128. text2imgV31: { minArea: 512 * 512, maxArea: 2048 * 2048 },
  129. img2imgV3: { minArea: 512 * 512, maxArea: 2048 * 2048 },
  130. imgV4: { minArea: 1024 * 1024, maxArea: 4096 * 4096 },
  131. imgV4Pod: { minArea: 1024 * 1024, maxArea: 4096 * 4096 },
  132. imgV4Goods: { minArea: 1024 * 1024, maxArea: 4096 * 4096 },
  133. superRes: { minArea: 0, maxArea: Infinity },
  134. };
  135. /** 各端点 × 比例 × 档位的推荐尺寸(都取自 KB README_v2 的「推荐宽高」) */
  136. const SIZE_TABLE = {
  137. // 文生图 3.0/3.1、图生图 3.0:1K 档
  138. 1: {
  139. '16:9': { width: 1664, height: 936 },
  140. '9:16': { width: 936, height: 1664 },
  141. '1:1': { width: 1328, height: 1328 },
  142. '4:3': { width: 1472, height: 1104 },
  143. '3:4': { width: 1104, height: 1472 },
  144. },
  145. // 2K 档(同样满足 2048² 上限)
  146. 2: {
  147. '16:9': { width: 2560, height: 1440 },
  148. '9:16': { width: 1440, height: 2560 },
  149. '1:1': { width: 2048, height: 2048 },
  150. '4:3': { width: 2304, height: 1728 },
  151. '3:4': { width: 1728, height: 2304 },
  152. },
  153. };
  154. /** getImgV4 必须 ≥ 1024²,所以没有真正的 1K 档,最低就是这些 */
  155. const SIZE_TABLE_V4 = {
  156. '16:9': { width: 2560, height: 1440 },
  157. '9:16': { width: 1440, height: 2560 },
  158. '1:1': { width: 2048, height: 2048 },
  159. '4:3': { width: 2304, height: 1728 },
  160. '3:4': { width: 1728, height: 2304 },
  161. };
  162. /** 尺寸不合法(本地自检错误,避免白等一轮轮询) */
  163. export class BadSizeError extends Error {
  164. constructor(message, hint) {
  165. super(message);
  166. this.name = 'BadSizeError';
  167. this.hint = hint;
  168. }
  169. }
  170. /**
  171. * 取某个端点在给定比例/档位下的合法尺寸。
  172. * @param {string} endpoint ENDPOINTS key(如 'text2imgV3' / 'imgV4' / 'img2imgV3')
  173. * @param {string} aspect '16:9' | '9:16' | '1:1' | '4:3' | '3:4'
  174. * @param {'1k'|'2k'} [tier]
  175. */
  176. export function resolveSize(endpoint, aspect = '16:9', tier = '1k') {
  177. const isV4 = /^imgV4/.test(endpoint);
  178. const table = isV4 ? SIZE_TABLE_V4 : SIZE_TABLE[tier === '2k' ? 2 : 1];
  179. const size = table[aspect] || table['16:9'];
  180. return { ...size };
  181. }
  182. /** 提交前校验尺寸;不合法直接抛,附可操作建议 */
  183. export function assertValidSize(endpoint, { width, height } = {}) {
  184. const b = SIZE_BOUNDS[endpoint];
  185. if (!b) return { width, height }; // 无约束的端点(超清等)放行
  186. const w = Number(width);
  187. const h = Number(height);
  188. if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) {
  189. throw new BadSizeError(`${endpoint} 尺寸非法:${width}×${height}`);
  190. }
  191. const area = w * h;
  192. const ratio = w / h;
  193. const problems = [];
  194. if (area < b.minArea) {
  195. problems.push(`面积 ${w}×${h}=${area} 小于下限 ${b.minArea}`);
  196. }
  197. if (area > b.maxArea) {
  198. problems.push(`面积 ${w}×${h}=${area} 超过上限 ${b.maxArea}`);
  199. }
  200. if (ratio > 3 || ratio < 1 / 3) {
  201. problems.push(`宽高比 ${ratio.toFixed(3)} 超出 [1/3, 3]`);
  202. }
  203. if (problems.length) {
  204. const sug = resolveSize(endpoint, ratio >= 1 ? '16:9' : '9:16', '2k');
  205. throw new BadSizeError(
  206. `${endpoint} 尺寸不合法:${problems.join(';')}(即梦会返回 'height or width invalid')`,
  207. `改用 resolveSize('${endpoint}', '<比例>', '<档位>') 取推荐尺寸,例如 ${sug.width}×${sug.height}`
  208. );
  209. }
  210. return { width: w, height: h };
  211. }
  212. // ==================== 凭据 ====================
  213. /** 身份文件候选路径 */
  214. export const IDENTITY_PATHS = [
  215. process.env.FMODE_IDENTITY_FILE,
  216. process.env.FEME_IDENTITY_FILE,
  217. '/opt/data/fmode-identity.json',
  218. ].filter(Boolean);
  219. let _tokenCache = null;
  220. /**
  221. * 取 sessionToken。优先环境变量 FMODE_SESSION_TOKEN,其次身份文件。
  222. * 返回值不带 "Bearer " 前缀;对外输出一律走 maskToken()。
  223. */
  224. export function jimengToken() {
  225. if (_tokenCache) return _tokenCache;
  226. const env = process.env.FMODE_SESSION_TOKEN || process.env.FMODE_TOKEN;
  227. if (env && String(env).trim()) {
  228. _tokenCache = String(env).trim().replace(/^Bearer\s+/i, '');
  229. return _tokenCache;
  230. }
  231. for (const p of IDENTITY_PATHS) {
  232. try {
  233. if (p && existsSync(p)) {
  234. const d = JSON.parse(readFileSync(p, 'utf8'));
  235. if (d.session_token && String(d.session_token).trim()) {
  236. _tokenCache = String(d.session_token).trim().replace(/^Bearer\s+/i, '');
  237. return _tokenCache;
  238. }
  239. }
  240. } catch {
  241. /* 身份文件损坏或不可读 → 继续找 */
  242. }
  243. }
  244. return null;
  245. }
  246. /** token 缺失时抛出可操作错误 */
  247. export function requireJimengToken() {
  248. const t = jimengToken();
  249. if (!t) {
  250. throw new ProviderUnavailableError('即梦通道缺少 sessionToken', {
  251. endpoint: JIMENG.base,
  252. hint:
  253. '即梦通道必须带 sessionToken(缺失会返回 403 请输入API_KEY或用户sessionToken)。二选一:\n' +
  254. ' A) export FMODE_SESSION_TOKEN=<你的sessionToken>\n' +
  255. ' B) 在 /opt/data/fmode-identity.json 写入 session_token 字段\n' +
  256. '(本技能仓绝不把 token 写入任何文件、日志或提交)',
  257. });
  258. }
  259. return t;
  260. }
  261. /** 脱敏显示:只露首尾各 3 位 */
  262. export function maskToken(t) {
  263. const s = String(t || '');
  264. if (!s) return '<empty>';
  265. if (s.length <= 8) return s[0] + '*'.repeat(Math.max(1, s.length - 1));
  266. return `${s.slice(0, 3)}${'*'.repeat(6)}${s.slice(-3)}`;
  267. }
  268. /** 身份文件里是否有可用 sessionToken(不打印值) */
  269. export function jimengAvailable() {
  270. return !!jimengToken();
  271. }
  272. // ==================== 三段式 · ① 提交 ====================
  273. export function sleep(ms) {
  274. return new Promise((r) => setTimeout(r, ms));
  275. }
  276. /** 从即梦响应里抠出人类可读的错误串(它有三种错误体形状,且会套娃) */
  277. export function extractError(json) {
  278. if (!json) return null;
  279. const e = json.error;
  280. if (!e) return null;
  281. if (typeof e === 'string') return e;
  282. let node = e;
  283. // 逐层往下挖:error.message → {tip, data:{code, message, ...}} → algo_msg
  284. for (let depth = 0; depth < 5; depth++) {
  285. if (typeof node === 'string') return node;
  286. if (!node || typeof node !== 'object') break;
  287. const deep = node.data?.message || node.message?.data?.message;
  288. if (typeof node.algo_msg === 'string') return node.algo_msg;
  289. if (node.data && typeof node.data === 'object' && typeof node.data.algo_msg === 'string') return node.data.algo_msg;
  290. if (typeof deep === 'string') return deep;
  291. const next = node.message ?? node.tip;
  292. if (next == null || next === node) break;
  293. if (typeof next === 'string') {
  294. // tip 只是「未知错误」这种壳,继续往 data 里找更具体的
  295. const inner = node.data?.message || node.data?.algo_msg;
  296. if (typeof inner === 'string') return inner;
  297. return next;
  298. }
  299. node = next;
  300. }
  301. return JSON.stringify(e).slice(0, 500);
  302. }
  303. /**
  304. * 提交一个即梦任务。
  305. * @param {string} endpoint ENDPOINTS 的 key,或直接给 '/getXxx'
  306. * @param {object} body 业务参数(token 由本函数注入)
  307. * @returns {Promise<string>} workId
  308. */
  309. export async function submit(endpoint, body, { timeout = 120_000, retries = 3 } = {}) {
  310. const ep = ENDPOINTS[endpoint] || { path: endpoint, router: String(endpoint).replace(/^\//, '') };
  311. const token = requireJimengToken();
  312. const url = `${JIMENG.base}${ep.path}`;
  313. let lastErr = null;
  314. for (let attempt = 0; attempt <= retries; attempt++) {
  315. if (attempt > 0) {
  316. const delay = 5000 * attempt;
  317. process.stderr.write(` [重试 ${attempt}/${retries}] ${delay / 1000}s 后重试(并发限制/网络抖动)\n`);
  318. await sleep(delay);
  319. }
  320. let text;
  321. let status;
  322. try {
  323. const res = await fetch(url, {
  324. method: 'POST',
  325. headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
  326. body: JSON.stringify({ ...body, token: `Bearer ${token}` }),
  327. signal: AbortSignal.timeout(timeout),
  328. });
  329. status = res.status;
  330. text = await res.text();
  331. } catch (e) {
  332. lastErr = new GatewayError(`即梦提交网络错误:${e.message}`, { endpoint: url });
  333. continue;
  334. }
  335. // 网关对未知路径会回前端兜底页,必须识别出来,不能当成成功
  336. if (/^\s*(<!doctype html|<html)/i.test(text)) {
  337. throw new ProviderUnavailableError(`即梦端点不存在或网关异常(返回 HTML 兜底页):${ep.path}`, {
  338. endpoint: url,
  339. status,
  340. hint: '请核对端点名(见 lib/jimeng.mjs ENDPOINTS,或 fmode-mv jimeng --endpoints)。',
  341. });
  342. }
  343. let json = null;
  344. try {
  345. json = JSON.parse(text);
  346. } catch {
  347. throw new GatewayError(`即梦返回非 JSON(HTTP ${status}):${String(text).slice(0, 200)}`, { endpoint: url, status });
  348. }
  349. if (status === 401 || status === 403) {
  350. throw new ProviderUnavailableError(`即梦鉴权失败(HTTP ${status}):${extractError(json) || '未授权'}`, {
  351. endpoint: url,
  352. status,
  353. hint: 'sessionToken 缺失或已失效。检查 FMODE_SESSION_TOKEN / /opt/data/fmode-identity.json 的 session_token。',
  354. });
  355. }
  356. // 并发限制 / 限流 → 退避重试
  357. const blob = JSON.stringify(json);
  358. const concurrent = blob.includes('50430') || /concurrent limit|请求过于频繁|rate limit/i.test(blob);
  359. if (concurrent && attempt < retries) {
  360. lastErr = new GatewayError(`即梦并发受限:${extractError(json) || 'Concurrent Limit'}`, { endpoint: url, status });
  361. continue;
  362. }
  363. const err = extractError(json);
  364. if (err || json.code !== 200) {
  365. lastErr = new GatewayError(`即梦提交失败(HTTP ${status}):${err || `code=${json.code}`}`, { endpoint: url, status });
  366. // 参数类错误重试无意义
  367. if (/取值范围|参数|invalid|不合法|required/i.test(String(err))) break;
  368. continue;
  369. }
  370. const workId = json.data?.workId || json.data?.task_id || json.data?.id;
  371. if (!workId) {
  372. lastErr = new GatewayError(`即梦提交成功但未返回 workId:${blob.slice(0, 300)}`, { endpoint: url, status });
  373. continue;
  374. }
  375. return workId;
  376. }
  377. throw lastErr || new GatewayError('即梦提交失败(重试耗尽)', { endpoint: url });
  378. }
  379. // ==================== 三段式 · ② 轮询 ====================
  380. /**
  381. * 轮询任务直到 isFinish。
  382. * @param {string} workId
  383. * @param {object} o
  384. * @param {string} o.routerName 对应提交端点名
  385. * @param {string} o.queryPath 默认 /getDataByTask02(数字人走 /getOhDateByTask)
  386. * @param {number} o.timeoutMs 默认 600_000
  387. * @param {number} o.intervalMs 默认 15_000
  388. * @param {(t:{elapsed:number,tip:string})=>void} o.onTick
  389. * @returns {Promise<{workId:string,elapsed:number,tip:string}>}
  390. */
  391. export async function poll(workId, o = {}) {
  392. const {
  393. routerName,
  394. queryPath = '/getDataByTask02',
  395. timeoutMs = 600_000,
  396. intervalMs = 15_000,
  397. onTick,
  398. // 数字人主体识别会用 isContinue 表示「可以进下一步」
  399. acceptContinue = false,
  400. } = o;
  401. const url = `${JIMENG.base}${queryPath}`;
  402. const token = requireJimengToken();
  403. const started = Date.now();
  404. let lastTip = '生成中';
  405. let consecutiveErrors = 0;
  406. while (Date.now() - started < timeoutMs) {
  407. await sleep(intervalMs);
  408. const elapsed = Math.round((Date.now() - started) / 1000);
  409. try {
  410. const res = await fetch(url, {
  411. method: 'POST',
  412. headers: { 'Content-Type': 'application/json' },
  413. body: JSON.stringify({ workId, routerName, token: `Bearer ${token}` }),
  414. signal: AbortSignal.timeout(60_000),
  415. });
  416. const text = await res.text();
  417. // 服务端偶发回 HTML(KB 里记的坑)—— 不解析,等下一轮
  418. if (/^\s*(<!doctype html|<html)/i.test(text)) {
  419. lastTip = '网关返回 HTML(服务端错误),继续等待';
  420. consecutiveErrors++;
  421. if (onTick) onTick({ elapsed, tip: lastTip });
  422. continue;
  423. }
  424. const json = JSON.parse(text);
  425. const d = json.data || {};
  426. lastTip = d.tip || lastTip;
  427. consecutiveErrors = 0;
  428. if (onTick) onTick({ elapsed, tip: lastTip, isFinish: !!d.isFinish });
  429. if (d.isFinish) return { workId, elapsed, tip: d.tip || '已完成' };
  430. if (acceptContinue && d.isContinue) return { workId, elapsed, tip: d.tip || '可进入下一步' };
  431. const err = extractError(json);
  432. if (err && !/Object not found/i.test(err)) {
  433. throw new GatewayError(`即梦任务轮询报错:${err}`, { endpoint: url });
  434. }
  435. } catch (e) {
  436. if (e instanceof GatewayError) throw e;
  437. consecutiveErrors++;
  438. lastTip = `轮询异常(${e.message}),继续等待`;
  439. if (onTick) onTick({ elapsed, tip: lastTip });
  440. if (consecutiveErrors > 20) {
  441. throw new GatewayError(`即梦轮询连续失败 ${consecutiveErrors} 次:${e.message}`, { endpoint: url });
  442. }
  443. }
  444. }
  445. throw new GatewayError(
  446. `即梦任务超时(${Math.round(timeoutMs / 1000)}s 未完成;workId=${workId},最后状态「${lastTip}」)\n` +
  447. ` workId 已保留,可稍后手工重查:fmode-mv jimeng --work ${workId}`
  448. );
  449. }
  450. // ==================== 三段式 · ③ 取产物 ====================
  451. /**
  452. * 读 ImagineWork 记录,拿 images / videos 直链。
  453. * @returns {Promise<{images:string[], videos:string[], usage:object|null, credit:number|null, raw:object}>}
  454. */
  455. export async function fetchWork(workId) {
  456. const url = `${JIMENG.parseBase}/classes/${JIMENG.workClass}/${encodeURIComponent(workId)}`;
  457. const res = await fetch(url, {
  458. method: 'GET',
  459. headers: { 'X-Parse-Application-Id': JIMENG.parseAppId, Accept: 'application/json' },
  460. signal: AbortSignal.timeout(60_000),
  461. });
  462. const text = await res.text();
  463. if (/^\s*(<!doctype html|<html)/i.test(text)) {
  464. throw new GatewayError(`读取作品记录返回 HTML(网关异常):${url}`);
  465. }
  466. const json = JSON.parse(text);
  467. if (json.error) {
  468. throw new GatewayError(`读取作品记录失败(workId=${workId}):${json.error}`);
  469. }
  470. const clean = (arr) => (Array.isArray(arr) ? arr.map((u) => String(u).trim().replace(/^["']|["']$/g, '')).filter(Boolean) : []);
  471. return {
  472. images: clean(json.images),
  473. videos: clean(json.videos),
  474. usage: json.usage || null,
  475. credit: typeof json.credit === 'number' ? json.credit : null,
  476. raw: json,
  477. };
  478. }
  479. /** 下载到本地文件(保留重定向处理) */
  480. export async function downloadTo(url, outPath, { timeout = 300_000 } = {}) {
  481. mkdirSync(dirname(outPath), { recursive: true });
  482. const res = await fetch(url, { signal: AbortSignal.timeout(timeout), redirect: 'follow' });
  483. if (!res.ok) throw new GatewayError(`下载失败 HTTP ${res.status}:${url}`);
  484. const buf = Buffer.from(await res.arrayBuffer());
  485. if (!buf.length) throw new GatewayError(`下载得到 0 字节:${url}`);
  486. writeFileSync(outPath, buf);
  487. return { outPath, bytes: buf.length };
  488. }
  489. /**
  490. * 一次性跑完三段式:提交 → 轮询 → 取产物 → 下载。
  491. * @returns {Promise<{workId:string, files:string[], urls:string[], elapsed:number, cost:number, usage:object|null, kind:'image'|'video'}>}
  492. */
  493. export async function runTask(endpoint, body, o = {}) {
  494. const {
  495. kind = 'image',
  496. outPaths = [],
  497. outDir,
  498. prefix = 'out',
  499. ext,
  500. routerName,
  501. queryPath = '/getDataByTask02',
  502. timeoutMs = 600_000,
  503. intervalMs = 15_000,
  504. onTick,
  505. quiet = false,
  506. } = o;
  507. const ep = ENDPOINTS[endpoint] || { path: endpoint, router: String(endpoint).replace(/^\//, '') };
  508. const est = o.estimateCost ?? 0;
  509. if (!quiet) {
  510. process.stderr.write(` → 提交 ${ep.label || endpoint}(预估费用 ¥${est.toFixed(2)})workId 获取中…\n`);
  511. }
  512. const t0 = Date.now();
  513. const workId = await submit(endpoint, body, { timeout: o.submitTimeout ?? 120_000, retries: o.retries ?? 3 });
  514. if (!quiet) process.stderr.write(` → workId=${workId},轮询中(最长 ${Math.round(timeoutMs / 1000)}s,每 ${Math.round(intervalMs / 1000)}s 一次)…\n`);
  515. await poll(workId, {
  516. routerName: routerName || ep.router,
  517. queryPath,
  518. timeoutMs,
  519. intervalMs,
  520. onTick: onTick || (quiet ? undefined : ({ elapsed, tip }) => process.stderr.write(` [${elapsed}s] ${tip}\n`)),
  521. });
  522. const rec = await fetchWork(workId);
  523. const urls = kind === 'video' ? rec.videos : rec.images;
  524. if (!urls.length) {
  525. throw new GatewayError(
  526. `即梦任务已完成但没拿到${kind === 'video' ? '视频' : '图片'} URL(workId=${workId},videos=${rec.videos.length},images=${rec.images.length})`
  527. );
  528. }
  529. const files = [];
  530. for (let i = 0; i < urls.length; i++) {
  531. const target =
  532. outPaths[i] ||
  533. join(outDir || '.', `${prefix}${urls.length > 1 ? `-${i + 1}` : ''}.${ext || (kind === 'video' ? 'mp4' : 'png')}`);
  534. const r = await downloadTo(urls[i], target);
  535. files.push(r.outPath);
  536. if (!quiet) process.stderr.write(` ✓ 落盘 ${r.outPath}(${(r.bytes / 1024).toFixed(1)} KB)\n`);
  537. }
  538. return {
  539. workId,
  540. files,
  541. urls,
  542. elapsed: Date.now() - t0,
  543. cost: rec.usage?.cost ?? est,
  544. credit: rec.credit,
  545. usage: rec.usage,
  546. kind,
  547. };
  548. }
  549. // ==================== 公网图片上传(七牛) ====================
  550. const MIME = {
  551. '.png': 'image/png',
  552. '.jpg': 'image/jpeg',
  553. '.jpeg': 'image/jpeg',
  554. '.webp': 'image/webp',
  555. '.gif': 'image/gif',
  556. '.bmp': 'image/bmp',
  557. '.mp4': 'video/mp4',
  558. '.mov': 'video/quicktime',
  559. '.webm': 'video/webm',
  560. '.mp3': 'audio/mpeg',
  561. '.wav': 'audio/wav',
  562. '.m4a': 'audio/mp4',
  563. };
  564. export function mimeOf(p) {
  565. return MIME[extname(p).toLowerCase()] || 'application/octet-stream';
  566. }
  567. /** 生成七牛 key(照抄 KB 的 `genFileKey` 规则) */
  568. export function genFileKey(fileName, cid) {
  569. const n = new Date();
  570. const pad = (x) => String(x).padStart(2, '0');
  571. const rand = randomBytes(4).toString('hex').slice(0, 6);
  572. const day = `${n.getFullYear()}${pad(n.getMonth() + 1)}${pad(n.getDate())}`;
  573. const time = `${pad(n.getHours())}${pad(n.getMinutes())}${pad(n.getSeconds())}`;
  574. return `storage/company/${cid}/${day}/${time}-${rand}-${basename(fileName)}`;
  575. }
  576. let _qiniu = null;
  577. /** 取七牛上传凭证(会缓存,有效期内复用) */
  578. export async function getQiniuToken(cid = JIMENG.cdnCompany) {
  579. if (_qiniu && _qiniu.cid === cid && Date.now() < _qiniu.expireAt) return _qiniu;
  580. const res = await fetch(`${JIMENG.parseBase}/functions/qiniu_uptoken`, {
  581. method: 'POST',
  582. headers: { 'Content-Type': 'application/json', 'X-Parse-Application-Id': JIMENG.parseAppId },
  583. body: JSON.stringify({ company: cid }),
  584. signal: AbortSignal.timeout(30_000),
  585. });
  586. const json = await res.json();
  587. const r = json.result;
  588. if (!r || !r.uptoken) {
  589. throw new GatewayError(
  590. `取七牛上传凭证失败(company=${cid}):${JSON.stringify(json).slice(0, 200)}\n` +
  591. ` → 可用 FMODE_CDN_COMPANY 指定正确的 company ID`
  592. );
  593. }
  594. _qiniu = {
  595. cid,
  596. uptoken: r.uptoken,
  597. domain: r.domain || `${JIMENG.cdnHost}/`,
  598. host: r.zoneUrl ? new URL(r.zoneUrl).hostname : JIMENG.uploadHost,
  599. // uptoken 的 deadline 解不出来就按 1 小时缓存
  600. expireAt: Date.now() + 30 * 60_000,
  601. };
  602. return _qiniu;
  603. }
  604. /**
  605. * 把本地文件上传到公网(七牛),返回可直接喂给即梦的 https 直链。
  606. * 图生图 / 图生视频 / 数字人的所有输入图都必须先过这一关。
  607. * @returns {Promise<string>} 例如 https://file-cloud.fmode.cn/storage/company/xxx/...png
  608. */
  609. export async function uploadToCdn(localPath, { cid = JIMENG.cdnCompany } = {}) {
  610. if (!existsSync(localPath)) throw new GatewayError(`上传失败:文件不存在 ${localPath}`);
  611. const cfg = await getQiniuToken(cid);
  612. const data = readFileSync(localPath);
  613. const fileName = basename(localPath);
  614. const key = genFileKey(fileName, cid);
  615. const boundary = `----FormBoundary${randomBytes(16).toString('hex')}`;
  616. const head = Buffer.from(
  617. `--${boundary}\r\n` +
  618. `Content-Disposition: form-data; name="token"\r\n\r\n${cfg.uptoken}\r\n` +
  619. `--${boundary}\r\n` +
  620. `Content-Disposition: form-data; name="key"\r\n\r\n${key}\r\n` +
  621. `--${boundary}\r\n` +
  622. `Content-Disposition: form-data; name="file"; filename="${fileName}"\r\n` +
  623. `Content-Type: ${mimeOf(localPath)}\r\n\r\n`
  624. );
  625. const tail = Buffer.from(`\r\n--${boundary}--\r\n`);
  626. const body = Buffer.concat([head, data, tail]);
  627. const res = await fetch(`https://${cfg.host}/`, {
  628. method: 'POST',
  629. headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}`, 'Content-Length': String(body.length) },
  630. body,
  631. signal: AbortSignal.timeout(180_000),
  632. });
  633. const text = await res.text();
  634. let json;
  635. try {
  636. json = JSON.parse(text);
  637. } catch {
  638. throw new GatewayError(`七牛上传返回非 JSON(HTTP ${res.status}):${text.slice(0, 200)}`);
  639. }
  640. if (!json.key) throw new GatewayError(`七牛上传失败(HTTP ${res.status}):${text.slice(0, 200)}`);
  641. return `${String(cfg.domain).replace(/\/+$/, '')}/${json.key}`;
  642. }
  643. /** 批量上传(串行,避免触发限流) */
  644. export async function uploadAll(paths, opts = {}) {
  645. const urls = [];
  646. for (const p of paths) urls.push(await uploadToCdn(p, opts));
  647. return urls;
  648. }
  649. /**
  650. * 为「图生图/图生视频」准备输入图:
  651. * ① 若已满足尺寸与体积要求 → 直接上传
  652. * ② 否则用 ffmpeg 压到 1280×720 JPEG q4(≈150~300KB)再上传
  653. *
  654. * 为什么必须压:即梦对二进制输入有体积限制(KB 记录约 4~5MB 的 base64 字符串),
  655. * 大 PNG 直传会报 "Image Decode Error: invalid binary data file size"。
  656. *
  657. * @returns {Promise<string>} 公网 URL
  658. */
  659. export async function prepareImageUrl(localPath, o = {}) {
  660. const { maxBytes = 3 * 1024 * 1024, width = 1280, height = 720, cid = JIMENG.cdnCompany } = o;
  661. const st = statSync(localPath);
  662. if (st.size <= maxBytes && /\.(jpe?g|png|webp)$/i.test(localPath)) {
  663. return uploadToCdn(localPath, { cid });
  664. }
  665. const dst = join(dirname(localPath), `.cdn-${basename(localPath).replace(/\.[^.]+$/, '')}.jpg`);
  666. await run(['-y', '-v', 'error', '-i', localPath, '-vf', `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2`, '-q:v', '4', dst], {
  667. label: 'ffmpeg 压缩',
  668. timeout: 120_000,
  669. });
  670. return uploadToCdn(dst, { cid });
  671. }
  672. // ==================== 数字人 v1.5(三步) ====================
  673. /**
  674. * 数字人完整流程:主体识别 → (可选)主体检测 → 音频驱动生成视频。
  675. * @returns {Promise<{workId:string, file:string, elapsed:number, urls:string[]}>}
  676. */
  677. export async function digitalHumanFlow({ imagePath, audioPath, prompt = '', outFile, peFastMode = true, onTick } = {}) {
  678. if (!imagePath || !audioPath) throw new GatewayError('数字人需要 imagePath 与 audioPath');
  679. const t0 = Date.now();
  680. process.stderr.write(' [1/3] 上传人物图与音频到公网…\n');
  681. const imageUrl = await prepareImageUrl(imagePath);
  682. const audioUrl = await uploadToCdn(audioPath);
  683. process.stderr.write(` 图片 ${imageUrl}\n 音频 ${audioUrl}\n`);
  684. process.stderr.write(' [2/3] 主体识别(getOhIdentifyMain)…\n');
  685. const workId = await submit('ohIdentify', { image_url: imageUrl });
  686. await poll(workId, {
  687. routerName: 'getOhIdentifyMain',
  688. queryPath: '/getOhDateByTask',
  689. timeoutMs: 300_000,
  690. intervalMs: 10_000,
  691. acceptContinue: true,
  692. onTick,
  693. });
  694. process.stderr.write(` workId=${workId}\n`);
  695. // 步骤 2「主体检测」在 KB 里标注为可选;识别通过即可直接生成,跳过可省一次调用
  696. process.stderr.write(' [3/3] 数字人视频生成(getOmniHuman,¥1.00/秒)…\n');
  697. const genBody = { workId, audio_url: audioUrl };
  698. if (prompt) genBody.prompt = prompt;
  699. if (peFastMode) genBody.pe_fast_mode = true;
  700. const genWorkId = await submit('omniHuman', genBody);
  701. await poll(genWorkId, {
  702. routerName: 'getOmniHuman',
  703. queryPath: '/getOhDateByTask',
  704. timeoutMs: 900_000,
  705. intervalMs: 15_000,
  706. onTick,
  707. });
  708. const rec = await fetchWork(genWorkId);
  709. if (!rec.videos.length) {
  710. throw new GatewayError(`数字人任务完成但无视频(workId=${genWorkId})`);
  711. }
  712. const target = outFile || join(process.cwd(), 'out', 'digital-human.mp4');
  713. const r = await downloadTo(rec.videos[0], target);
  714. return {
  715. workId: genWorkId,
  716. identifyWorkId: workId,
  717. file: r.outPath,
  718. bytes: r.bytes,
  719. urls: rec.videos,
  720. elapsed: Date.now() - t0,
  721. cost: rec.usage?.cost ?? null,
  722. };
  723. }
  724. // ==================== 动作模仿 ====================
  725. /**
  726. * 动作模仿(把静态人物图按模板视频驱动)。
  727. * @param {{imagePath:string, templateVideoUrl:string, outFile:string, version?:1|2, cutFirstSecond?:boolean}} o
  728. */
  729. export async function actorFlow({ imagePath, templateVideoUrl, outFile, version = 2, cutFirstSecond = true, onTick } = {}) {
  730. if (!imagePath) throw new GatewayError('动作模仿需要 imagePath');
  731. if (!templateVideoUrl || !/^https?:\/\//i.test(templateVideoUrl)) {
  732. throw new GatewayError(
  733. '动作模仿需要公网直链模板视频(templateVideoUrl)。若你只有本地视频,请先 uploadToCdn() 再传入。'
  734. );
  735. }
  736. const ep = version === 1 ? 'actor' : 'actorV2';
  737. const imageUrl = await prepareImageUrl(imagePath);
  738. const t0 = Date.now();
  739. const body = { image_url: imageUrl, video_url: templateVideoUrl };
  740. if (cutFirstSecond) body.cut_first_second = true;
  741. const workId = await submit(ep, body);
  742. await poll(workId, { routerName: ENDPOINTS[ep].router, timeoutMs: 600_000, intervalMs: 15_000, onTick });
  743. const rec = await fetchWork(workId);
  744. if (!rec.videos.length) throw new GatewayError(`动作模仿完成但无视频(workId=${workId})`);
  745. const target = outFile || join(process.cwd(), 'out', 'actor.mp4');
  746. const r = await downloadTo(rec.videos[0], target);
  747. return { workId, file: r.outPath, bytes: r.bytes, urls: rec.videos, elapsed: Date.now() - t0, cost: rec.usage?.cost ?? null };
  748. }
  749. // ==================== 费用台账 ====================
  750. /**
  751. * 全程费用台账。所有真实调用都应先 printEstimate(),跑完 record()。
  752. * 目的是让「花了多少钱」始终可追溯 —— 技能仓对用户的基本承诺。
  753. */
  754. export class CostLedger {
  755. constructor(label = '本次任务') {
  756. this.label = label;
  757. this.entries = [];
  758. this.startedAt = new Date().toISOString();
  759. }
  760. /** 调用前的预估打印 */
  761. printEstimate(kind, detail, amount) {
  762. const ep = ENDPOINTS[kind];
  763. const line = ` ¥${Number(amount).toFixed(2)} ${ep ? ep.label : kind}${detail ? ` · ${detail}` : ''}`;
  764. process.stderr.write(` 预估费用 ${line}\n`);
  765. return amount;
  766. }
  767. /** 调用后记账(优先用接口返回的实扣,没有则用预估) */
  768. record({ kind, detail = '', estimate = 0, actual = null, workId = '', file = '', ms = 0 }) {
  769. const amount = actual != null ? Number(actual) : Number(estimate) || 0;
  770. this.entries.push({ kind, detail, amount, estimate, actual, workId, file, ms, at: new Date().toISOString() });
  771. return amount;
  772. }
  773. get total() {
  774. return +this.entries.reduce((s, e) => s + (e.amount || 0), 0).toFixed(4);
  775. }
  776. summary() {
  777. const lines = [`费用台账 · ${this.label}`];
  778. for (const e of this.entries) {
  779. const ep = ENDPOINTS[e.kind];
  780. const tag = e.actual != null ? '实扣' : '预估';
  781. lines.push(` ¥${e.amount.toFixed(2)} [${tag}] ${ep?.label || e.kind}${e.detail ? ` · ${e.detail}` : ''}${e.ms ? ` · ${(e.ms / 1000).toFixed(1)}s` : ''}`);
  782. }
  783. lines.push(` 合计 ¥${this.total.toFixed(2)}`);
  784. return lines.join('\n');
  785. }
  786. }