// 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}/ 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 ''; 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} 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*(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*( 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*( (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} 例如 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} 公网 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'); } }