| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863 |
- // lib/jimeng.mjs — 即梦(火山引擎)真实通道封装
- //
- // ★ 本文件是 skill-mv 唯一的「真·生成通道」。实测(2026-10-13,本容器):
- // 文生图3.0 ¥0.20/张 —— 提交→轮询→下载 全链路跑通,返回 1024×1024 PNG 49.7KB
- // 图生视频720p ¥0.28/秒 —— 5 秒 121 帧跑通,返回 960×960 h264 mp4 1.09MB,实扣 ¥1.41
- // 七牛上传 免费 —— 换 uptoken → multipart 上传 → 返回 file-cloud.fmode.cn 直链
- //
- // 三段式调用(所有即梦生成能力都走这个模式):
- // ① 提交 POST {base}/<endpoint> body {...params, token} → {code:200,data:{workId}}
- // ② 轮询 POST {base}/getDataByTask02 body {workId, routerName, token} → {data:{isFinish}}
- // ③ 取件 GET {parseBase}/classes/ImagineWork/{workId} header X-Parse-Application-Id → {images[],videos[]}
- //
- // 【凭据铁律】sessionToken 只从 环境变量 FMODE_SESSION_TOKEN 或 /opt/data/fmode-identity.json
- // 运行时读取;本文件(以及整个技能仓)绝不写入、绝不硬编码、绝不提交任何 token。
- // 错误信息里也只出现脱敏后的 token。
- import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
- import { dirname, extname, join, basename } from 'node:path';
- import { randomBytes } from 'node:crypto';
- import { ProviderUnavailableError, GatewayError } from './api.mjs';
- import { run } from './ffmpeg.mjs';
- // ==================== 通道配置(可被环境变量覆盖) ====================
- export const JIMENG = {
- base: process.env.FMODE_JIMENG_BASE || 'https://server.fmode.cn/api/volcengine/jimeng',
- parseBase: (process.env.FMODE_PARSE_BASE || 'https://server.fmode.cn/parse').replace(/\/+$/, ''),
- parseAppId: process.env.FMODE_PARSE_APPID || 'ncloudmaster',
- cdnHost: 'https://file-cloud.fmode.cn',
- uploadHost: process.env.FMODE_QINIU_UPLOAD_HOST || 'upload.qiniup.com',
- cdnCompany: process.env.FMODE_CDN_COMPANY || 'XlTE6uWQEQ',
- workClass: 'ImagineWork',
- };
- // ==================== 端点清单与费用表 ====================
- /**
- * 即梦全部端点。cost = 单价(元),unit = 计价单位。
- * 依据 `_kb/07-即梦API工具箱/参考文档/即梦AI技能汇总.md`,实测校验通过。
- */
- export const ENDPOINTS = {
- // —— 文生图 / 图生图 ——
- text2imgV3: { path: '/getText2ImgV3', router: 'getText2ImgV3', cost: 0.2, unit: '张', label: '文生图3.0' },
- text2imgV31: { path: '/getText2ImgV31', router: 'getText2ImgV31', cost: 0.2, unit: '张', label: '文生图3.1' },
- imgV4: { path: '/getImgV4', router: 'getImgV4', cost: 0.22, unit: '张', label: '图片生成4.0(文生图/多图组合)' },
- img2imgV3: { path: '/getImg2ImgV3', router: 'getImg2ImgV3', cost: 0.2, unit: '张', label: '图生图3.0(智能参考)' },
- // —— 电商专用 ——
- imgV4Goods: { path: '/getImgV4_goods', router: 'getImgV4_goods', cost: 0.22, unit: '张', label: '商品提取' },
- imgV4Pod: { path: '/getImgV4_pod', router: 'getImgV4_pod', cost: 0.22, unit: '张', label: '素材提取' },
- clothesV2: { path: '/getClothesV2', router: 'getClothesV2', cost: 0.22, unit: '张', label: '虚拟换装v2' },
- // —— 图像编辑 ——
- inpaint: { path: '/getInpaint', router: 'getInpaint', cost: 0.2, unit: '张', label: '局部重绘' },
- superRes: { path: '/getSuperResolution', router: 'getSuperResolution', cost: 0.4, unit: '张', label: '智能超清' },
- // —— 动作模仿 ——
- actor: { path: '/getActor', router: 'getActor', cost: 0.5, unit: '秒', minCost: 4, maxCost: 6, label: '动作模仿1.0' },
- actorV2: { path: '/getActorV2', router: 'getActorV2', cost: 0.4, unit: '秒', minCost: 3.2, maxCost: 5, label: '动作模仿2.0' },
- // —— 视频生成 ——
- video720: { path: '/getVideoV3_720p', router: 'getVideoV3_720p', cost: 0.28, unit: '秒', label: '图生视频3.0 720p' },
- video1080: { path: '/getVideoV3_1080p', router: 'getVideoV3_1080p', cost: 0.63, unit: '秒', label: '图生视频3.0 1080p' },
- videoPro: { path: '/getVideoV3_Pro', router: 'getVideoV3_Pro', cost: 1.0, unit: '秒', label: '图生视频3.0 Pro' },
- // —— 数字人 v1.5 三步 ——
- ohIdentify: { path: '/getOhIdentifyMain', router: 'getOhIdentifyMain', cost: 0, unit: '次', label: '数字人·主体识别' },
- ohDetect: { path: '/getOhDetectMain', router: 'getOhDetectMain', cost: 0, unit: '次', label: '数字人·主体检测' },
- omniHuman: { path: '/getOmniHuman', router: 'getOmniHuman', cost: 1.0, unit: '秒', label: '数字人·视频生成' },
- };
- /**
- * 图生视频 frames 的硬约束(实测:传 49 被拒 "frames 取值范围为[121,241]";
- * 传 122 被拒 "x2v pipeline: frames % 24 should be 1")。
- * 因此 frames 必须 ≡ 1 (mod 24),可用值只有 121 / 145 / 169 / 193 / 217 / 241,
- * 对应时长 5 / 6 / 7 / 8 / 9 / 10 秒。
- */
- export const VIDEO_FRAMES = { min: 121, max: 241, fps: 24, step: 24 };
- /** 合法的帧数档位(5s~10s,每 1 秒一档) */
- export const FRAME_STEPS = [121, 145, 169, 193, 217, 241];
- /** 秒 → 帧(就近吸附到合法档位,保证 frames ≡ 1 (mod 24)) */
- export function secondsToFrames(seconds) {
- const target = Number(seconds) || 5;
- let best = FRAME_STEPS[0];
- let bestDelta = Infinity;
- for (const f of FRAME_STEPS) {
- const d = Math.abs(framesToSeconds(f) - target);
- if (d < bestDelta) {
- bestDelta = d;
- best = f;
- }
- }
- return best;
- }
- /** 帧 → 秒 */
- export function framesToSeconds(frames) {
- return +((Number(frames) - 1) / VIDEO_FRAMES.fps).toFixed(3);
- }
- /** 校验帧数是否合法(提交前自检,避免白等一次 503/502) */
- export function assertValidFrames(frames) {
- const f = Number(frames);
- if (!Number.isInteger(f)) throw new BadFramesError(`frames 必须是整数,收到 ${frames}`);
- if (f < VIDEO_FRAMES.min || f > VIDEO_FRAMES.max) {
- throw new BadFramesError(`frames 取值范围为 [${VIDEO_FRAMES.min}, ${VIDEO_FRAMES.max}],收到 ${f}`);
- }
- if (f % VIDEO_FRAMES.step !== 1) {
- throw new BadFramesError(`frames % 24 必须为 1(合法值:${FRAME_STEPS.join('/')}),收到 ${f}`);
- }
- return f;
- }
- /** 帧数不合法时抛出(本地自检错误,不是通道错误) */
- export class BadFramesError extends Error {
- constructor(message) {
- super(message);
- this.name = 'BadFramesError';
- }
- }
- /** 估算费用(元),调用前必须先打印 */
- export function estimateCost(kind, { seconds = 0, count = 1 } = {}) {
- const ep = ENDPOINTS[kind];
- if (!ep) return 0;
- if (ep.unit === '秒') {
- const raw = ep.cost * (Number(seconds) || 0);
- const lo = ep.minCost ?? 0;
- const hi = ep.maxCost ?? Infinity;
- return Math.min(hi, Math.max(lo, raw));
- }
- return ep.cost * (Number(count) || 1);
- }
- // ==================== 尺寸约束(实测踩坑:尺寸不合法要到轮询阶段才报错) ====================
- //
- // 各端点的尺寸约束不同,传错不会在提交时报错,而要等轮询才回 algo_msg,
- // 白等一轮 30 秒。所以本地必须先卡住:
- // text2imgV3 / text2imgV31 / img2imgV3:宽高积 ∈ [512², 2048²],宽高比 ∈ [1/3, 3]
- // imgV4:宽高积 ∈ [1024², 4096²],宽高比 ∈ [1/3, 3]
- // 实测报错原文举例:'height or width invalid'(getImgV4 传 1280×720,面积 921600 < 1048576)
- const SIZE_BOUNDS = {
- text2imgV3: { minArea: 512 * 512, maxArea: 2048 * 2048 },
- text2imgV31: { minArea: 512 * 512, maxArea: 2048 * 2048 },
- img2imgV3: { minArea: 512 * 512, maxArea: 2048 * 2048 },
- imgV4: { minArea: 1024 * 1024, maxArea: 4096 * 4096 },
- imgV4Pod: { minArea: 1024 * 1024, maxArea: 4096 * 4096 },
- imgV4Goods: { minArea: 1024 * 1024, maxArea: 4096 * 4096 },
- superRes: { minArea: 0, maxArea: Infinity },
- };
- /** 各端点 × 比例 × 档位的推荐尺寸(都取自 KB README_v2 的「推荐宽高」) */
- const SIZE_TABLE = {
- // 文生图 3.0/3.1、图生图 3.0:1K 档
- 1: {
- '16:9': { width: 1664, height: 936 },
- '9:16': { width: 936, height: 1664 },
- '1:1': { width: 1328, height: 1328 },
- '4:3': { width: 1472, height: 1104 },
- '3:4': { width: 1104, height: 1472 },
- },
- // 2K 档(同样满足 2048² 上限)
- 2: {
- '16:9': { width: 2560, height: 1440 },
- '9:16': { width: 1440, height: 2560 },
- '1:1': { width: 2048, height: 2048 },
- '4:3': { width: 2304, height: 1728 },
- '3:4': { width: 1728, height: 2304 },
- },
- };
- /** getImgV4 必须 ≥ 1024²,所以没有真正的 1K 档,最低就是这些 */
- const SIZE_TABLE_V4 = {
- '16:9': { width: 2560, height: 1440 },
- '9:16': { width: 1440, height: 2560 },
- '1:1': { width: 2048, height: 2048 },
- '4:3': { width: 2304, height: 1728 },
- '3:4': { width: 1728, height: 2304 },
- };
- /** 尺寸不合法(本地自检错误,避免白等一轮轮询) */
- export class BadSizeError extends Error {
- constructor(message, hint) {
- super(message);
- this.name = 'BadSizeError';
- this.hint = hint;
- }
- }
- /**
- * 取某个端点在给定比例/档位下的合法尺寸。
- * @param {string} endpoint ENDPOINTS key(如 'text2imgV3' / 'imgV4' / 'img2imgV3')
- * @param {string} aspect '16:9' | '9:16' | '1:1' | '4:3' | '3:4'
- * @param {'1k'|'2k'} [tier]
- */
- export function resolveSize(endpoint, aspect = '16:9', tier = '1k') {
- const isV4 = /^imgV4/.test(endpoint);
- const table = isV4 ? SIZE_TABLE_V4 : SIZE_TABLE[tier === '2k' ? 2 : 1];
- const size = table[aspect] || table['16:9'];
- return { ...size };
- }
- /** 提交前校验尺寸;不合法直接抛,附可操作建议 */
- export function assertValidSize(endpoint, { width, height } = {}) {
- const b = SIZE_BOUNDS[endpoint];
- if (!b) return { width, height }; // 无约束的端点(超清等)放行
- const w = Number(width);
- const h = Number(height);
- if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) {
- throw new BadSizeError(`${endpoint} 尺寸非法:${width}×${height}`);
- }
- const area = w * h;
- const ratio = w / h;
- const problems = [];
- if (area < b.minArea) {
- problems.push(`面积 ${w}×${h}=${area} 小于下限 ${b.minArea}`);
- }
- if (area > b.maxArea) {
- problems.push(`面积 ${w}×${h}=${area} 超过上限 ${b.maxArea}`);
- }
- if (ratio > 3 || ratio < 1 / 3) {
- problems.push(`宽高比 ${ratio.toFixed(3)} 超出 [1/3, 3]`);
- }
- if (problems.length) {
- const sug = resolveSize(endpoint, ratio >= 1 ? '16:9' : '9:16', '2k');
- throw new BadSizeError(
- `${endpoint} 尺寸不合法:${problems.join(';')}(即梦会返回 'height or width invalid')`,
- `改用 resolveSize('${endpoint}', '<比例>', '<档位>') 取推荐尺寸,例如 ${sug.width}×${sug.height}`
- );
- }
- return { width: w, height: h };
- }
- // ==================== 凭据 ====================
- /** 身份文件候选路径 */
- export const IDENTITY_PATHS = [
- process.env.FMODE_IDENTITY_FILE,
- process.env.FEME_IDENTITY_FILE,
- '/opt/data/fmode-identity.json',
- ].filter(Boolean);
- let _tokenCache = null;
- /**
- * 取 sessionToken。优先环境变量 FMODE_SESSION_TOKEN,其次身份文件。
- * 返回值不带 "Bearer " 前缀;对外输出一律走 maskToken()。
- */
- export function jimengToken() {
- if (_tokenCache) return _tokenCache;
- const env = process.env.FMODE_SESSION_TOKEN || process.env.FMODE_TOKEN;
- if (env && String(env).trim()) {
- _tokenCache = String(env).trim().replace(/^Bearer\s+/i, '');
- return _tokenCache;
- }
- for (const p of IDENTITY_PATHS) {
- try {
- if (p && existsSync(p)) {
- const d = JSON.parse(readFileSync(p, 'utf8'));
- if (d.session_token && String(d.session_token).trim()) {
- _tokenCache = String(d.session_token).trim().replace(/^Bearer\s+/i, '');
- return _tokenCache;
- }
- }
- } catch {
- /* 身份文件损坏或不可读 → 继续找 */
- }
- }
- return null;
- }
- /** token 缺失时抛出可操作错误 */
- export function requireJimengToken() {
- const t = jimengToken();
- if (!t) {
- throw new ProviderUnavailableError('即梦通道缺少 sessionToken', {
- endpoint: JIMENG.base,
- hint:
- '即梦通道必须带 sessionToken(缺失会返回 403 请输入API_KEY或用户sessionToken)。二选一:\n' +
- ' A) export FMODE_SESSION_TOKEN=<你的sessionToken>\n' +
- ' B) 在 /opt/data/fmode-identity.json 写入 session_token 字段\n' +
- '(本技能仓绝不把 token 写入任何文件、日志或提交)',
- });
- }
- return t;
- }
- /** 脱敏显示:只露首尾各 3 位 */
- export function maskToken(t) {
- const s = String(t || '');
- if (!s) return '<empty>';
- if (s.length <= 8) return s[0] + '*'.repeat(Math.max(1, s.length - 1));
- return `${s.slice(0, 3)}${'*'.repeat(6)}${s.slice(-3)}`;
- }
- /** 身份文件里是否有可用 sessionToken(不打印值) */
- export function jimengAvailable() {
- return !!jimengToken();
- }
- // ==================== 三段式 · ① 提交 ====================
- export function sleep(ms) {
- return new Promise((r) => setTimeout(r, ms));
- }
- /** 从即梦响应里抠出人类可读的错误串(它有三种错误体形状,且会套娃) */
- export function extractError(json) {
- if (!json) return null;
- const e = json.error;
- if (!e) return null;
- if (typeof e === 'string') return e;
- let node = e;
- // 逐层往下挖:error.message → {tip, data:{code, message, ...}} → algo_msg
- for (let depth = 0; depth < 5; depth++) {
- if (typeof node === 'string') return node;
- if (!node || typeof node !== 'object') break;
- const deep = node.data?.message || node.message?.data?.message;
- if (typeof node.algo_msg === 'string') return node.algo_msg;
- if (node.data && typeof node.data === 'object' && typeof node.data.algo_msg === 'string') return node.data.algo_msg;
- if (typeof deep === 'string') return deep;
- const next = node.message ?? node.tip;
- if (next == null || next === node) break;
- if (typeof next === 'string') {
- // tip 只是「未知错误」这种壳,继续往 data 里找更具体的
- const inner = node.data?.message || node.data?.algo_msg;
- if (typeof inner === 'string') return inner;
- return next;
- }
- node = next;
- }
- return JSON.stringify(e).slice(0, 500);
- }
- /**
- * 提交一个即梦任务。
- * @param {string} endpoint ENDPOINTS 的 key,或直接给 '/getXxx'
- * @param {object} body 业务参数(token 由本函数注入)
- * @returns {Promise<string>} workId
- */
- export async function submit(endpoint, body, { timeout = 120_000, retries = 3 } = {}) {
- const ep = ENDPOINTS[endpoint] || { path: endpoint, router: String(endpoint).replace(/^\//, '') };
- const token = requireJimengToken();
- const url = `${JIMENG.base}${ep.path}`;
- let lastErr = null;
- for (let attempt = 0; attempt <= retries; attempt++) {
- if (attempt > 0) {
- const delay = 5000 * attempt;
- process.stderr.write(` [重试 ${attempt}/${retries}] ${delay / 1000}s 后重试(并发限制/网络抖动)\n`);
- await sleep(delay);
- }
- let text;
- let status;
- try {
- const res = await fetch(url, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
- body: JSON.stringify({ ...body, token: `Bearer ${token}` }),
- signal: AbortSignal.timeout(timeout),
- });
- status = res.status;
- text = await res.text();
- } catch (e) {
- lastErr = new GatewayError(`即梦提交网络错误:${e.message}`, { endpoint: url });
- continue;
- }
- // 网关对未知路径会回前端兜底页,必须识别出来,不能当成成功
- if (/^\s*(<!doctype html|<html)/i.test(text)) {
- throw new ProviderUnavailableError(`即梦端点不存在或网关异常(返回 HTML 兜底页):${ep.path}`, {
- endpoint: url,
- status,
- hint: '请核对端点名(见 lib/jimeng.mjs ENDPOINTS,或 fmode-mv jimeng --endpoints)。',
- });
- }
- let json = null;
- try {
- json = JSON.parse(text);
- } catch {
- throw new GatewayError(`即梦返回非 JSON(HTTP ${status}):${String(text).slice(0, 200)}`, { endpoint: url, status });
- }
- if (status === 401 || status === 403) {
- throw new ProviderUnavailableError(`即梦鉴权失败(HTTP ${status}):${extractError(json) || '未授权'}`, {
- endpoint: url,
- status,
- hint: 'sessionToken 缺失或已失效。检查 FMODE_SESSION_TOKEN / /opt/data/fmode-identity.json 的 session_token。',
- });
- }
- // 并发限制 / 限流 → 退避重试
- const blob = JSON.stringify(json);
- const concurrent = blob.includes('50430') || /concurrent limit|请求过于频繁|rate limit/i.test(blob);
- if (concurrent && attempt < retries) {
- lastErr = new GatewayError(`即梦并发受限:${extractError(json) || 'Concurrent Limit'}`, { endpoint: url, status });
- continue;
- }
- const err = extractError(json);
- if (err || json.code !== 200) {
- lastErr = new GatewayError(`即梦提交失败(HTTP ${status}):${err || `code=${json.code}`}`, { endpoint: url, status });
- // 参数类错误重试无意义
- if (/取值范围|参数|invalid|不合法|required/i.test(String(err))) break;
- continue;
- }
- const workId = json.data?.workId || json.data?.task_id || json.data?.id;
- if (!workId) {
- lastErr = new GatewayError(`即梦提交成功但未返回 workId:${blob.slice(0, 300)}`, { endpoint: url, status });
- continue;
- }
- return workId;
- }
- throw lastErr || new GatewayError('即梦提交失败(重试耗尽)', { endpoint: url });
- }
- // ==================== 三段式 · ② 轮询 ====================
- /**
- * 轮询任务直到 isFinish。
- * @param {string} workId
- * @param {object} o
- * @param {string} o.routerName 对应提交端点名
- * @param {string} o.queryPath 默认 /getDataByTask02(数字人走 /getOhDateByTask)
- * @param {number} o.timeoutMs 默认 600_000
- * @param {number} o.intervalMs 默认 15_000
- * @param {(t:{elapsed:number,tip:string})=>void} o.onTick
- * @returns {Promise<{workId:string,elapsed:number,tip:string}>}
- */
- export async function poll(workId, o = {}) {
- const {
- routerName,
- queryPath = '/getDataByTask02',
- timeoutMs = 600_000,
- intervalMs = 15_000,
- onTick,
- // 数字人主体识别会用 isContinue 表示「可以进下一步」
- acceptContinue = false,
- } = o;
- const url = `${JIMENG.base}${queryPath}`;
- const token = requireJimengToken();
- const started = Date.now();
- let lastTip = '生成中';
- let consecutiveErrors = 0;
- while (Date.now() - started < timeoutMs) {
- await sleep(intervalMs);
- const elapsed = Math.round((Date.now() - started) / 1000);
- try {
- const res = await fetch(url, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ workId, routerName, token: `Bearer ${token}` }),
- signal: AbortSignal.timeout(60_000),
- });
- const text = await res.text();
- // 服务端偶发回 HTML(KB 里记的坑)—— 不解析,等下一轮
- if (/^\s*(<!doctype html|<html)/i.test(text)) {
- lastTip = '网关返回 HTML(服务端错误),继续等待';
- consecutiveErrors++;
- if (onTick) onTick({ elapsed, tip: lastTip });
- continue;
- }
- const json = JSON.parse(text);
- const d = json.data || {};
- lastTip = d.tip || lastTip;
- consecutiveErrors = 0;
- if (onTick) onTick({ elapsed, tip: lastTip, isFinish: !!d.isFinish });
- if (d.isFinish) return { workId, elapsed, tip: d.tip || '已完成' };
- if (acceptContinue && d.isContinue) return { workId, elapsed, tip: d.tip || '可进入下一步' };
- const err = extractError(json);
- if (err && !/Object not found/i.test(err)) {
- throw new GatewayError(`即梦任务轮询报错:${err}`, { endpoint: url });
- }
- } catch (e) {
- if (e instanceof GatewayError) throw e;
- consecutiveErrors++;
- lastTip = `轮询异常(${e.message}),继续等待`;
- if (onTick) onTick({ elapsed, tip: lastTip });
- if (consecutiveErrors > 20) {
- throw new GatewayError(`即梦轮询连续失败 ${consecutiveErrors} 次:${e.message}`, { endpoint: url });
- }
- }
- }
- throw new GatewayError(
- `即梦任务超时(${Math.round(timeoutMs / 1000)}s 未完成;workId=${workId},最后状态「${lastTip}」)\n` +
- ` workId 已保留,可稍后手工重查:fmode-mv jimeng --work ${workId}`
- );
- }
- // ==================== 三段式 · ③ 取产物 ====================
- /**
- * 读 ImagineWork 记录,拿 images / videos 直链。
- * @returns {Promise<{images:string[], videos:string[], usage:object|null, credit:number|null, raw:object}>}
- */
- export async function fetchWork(workId) {
- const url = `${JIMENG.parseBase}/classes/${JIMENG.workClass}/${encodeURIComponent(workId)}`;
- const res = await fetch(url, {
- method: 'GET',
- headers: { 'X-Parse-Application-Id': JIMENG.parseAppId, Accept: 'application/json' },
- signal: AbortSignal.timeout(60_000),
- });
- const text = await res.text();
- if (/^\s*(<!doctype html|<html)/i.test(text)) {
- throw new GatewayError(`读取作品记录返回 HTML(网关异常):${url}`);
- }
- const json = JSON.parse(text);
- if (json.error) {
- throw new GatewayError(`读取作品记录失败(workId=${workId}):${json.error}`);
- }
- const clean = (arr) => (Array.isArray(arr) ? arr.map((u) => String(u).trim().replace(/^["']|["']$/g, '')).filter(Boolean) : []);
- return {
- images: clean(json.images),
- videos: clean(json.videos),
- usage: json.usage || null,
- credit: typeof json.credit === 'number' ? json.credit : null,
- raw: json,
- };
- }
- /** 下载到本地文件(保留重定向处理) */
- export async function downloadTo(url, outPath, { timeout = 300_000 } = {}) {
- mkdirSync(dirname(outPath), { recursive: true });
- const res = await fetch(url, { signal: AbortSignal.timeout(timeout), redirect: 'follow' });
- if (!res.ok) throw new GatewayError(`下载失败 HTTP ${res.status}:${url}`);
- const buf = Buffer.from(await res.arrayBuffer());
- if (!buf.length) throw new GatewayError(`下载得到 0 字节:${url}`);
- writeFileSync(outPath, buf);
- return { outPath, bytes: buf.length };
- }
- /**
- * 一次性跑完三段式:提交 → 轮询 → 取产物 → 下载。
- * @returns {Promise<{workId:string, files:string[], urls:string[], elapsed:number, cost:number, usage:object|null, kind:'image'|'video'}>}
- */
- export async function runTask(endpoint, body, o = {}) {
- const {
- kind = 'image',
- outPaths = [],
- outDir,
- prefix = 'out',
- ext,
- routerName,
- queryPath = '/getDataByTask02',
- timeoutMs = 600_000,
- intervalMs = 15_000,
- onTick,
- quiet = false,
- } = o;
- const ep = ENDPOINTS[endpoint] || { path: endpoint, router: String(endpoint).replace(/^\//, '') };
- const est = o.estimateCost ?? 0;
- if (!quiet) {
- process.stderr.write(` → 提交 ${ep.label || endpoint}(预估费用 ¥${est.toFixed(2)})workId 获取中…\n`);
- }
- const t0 = Date.now();
- const workId = await submit(endpoint, body, { timeout: o.submitTimeout ?? 120_000, retries: o.retries ?? 3 });
- if (!quiet) process.stderr.write(` → workId=${workId},轮询中(最长 ${Math.round(timeoutMs / 1000)}s,每 ${Math.round(intervalMs / 1000)}s 一次)…\n`);
- await poll(workId, {
- routerName: routerName || ep.router,
- queryPath,
- timeoutMs,
- intervalMs,
- onTick: onTick || (quiet ? undefined : ({ elapsed, tip }) => process.stderr.write(` [${elapsed}s] ${tip}\n`)),
- });
- const rec = await fetchWork(workId);
- const urls = kind === 'video' ? rec.videos : rec.images;
- if (!urls.length) {
- throw new GatewayError(
- `即梦任务已完成但没拿到${kind === 'video' ? '视频' : '图片'} URL(workId=${workId},videos=${rec.videos.length},images=${rec.images.length})`
- );
- }
- const files = [];
- for (let i = 0; i < urls.length; i++) {
- const target =
- outPaths[i] ||
- join(outDir || '.', `${prefix}${urls.length > 1 ? `-${i + 1}` : ''}.${ext || (kind === 'video' ? 'mp4' : 'png')}`);
- const r = await downloadTo(urls[i], target);
- files.push(r.outPath);
- if (!quiet) process.stderr.write(` ✓ 落盘 ${r.outPath}(${(r.bytes / 1024).toFixed(1)} KB)\n`);
- }
- return {
- workId,
- files,
- urls,
- elapsed: Date.now() - t0,
- cost: rec.usage?.cost ?? est,
- credit: rec.credit,
- usage: rec.usage,
- kind,
- };
- }
- // ==================== 公网图片上传(七牛) ====================
- const MIME = {
- '.png': 'image/png',
- '.jpg': 'image/jpeg',
- '.jpeg': 'image/jpeg',
- '.webp': 'image/webp',
- '.gif': 'image/gif',
- '.bmp': 'image/bmp',
- '.mp4': 'video/mp4',
- '.mov': 'video/quicktime',
- '.webm': 'video/webm',
- '.mp3': 'audio/mpeg',
- '.wav': 'audio/wav',
- '.m4a': 'audio/mp4',
- };
- export function mimeOf(p) {
- return MIME[extname(p).toLowerCase()] || 'application/octet-stream';
- }
- /** 生成七牛 key(照抄 KB 的 `genFileKey` 规则) */
- export function genFileKey(fileName, cid) {
- const n = new Date();
- const pad = (x) => String(x).padStart(2, '0');
- const rand = randomBytes(4).toString('hex').slice(0, 6);
- const day = `${n.getFullYear()}${pad(n.getMonth() + 1)}${pad(n.getDate())}`;
- const time = `${pad(n.getHours())}${pad(n.getMinutes())}${pad(n.getSeconds())}`;
- return `storage/company/${cid}/${day}/${time}-${rand}-${basename(fileName)}`;
- }
- let _qiniu = null;
- /** 取七牛上传凭证(会缓存,有效期内复用) */
- export async function getQiniuToken(cid = JIMENG.cdnCompany) {
- if (_qiniu && _qiniu.cid === cid && Date.now() < _qiniu.expireAt) return _qiniu;
- const res = await fetch(`${JIMENG.parseBase}/functions/qiniu_uptoken`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json', 'X-Parse-Application-Id': JIMENG.parseAppId },
- body: JSON.stringify({ company: cid }),
- signal: AbortSignal.timeout(30_000),
- });
- const json = await res.json();
- const r = json.result;
- if (!r || !r.uptoken) {
- throw new GatewayError(
- `取七牛上传凭证失败(company=${cid}):${JSON.stringify(json).slice(0, 200)}\n` +
- ` → 可用 FMODE_CDN_COMPANY 指定正确的 company ID`
- );
- }
- _qiniu = {
- cid,
- uptoken: r.uptoken,
- domain: r.domain || `${JIMENG.cdnHost}/`,
- host: r.zoneUrl ? new URL(r.zoneUrl).hostname : JIMENG.uploadHost,
- // uptoken 的 deadline 解不出来就按 1 小时缓存
- expireAt: Date.now() + 30 * 60_000,
- };
- return _qiniu;
- }
- /**
- * 把本地文件上传到公网(七牛),返回可直接喂给即梦的 https 直链。
- * 图生图 / 图生视频 / 数字人的所有输入图都必须先过这一关。
- * @returns {Promise<string>} 例如 https://file-cloud.fmode.cn/storage/company/xxx/...png
- */
- export async function uploadToCdn(localPath, { cid = JIMENG.cdnCompany } = {}) {
- if (!existsSync(localPath)) throw new GatewayError(`上传失败:文件不存在 ${localPath}`);
- const cfg = await getQiniuToken(cid);
- const data = readFileSync(localPath);
- const fileName = basename(localPath);
- const key = genFileKey(fileName, cid);
- const boundary = `----FormBoundary${randomBytes(16).toString('hex')}`;
- const head = Buffer.from(
- `--${boundary}\r\n` +
- `Content-Disposition: form-data; name="token"\r\n\r\n${cfg.uptoken}\r\n` +
- `--${boundary}\r\n` +
- `Content-Disposition: form-data; name="key"\r\n\r\n${key}\r\n` +
- `--${boundary}\r\n` +
- `Content-Disposition: form-data; name="file"; filename="${fileName}"\r\n` +
- `Content-Type: ${mimeOf(localPath)}\r\n\r\n`
- );
- const tail = Buffer.from(`\r\n--${boundary}--\r\n`);
- const body = Buffer.concat([head, data, tail]);
- const res = await fetch(`https://${cfg.host}/`, {
- method: 'POST',
- headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}`, 'Content-Length': String(body.length) },
- body,
- signal: AbortSignal.timeout(180_000),
- });
- const text = await res.text();
- let json;
- try {
- json = JSON.parse(text);
- } catch {
- throw new GatewayError(`七牛上传返回非 JSON(HTTP ${res.status}):${text.slice(0, 200)}`);
- }
- if (!json.key) throw new GatewayError(`七牛上传失败(HTTP ${res.status}):${text.slice(0, 200)}`);
- return `${String(cfg.domain).replace(/\/+$/, '')}/${json.key}`;
- }
- /** 批量上传(串行,避免触发限流) */
- export async function uploadAll(paths, opts = {}) {
- const urls = [];
- for (const p of paths) urls.push(await uploadToCdn(p, opts));
- return urls;
- }
- /**
- * 为「图生图/图生视频」准备输入图:
- * ① 若已满足尺寸与体积要求 → 直接上传
- * ② 否则用 ffmpeg 压到 1280×720 JPEG q4(≈150~300KB)再上传
- *
- * 为什么必须压:即梦对二进制输入有体积限制(KB 记录约 4~5MB 的 base64 字符串),
- * 大 PNG 直传会报 "Image Decode Error: invalid binary data file size"。
- *
- * @returns {Promise<string>} 公网 URL
- */
- export async function prepareImageUrl(localPath, o = {}) {
- const { maxBytes = 3 * 1024 * 1024, width = 1280, height = 720, cid = JIMENG.cdnCompany } = o;
- const st = statSync(localPath);
- if (st.size <= maxBytes && /\.(jpe?g|png|webp)$/i.test(localPath)) {
- return uploadToCdn(localPath, { cid });
- }
- const dst = join(dirname(localPath), `.cdn-${basename(localPath).replace(/\.[^.]+$/, '')}.jpg`);
- 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], {
- label: 'ffmpeg 压缩',
- timeout: 120_000,
- });
- return uploadToCdn(dst, { cid });
- }
- // ==================== 数字人 v1.5(三步) ====================
- /**
- * 数字人完整流程:主体识别 → (可选)主体检测 → 音频驱动生成视频。
- * @returns {Promise<{workId:string, file:string, elapsed:number, urls:string[]}>}
- */
- export async function digitalHumanFlow({ imagePath, audioPath, prompt = '', outFile, peFastMode = true, onTick } = {}) {
- if (!imagePath || !audioPath) throw new GatewayError('数字人需要 imagePath 与 audioPath');
- const t0 = Date.now();
- process.stderr.write(' [1/3] 上传人物图与音频到公网…\n');
- const imageUrl = await prepareImageUrl(imagePath);
- const audioUrl = await uploadToCdn(audioPath);
- process.stderr.write(` 图片 ${imageUrl}\n 音频 ${audioUrl}\n`);
- process.stderr.write(' [2/3] 主体识别(getOhIdentifyMain)…\n');
- const workId = await submit('ohIdentify', { image_url: imageUrl });
- await poll(workId, {
- routerName: 'getOhIdentifyMain',
- queryPath: '/getOhDateByTask',
- timeoutMs: 300_000,
- intervalMs: 10_000,
- acceptContinue: true,
- onTick,
- });
- process.stderr.write(` workId=${workId}\n`);
- // 步骤 2「主体检测」在 KB 里标注为可选;识别通过即可直接生成,跳过可省一次调用
- process.stderr.write(' [3/3] 数字人视频生成(getOmniHuman,¥1.00/秒)…\n');
- const genBody = { workId, audio_url: audioUrl };
- if (prompt) genBody.prompt = prompt;
- if (peFastMode) genBody.pe_fast_mode = true;
- const genWorkId = await submit('omniHuman', genBody);
- await poll(genWorkId, {
- routerName: 'getOmniHuman',
- queryPath: '/getOhDateByTask',
- timeoutMs: 900_000,
- intervalMs: 15_000,
- onTick,
- });
- const rec = await fetchWork(genWorkId);
- if (!rec.videos.length) {
- throw new GatewayError(`数字人任务完成但无视频(workId=${genWorkId})`);
- }
- const target = outFile || join(process.cwd(), 'out', 'digital-human.mp4');
- const r = await downloadTo(rec.videos[0], target);
- return {
- workId: genWorkId,
- identifyWorkId: workId,
- file: r.outPath,
- bytes: r.bytes,
- urls: rec.videos,
- elapsed: Date.now() - t0,
- cost: rec.usage?.cost ?? null,
- };
- }
- // ==================== 动作模仿 ====================
- /**
- * 动作模仿(把静态人物图按模板视频驱动)。
- * @param {{imagePath:string, templateVideoUrl:string, outFile:string, version?:1|2, cutFirstSecond?:boolean}} o
- */
- export async function actorFlow({ imagePath, templateVideoUrl, outFile, version = 2, cutFirstSecond = true, onTick } = {}) {
- if (!imagePath) throw new GatewayError('动作模仿需要 imagePath');
- if (!templateVideoUrl || !/^https?:\/\//i.test(templateVideoUrl)) {
- throw new GatewayError(
- '动作模仿需要公网直链模板视频(templateVideoUrl)。若你只有本地视频,请先 uploadToCdn() 再传入。'
- );
- }
- const ep = version === 1 ? 'actor' : 'actorV2';
- const imageUrl = await prepareImageUrl(imagePath);
- const t0 = Date.now();
- const body = { image_url: imageUrl, video_url: templateVideoUrl };
- if (cutFirstSecond) body.cut_first_second = true;
- const workId = await submit(ep, body);
- await poll(workId, { routerName: ENDPOINTS[ep].router, timeoutMs: 600_000, intervalMs: 15_000, onTick });
- const rec = await fetchWork(workId);
- if (!rec.videos.length) throw new GatewayError(`动作模仿完成但无视频(workId=${workId})`);
- const target = outFile || join(process.cwd(), 'out', 'actor.mp4');
- const r = await downloadTo(rec.videos[0], target);
- return { workId, file: r.outPath, bytes: r.bytes, urls: rec.videos, elapsed: Date.now() - t0, cost: rec.usage?.cost ?? null };
- }
- // ==================== 费用台账 ====================
- /**
- * 全程费用台账。所有真实调用都应先 printEstimate(),跑完 record()。
- * 目的是让「花了多少钱」始终可追溯 —— 技能仓对用户的基本承诺。
- */
- export class CostLedger {
- constructor(label = '本次任务') {
- this.label = label;
- this.entries = [];
- this.startedAt = new Date().toISOString();
- }
- /** 调用前的预估打印 */
- printEstimate(kind, detail, amount) {
- const ep = ENDPOINTS[kind];
- const line = ` ¥${Number(amount).toFixed(2)} ${ep ? ep.label : kind}${detail ? ` · ${detail}` : ''}`;
- process.stderr.write(` 预估费用 ${line}\n`);
- return amount;
- }
- /** 调用后记账(优先用接口返回的实扣,没有则用预估) */
- record({ kind, detail = '', estimate = 0, actual = null, workId = '', file = '', ms = 0 }) {
- const amount = actual != null ? Number(actual) : Number(estimate) || 0;
- this.entries.push({ kind, detail, amount, estimate, actual, workId, file, ms, at: new Date().toISOString() });
- return amount;
- }
- get total() {
- return +this.entries.reduce((s, e) => s + (e.amount || 0), 0).toFixed(4);
- }
- summary() {
- const lines = [`费用台账 · ${this.label}`];
- for (const e of this.entries) {
- const ep = ENDPOINTS[e.kind];
- const tag = e.actual != null ? '实扣' : '预估';
- lines.push(` ¥${e.amount.toFixed(2)} [${tag}] ${ep?.label || e.kind}${e.detail ? ` · ${e.detail}` : ''}${e.ms ? ` · ${(e.ms / 1000).toFixed(1)}s` : ''}`);
- }
- lines.push(` 合计 ¥${this.total.toFixed(2)}`);
- return lines.join('\n');
- }
- }
|