providers.mjs 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669
  1. // lib/providers.mjs — 可插拔 provider 注册表
  2. //
  3. // 设计原则(务必遵守):
  4. // 1. 每个 provider 显式声明 available —— 通道没开通就是 false,绝不伪装成能用。
  5. // 2. probe() 会真的去探活(默认轻量:查 /models 列表;deep 模式才真提交任务)。
  6. // 3. 调用未开通通道时抛 ProviderUnavailableError,附「怎么换通道 / 怎么对接」的指引。
  7. // 4. 用户可以用环境变量或 providers.json 覆盖任何 provider 的模型名与端点,
  8. // 不需要改代码 —— 这是「可插拔」的落点。
  9. //
  10. // 能力现状以本文件为唯一事实来源;references/capability-matrix.md 与之保持同步。
  11. import { loadConfig, requireToken } from './config.mjs';
  12. import { request, ProviderUnavailableError, GatewayError, runAsyncTask, chat } from './api.mjs';
  13. import * as JM from './jimeng.mjs';
  14. const cfg = () => loadConfig();
  15. /** 读取 provider 覆盖配置(环境变量 / 可选 providers.json) */
  16. function overrides(kind) {
  17. const c = loadConfig();
  18. const o = {};
  19. // 环境变量覆盖:FEME_<KIND>_MODEL / FEME_<KIND>_ENDPOINT
  20. const K = kind.toUpperCase().replace(/[^A-Z0-9]/g, '_');
  21. if (process.env[`FEME_${K}_MODEL`]) o.model = process.env[`FEME_${K}_MODEL`];
  22. if (process.env[`FEME_${K}_ENDPOINT`]) o.endpoint = process.env[`FEME_${K}_ENDPOINT`];
  23. // 身份文件里的 providers 段
  24. const p = c.identity?.providers?.[kind];
  25. if (p && typeof p === 'object') Object.assign(o, p);
  26. return o;
  27. }
  28. /** 通用 /models 探活:模型是否在网关模型列表里 */
  29. async function probeModelList(model) {
  30. try {
  31. const res = await request('/models', { timeout: 20_000, retries: 0 });
  32. const ids = (res.body?.data || []).map((m) => m.id);
  33. const hit = ids.find((x) => x === model) || ids.find((x) => x.includes(model)) || null;
  34. return {
  35. available: !!hit,
  36. detail: hit ? `模型 ${hit} 在网关模型列表中` : `模型 ${model} 不在网关模型列表中(当前共 ${ids.length} 个模型)`,
  37. models: ids,
  38. };
  39. } catch (e) {
  40. return { available: false, detail: `探活失败:${e.message.split('\n')[0]}` };
  41. }
  42. }
  43. /**
  44. * 判断一个端点是否「真的存在」。
  45. * 关键陷阱:网关根域对未知路径会返回 SPA 的 index.html(HTTP 200 + text/html),
  46. * 不能只看状态码,必须看 Content-Type 和响应体形状。
  47. */
  48. async function probeEndpoint(pathOrUrl, { method = 'POST', body, timeout = 20_000, base } = {}) {
  49. const b = base || cfg().rootUrl;
  50. const url = /^https?:\/\//.test(pathOrUrl) ? pathOrUrl : `${b}${pathOrUrl}`;
  51. let res;
  52. try {
  53. res = await fetch(url, {
  54. method,
  55. headers: {
  56. Authorization: `Bearer ${requireToken()}`,
  57. 'Content-Type': 'application/json',
  58. Accept: 'application/json',
  59. },
  60. body: body ? JSON.stringify(body) : undefined,
  61. signal: AbortSignal.timeout(timeout),
  62. });
  63. } catch (e) {
  64. return { exists: false, status: 0, detail: `无法连接(${e.message})` };
  65. }
  66. const ct = (res.headers.get('content-type') || '').toLowerCase();
  67. const text = (await res.text().catch(() => '')).slice(0, 400);
  68. // SPA 兜底页 → 端点不存在
  69. if (ct.includes('text/html') || /^\s*<!doctype html/i.test(text) || /^\s*<html/i.test(text)) {
  70. return { exists: false, status: res.status, spa: true, detail: `端点不存在(网关返回前端页面兜底,HTTP ${res.status})` };
  71. }
  72. let json = null;
  73. try { json = JSON.parse(text); } catch { /* 非 JSON */ }
  74. // 路由存在但方法不对(405)→ 端点存在
  75. if (res.status === 405) return { exists: true, status: 405, json, detail: '端点存在(405 方法不允许)' };
  76. // 明确的上游/渠道错误 → 端点存在但不可用
  77. const msg = json?.error?.message || json?.message || text;
  78. const lower = String(msg).toLowerCase();
  79. const noChannel =
  80. lower.includes('no available channel') || lower.includes('model_not_found') || lower.includes('task request failed');
  81. if (noChannel) return { exists: true, status: res.status, json, noChannel: true, detail: `端点存在但渠道未开通(HTTP ${res.status}):${String(msg).slice(0, 120)}` };
  82. if (res.status === 404) return { exists: false, status: 404, json, detail: `端点不存在(404)` };
  83. return { exists: true, status: res.status, json, detail: `端点存在(HTTP ${res.status})` };
  84. }
  85. // ==================== Provider 定义 ====================
  86. export const PROVIDERS = {
  87. // ---------- 文本:作曲填词 / 分镜 / 口播稿 ----------
  88. chat: {
  89. id: 'chat',
  90. kind: 'chat',
  91. label: '大语言模型(作曲填词 / 分镜 / 口播稿)',
  92. available: true,
  93. note: 'POST /v1/chat/completions,实测可用',
  94. defaultModel: () => overrides('chat').model || process.env.FEME_CHAT_MODEL || 'deepseek-v4-flash',
  95. modelCandidates: ['deepseek-v4-flash', 'doubao-seed-2-1-pro', 'deepseek-v4-pro', 'doubao-seed-2-0-pro-260215'],
  96. async probe({ deep = false } = {}) {
  97. const model = this.defaultModel();
  98. const r = await probeModelList(model);
  99. if (!r.available || !deep) return { ...r, model };
  100. const t0 = Date.now();
  101. try {
  102. await chat({ messages: [{ role: 'user', content: 'ping' }], max_tokens: 4, model });
  103. return { available: true, model, detail: `真实调用成功(${Date.now() - t0}ms)` };
  104. } catch (e) {
  105. return { available: false, model, detail: `真实调用失败:${e.message.split('\n')[0]}` };
  106. }
  107. },
  108. async call({ messages, system, temperature, max_tokens, json }) {
  109. return chat({ messages, system, temperature, max_tokens, json, model: this.defaultModel() });
  110. },
  111. },
  112. // ---------- 图像:场景 / 人物关键帧 ----------
  113. image: {
  114. id: 'image',
  115. kind: 'image',
  116. label: '文生图(场景 & 人物关键帧)',
  117. available: true,
  118. note: 'POST /v1/images/generations,实测可用(返回 b64_json)',
  119. defaultModel: () => overrides('image').model || process.env.FEME_IMAGE_MODEL || 'gpt-image-2.5-sunburst',
  120. modelCandidates: ['gpt-image-2.5-sunburst', 'doubao-seedream-4-0-250828'],
  121. async probe({ deep = false } = {}) {
  122. const model = this.defaultModel();
  123. const r = await probeModelList(model);
  124. // 图像探活不做 deep(每张都要计费),改为说明
  125. return { ...r, model, detail: r.available ? `${r.detail}(deep 会真的生成图片并计费,故默认跳过)` : r.detail };
  126. },
  127. async call({ prompt, size = '1024x1024', n = 1, refImage }) {
  128. const { generateImage } = await import('./api.mjs');
  129. const model = this.defaultModel();
  130. return generateImage({ prompt, size, n, refImage, model });
  131. },
  132. },
  133. // ---------- 音乐:AI 作曲 ----------
  134. music: {
  135. id: 'music',
  136. kind: 'music',
  137. label: 'AI 音乐生成(Suno 兼容通道)',
  138. available: false, // ← 实测:渠道未开通
  139. note: 'POST /suno/submit/music + POST /suno/fetch —— 端点存在,渠道未开通(返回 Task request failed)',
  140. defaultModel: () => overrides('music').model || process.env.FEME_MUSIC_MODEL || 'chirp-v3-5',
  141. endpoint: () => overrides('music').endpoint || '/suno/submit/music',
  142. fetchPath: () => overrides('music').fetchPath || '/suno/fetch',
  143. hint:
  144. '音乐通道未开通。可选方案:\n' +
  145. ' A) 等平台开通 Suno 渠道后,本命令无需改动即可用;\n' +
  146. ' B) 自建/对接其它音乐服务,用 FEME_MUSIC_ENDPOINT 指向兼容端点;\n' +
  147. ' C) 手工把成品音频放到 work/audio/ 下,再用 --from 跳过本步继续走后续流程。',
  148. async probe({ deep = true } = {}) {
  149. const r = await probeEndpoint(this.endpoint(), {
  150. method: 'GET',
  151. timeout: 15_000,
  152. });
  153. if (!r.exists) return { available: false, detail: r.detail, hint: this.hint };
  154. if (!deep) return { available: null, detail: r.detail + '(未做真实提交;--deep 才真提交)' };
  155. const post = await probeEndpoint(this.endpoint(), {
  156. method: 'POST',
  157. body: { prompt: 'probe', mv: this.defaultModel(), custom_mode: false, make_instrumental: true },
  158. timeout: 30_000,
  159. });
  160. if (post.exists && !post.noChannel && post.status < 400) {
  161. return { available: true, detail: '提交成功,渠道已开通' };
  162. }
  163. return {
  164. available: false,
  165. detail: post.noChannel ? post.detail : `提交失败:${post.detail}`,
  166. hint: this.hint,
  167. };
  168. },
  169. async call({ prompt, lyrics, instrumental = false, title }) {
  170. if (!this.available && !process.env.FEME_FORCE_UNAVAILABLE_CHANNELS) {
  171. // 先真试一次,渠道若被开通则照常走通;失败再给指引
  172. }
  173. const base = cfg().rootUrl;
  174. const payload = {
  175. prompt: prompt || '', // 曲风 / 风格描述
  176. mv: this.defaultModel(), // 版本(chirp-v3-5 等)
  177. title: title || undefined,
  178. custom_mode: !!lyrics,
  179. make_instrumental: !!instrumental,
  180. };
  181. if (lyrics) {
  182. // Suno 自定义模式:歌词 + 标题
  183. payload.lyrics = typeof lyrics === 'string' ? lyrics : String(lyrics);
  184. payload.prompt = prompt || '';
  185. }
  186. try {
  187. const { taskId, result } = await runAsyncTask({
  188. submitPath: this.endpoint(),
  189. listPath: this.fetchPath(),
  190. submitBase: base,
  191. fetchBase: base,
  192. payload,
  193. taskLabel: '音乐生成',
  194. interval: 6000,
  195. maxWait: 480_000,
  196. pick: (raw) => ({
  197. status: raw?.status || 'running',
  198. progress: raw?.progress,
  199. error: raw?.fail_reason || raw?.error,
  200. result: raw,
  201. }),
  202. });
  203. return { taskId, result };
  204. } catch (e) {
  205. if (e instanceof ProviderUnavailableError || /channel|401|403|404|503|502/i.test(String(e.message))) {
  206. throw new ProviderUnavailableError(`音乐通道调用失败:${e.message.split('\n')[0]}`, {
  207. endpoint: `${base}${this.endpoint()}`,
  208. status: e.status,
  209. hint: this.hint,
  210. cause: e,
  211. });
  212. }
  213. throw e;
  214. }
  215. },
  216. },
  217. // ---------- 视频:图生视频(旧网关通道,已被 jimeng 取代) ----------
  218. video: {
  219. id: 'video',
  220. kind: 'video',
  221. label: '图生视频(旧网关通道 /v1/video/generations)',
  222. available: false, // ← 实测:模型在列,渠道未开通;已改用 jimeng 通道
  223. note: '已废弃:POST /v1/video/generations 实测 503 No available channel。请用 `jimeng.img2video`(即梦视频3.0,✅ 实测可用)。',
  224. supersededBy: 'jimeng',
  225. defaultModel: () => overrides('video').model || process.env.FEME_VIDEO_MODEL || 'doubao-seedance-1-0-pro',
  226. modelCandidates: ['doubao-seedance-1-0-pro', 'doubao-seedance-1-0-lite', 'kling-v1'],
  227. endpoint: () => overrides('video').endpoint || '/video/generations',
  228. fetchPath: () => overrides('video').fetchPath || '/video/generations',
  229. hint:
  230. '旧图生视频通道未开通(模型在清单里但无可用渠道)。\n' +
  231. ' → 本技能默认已切到即梦通道:`fmode-mv video --provider jimeng`(实测可用,720p ¥0.28/秒)。\n' +
  232. ' A) 平台开通旧通道后仍可用;\n' +
  233. ' B) FEME_VIDEO_MODEL 换一个已开通渠道的视频模型;\n' +
  234. ' C) --fallback-still 用「静帧 + Ken Burns 运镜」兜底(产物带 .fallback 标记)。',
  235. async probe({ deep = false } = {}) {
  236. const model = this.defaultModel();
  237. const list = await probeModelList(model);
  238. const r = await probeEndpoint(this.endpoint(), {
  239. method: 'POST',
  240. body: { model, prompt: 'probe', duration: 4 },
  241. timeout: 25_000,
  242. });
  243. if (!r.exists) {
  244. return { available: false, model, detail: `视频端点不存在:${r.detail}`, hint: this.hint };
  245. }
  246. if (r.noChannel) {
  247. return {
  248. available: false,
  249. model,
  250. detail: `模型 ${model} 无可用渠道:${r.detail.replace(/^端点存在但渠道未开通(HTTP \d+):/, '')}`,
  251. hint: this.hint,
  252. };
  253. }
  254. if (r.status >= 400) {
  255. return { available: false, model, detail: `提交失败(HTTP ${r.status})`, hint: this.hint };
  256. }
  257. return { available: true, model, detail: '提交成功,渠道已开通' };
  258. },
  259. async call({ image, prompt, duration = 5, size = '1280x720' }) {
  260. const model = this.defaultModel();
  261. const payload = { model, prompt, duration, size, image };
  262. const { taskId, result } = await runAsyncTask({
  263. submitPath: this.endpoint(),
  264. listPath: this.fetchPath(),
  265. payload,
  266. taskLabel: '图生视频',
  267. interval: 8000,
  268. maxWait: 900_000,
  269. pick: (raw) => ({
  270. status: raw?.status || 'running',
  271. progress: raw?.progress,
  272. error: raw?.fail_reason || raw?.error?.message || raw?.error,
  273. result: raw,
  274. }),
  275. });
  276. return { taskId, result };
  277. },
  278. },
  279. // ---------- ★ 即梦:唯一实测可用的真实生成通道 ----------
  280. //
  281. // 这是本技能仓的主力通道:文生图 / 图生图 / 图生视频 / 动作模仿 / 数字人 / 超清。
  282. // 与其他 provider 的差别:它不吃 /v1 网关那套,走的是 server.fmode.cn 的
  283. // `/api/volcengine/jimeng` 代理,鉴权用 sessionToken(不是 newapi_token)。
  284. jimeng: {
  285. id: 'jimeng',
  286. kind: 'jimeng',
  287. label: '即梦(火山引擎)文生图 / 图生图 / 图生视频 / 动作模仿 / 数字人',
  288. available: true,
  289. note: 'POST {base}/<endpoint> → workId → getDataByTask02 轮询 → ImagineWork 取直链。实测 2026-10-13 全链路可用。',
  290. base: JM.JIMENG.base,
  291. cost: JM.ENDPOINTS,
  292. defaultModel: () => overrides('jimeng').model || process.env.FMODE_JIMENG_MODEL || 'jimeng-3.0',
  293. hint:
  294. '即梦通道需要 sessionToken(缺失会返回 403 请输入API_KEY或用户sessionToken)。\n' +
  295. ' A) export FMODE_SESSION_TOKEN=<你的sessionToken>\n' +
  296. ' B) 在 /opt/data/fmode-identity.json 写入 session_token 字段\n' +
  297. '本技能仓不会把 token 写入任何文件、日志或提交。',
  298. /** 轻量探活:只查 workId 不存在时的报错形状,不产生任何费用 */
  299. async probe({ deep = false } = {}) {
  300. if (!JM.jimengAvailable()) {
  301. return { available: false, detail: '未找到 sessionToken(FMODE_SESSION_TOKEN / fmode-identity.json)', hint: this.hint };
  302. }
  303. try {
  304. const res = await fetch(`${JM.JIMENG.base}/getDataByTask02`, {
  305. method: 'POST',
  306. headers: { 'Content-Type': 'application/json' },
  307. body: JSON.stringify({
  308. workId: '__fmode_mv_probe__',
  309. routerName: 'getImgV4',
  310. token: `Bearer ${JM.requireJimengToken()}`,
  311. }),
  312. signal: AbortSignal.timeout(20_000),
  313. });
  314. const text = await res.text();
  315. if (/^\s*(<!doctype html|<html)/i.test(text)) {
  316. return { available: false, detail: '网关返回前端兜底页,即梦代理未挂载', hint: this.hint };
  317. }
  318. // 200 或「Object not found」都说明鉴权通过、代理存活
  319. if (res.status === 200 || /Object not found/i.test(text)) {
  320. return {
  321. available: true,
  322. detail: deep
  323. ? '鉴权通过(轻量探活;deep 的真提交请用 `fmode-mv jimeng --test`)'
  324. : '鉴权通过,代理存活(探活不产生费用)',
  325. };
  326. }
  327. if (res.status === 401 || res.status === 403) {
  328. return { available: false, detail: `鉴权失败 HTTP ${res.status}:${text.slice(0, 120)}`, hint: this.hint };
  329. }
  330. return { available: false, detail: `异常响应 HTTP ${res.status}:${text.slice(0, 120)}`, hint: this.hint };
  331. } catch (e) {
  332. return { available: false, detail: `探活失败:${e.message.split('\n')[0]}`, hint: this.hint };
  333. }
  334. },
  335. // ---- 统一接口(模块 A 要求) ----
  336. /** 文生图 → 本地图片路径 */
  337. async text2img({ prompt, width = 1024, height = 1024, model = 'v3', outFile, outDir, prefix = 'text2img', ledger } = {}) {
  338. const ep = model === 'v31' ? 'text2imgV31' : model === 'v4' ? 'imgV4' : 'text2imgV3';
  339. // 尺寸本地先卡住:传错尺寸即梦要等轮询才回 'height or width invalid',白等一轮
  340. let size;
  341. try {
  342. size = JM.assertValidSize(ep, { width, height });
  343. } catch (e) {
  344. // 调用方没给合法尺寸 → 用该端点的推荐尺寸兜住,并明确告知(不静默换尺寸)
  345. const fallback = JM.resolveSize(ep, width >= height ? '16:9' : '9:16', '2k');
  346. process.stderr.write(
  347. ` ! ${e.message}\n → 已自动改用推荐尺寸 ${fallback.width}×${fallback.height}(${ep})\n`
  348. );
  349. size = JM.assertValidSize(ep, fallback);
  350. }
  351. const est = JM.estimateCost(ep);
  352. if (ledger) ledger.printEstimate(ep, `${size.width}×${size.height}`, est);
  353. const r = await JM.runTask(ep, { prompt, sizeDate: size }, {
  354. kind: 'image',
  355. outPaths: outFile ? [outFile] : [],
  356. outDir,
  357. prefix,
  358. estimateCost: est,
  359. });
  360. if (ledger) ledger.record({ kind: ep, detail: `${size.width}×${size.height}`, estimate: est, actual: r.cost, workId: r.workId, file: r.files[0], ms: r.elapsed });
  361. return r.files[0];
  362. },
  363. /** 图生图(多图参考)→ 本地图片路径。内部自动压缩 + 上传七牛。 */
  364. async img2img({ prompt, refImagePaths = [], width = 1024, height = 1024, scale = 0.6, endpoint = 'imgV4', outFile, outDir, prefix = 'img2img', ledger } = {}) {
  365. const refs = Array.isArray(refImagePaths) ? refImagePaths : [refImagePaths];
  366. let size;
  367. try {
  368. size = JM.assertValidSize(endpoint, { width, height });
  369. } catch (e) {
  370. const fallback = JM.resolveSize(endpoint, width >= height ? '16:9' : '9:16', '2k');
  371. process.stderr.write(` ! ${e.message}\n → 已自动改用推荐尺寸 ${fallback.width}×${fallback.height}(${endpoint})\n`);
  372. size = JM.assertValidSize(endpoint, fallback);
  373. }
  374. const urls = [];
  375. for (const p of refs.filter(Boolean)) {
  376. process.stderr.write(` → 上传参考图到公网:${p}\n`);
  377. urls.push(await JM.prepareImageUrl(p, { width: Math.max(size.width, 1280), height: Math.max(size.height, 720) }));
  378. }
  379. const est = JM.estimateCost(endpoint);
  380. if (ledger) ledger.printEstimate(endpoint, `参考 ${urls.length} 图 ${size.width}×${size.height}`, est);
  381. const r = await JM.runTask(endpoint, { prompt, image_urls: urls, sizeDate: size, scale, force_single: true }, {
  382. kind: 'image',
  383. outPaths: outFile ? [outFile] : [],
  384. outDir,
  385. prefix,
  386. estimateCost: est,
  387. });
  388. if (ledger) ledger.record({ kind: endpoint, detail: `参考${urls.length}图`, estimate: est, actual: r.cost, workId: r.workId, file: r.files[0], ms: r.elapsed });
  389. return r.files[0];
  390. },
  391. /**
  392. * 图生视频(即梦视频 3.0)→ 本地 mp4 路径。
  393. * method: '2'=首帧(需 firstImagePath);'3'=首尾帧(需 lastImagePath)。
  394. * method='3' 失败会自动降级为 '2'(照抄 KB 的降级逻辑),降级会在返回值里留痕。
  395. */
  396. async img2video({
  397. prompt,
  398. firstImagePath,
  399. lastImagePath,
  400. seconds = 5,
  401. resolution = '720p',
  402. method = '2',
  403. outFile,
  404. outDir,
  405. prefix = 'clip',
  406. ledger,
  407. } = {}) {
  408. if (!firstImagePath) throw new GatewayError('img2video 需要 firstImagePath');
  409. const ep = resolution === '1080p' ? 'video1080' : resolution === 'pro' ? 'videoPro' : 'video720';
  410. // 本地先自检帧数合法性:接口要求 frames ∈ [121,241] 且 ≡ 1 (mod 24)。
  411. // 不在本地拦住的话,要白等一轮轮询才拿到 50200 的报错。
  412. const frames = JM.assertValidFrames(JM.secondsToFrames(seconds));
  413. const realSeconds = JM.framesToSeconds(frames);
  414. const urls = [await JM.prepareImageUrl(firstImagePath)];
  415. let wantLast = method === '3';
  416. if (wantLast) {
  417. if (!lastImagePath) throw new GatewayError('method=3(首尾帧)需要 lastImagePath');
  418. urls.push(await JM.prepareImageUrl(lastImagePath));
  419. }
  420. const est = JM.estimateCost(ep, { seconds: realSeconds });
  421. if (ledger) ledger.printEstimate(ep, `${realSeconds.toFixed(1)}s/${frames}帧 method=${wantLast ? 3 : 2}`, est);
  422. const attempt = async (m, imageUrls) => {
  423. const r = await JM.runTask(ep, { prompt, frames, method: String(m), config: { image_urls: imageUrls } }, {
  424. kind: 'video',
  425. outPaths: outFile ? [outFile] : [],
  426. outDir,
  427. prefix,
  428. estimateCost: est,
  429. timeoutMs: 900_000,
  430. });
  431. return r;
  432. };
  433. let res;
  434. let degraded = false;
  435. if (wantLast) {
  436. try {
  437. res = await attempt('3', urls);
  438. } catch (e) {
  439. process.stderr.write(` ! method=3(首尾帧)失败:${String(e.message).split('\n')[0]}\n`);
  440. process.stderr.write(' → 自动降级为 method=2(首帧模式),照 KB 的降级预案\n');
  441. degraded = true;
  442. wantLast = false;
  443. res = await attempt('2', [urls[0]]);
  444. }
  445. } else {
  446. res = await attempt('2', [urls[0]]);
  447. }
  448. if (ledger) ledger.record({ kind: ep, detail: `${realSeconds.toFixed(1)}s method=${wantLast ? 3 : 2}`, estimate: est, actual: res.cost, workId: res.workId, file: res.files[0], ms: res.elapsed });
  449. res.degraded = degraded;
  450. res.frames = frames;
  451. res.method = wantLast ? '3' : '2';
  452. res.seconds = realSeconds;
  453. return res;
  454. },
  455. /** 动作模仿 → 本地 mp4 */
  456. async actor({ imagePath, templateVideoUrl, outFile, version = 2, ledger } = {}) {
  457. const ep = version === 1 ? 'actor' : 'actorV2';
  458. const est = JM.estimateCost(ep, { seconds: 8 });
  459. if (ledger) ledger.printEstimate(ep, '按 8s 估算(最低 3.2 元)', est);
  460. const r = await JM.actorFlow({ imagePath, templateVideoUrl, outFile, version });
  461. if (ledger) ledger.record({ kind: ep, detail: '动作模仿', estimate: est, actual: r.cost, workId: r.workId, file: r.file, ms: r.elapsed });
  462. return r.file;
  463. },
  464. /** 数字人(三步流程)→ 本地 mp4 */
  465. async digitalHuman({ imagePath, audioPath, prompt = '', outFile, ledger } = {}) {
  466. const est = JM.estimateCost('omniHuman', { seconds: 8 });
  467. if (ledger) ledger.printEstimate('omniHuman', '按 8s 估算', est);
  468. const r = await JM.digitalHumanFlow({ imagePath, audioPath, prompt, outFile });
  469. if (ledger) ledger.record({ kind: 'omniHuman', detail: '数字人', estimate: est, actual: r.cost, workId: r.workId, file: r.file, ms: r.elapsed });
  470. return r.file;
  471. },
  472. /** 智能超清 → 本地图片 */
  473. async upscale({ imagePath, resolution = '4k', scale = 50, outFile, outDir, prefix = 'upscale', ledger } = {}) {
  474. const url = await JM.prepareImageUrl(imagePath);
  475. const est = JM.estimateCost('superRes');
  476. if (ledger) ledger.printEstimate('superRes', resolution, est);
  477. const r = await JM.runTask('superRes', { image_urls: [url], resolution, scale }, {
  478. kind: 'image',
  479. outPaths: outFile ? [outFile] : [],
  480. outDir,
  481. prefix,
  482. estimateCost: est,
  483. });
  484. if (ledger) ledger.record({ kind: 'superRes', detail: resolution, estimate: est, actual: r.cost, workId: r.workId, file: r.files[0], ms: r.elapsed });
  485. return r.files[0];
  486. },
  487. /** 供 CLI 直调(`fmode-mv jimeng ...`)用的原始任务入口 */
  488. async call(o = {}) {
  489. const { endpoint, body, kind = 'image', ...rest } = o;
  490. return JM.runTask(endpoint, body || {}, { kind, ...rest });
  491. },
  492. },
  493. // ---------- 唇形驱动 ----------
  494. lipsync: {
  495. id: 'lipsync',
  496. kind: 'lipsync',
  497. label: '唇形驱动(口型对齐人声)',
  498. available: false, // ← 平台暂无通道
  499. note: '平台暂无通道,留接口 + 外部服务对接说明',
  500. defaultModel: () => overrides('lipsync').model || process.env.FEME_LIPSYNC_MODEL || '',
  501. endpoint: () => overrides('lipsync').endpoint || '/lipsync/generations',
  502. hint:
  503. '唇形驱动通道平台暂无。可选方案:\n' +
  504. ' A) 自建/对接 SadTalker / Wav2Lip / LatentSync,用 FEME_LIPSYNC_ENDPOINT 指向你的服务;\n' +
  505. ' B) 用「演唱镜头」替代:关键帧 + 图生视频做表情/头部微动,不严格对口型(成片观感可接受);\n' +
  506. ' C) 只走空镜 B-roll + 字幕,完全不做唇形(纯音乐 MV 常见做法)。',
  507. async probe() {
  508. const ep = this.endpoint();
  509. const r = await probeEndpoint(ep, { method: 'POST', body: { image: 'probe', audio: 'probe' }, timeout: 20_000 });
  510. if (!r.exists) {
  511. return { available: false, detail: `平台无此通道:${r.detail}`, hint: this.hint };
  512. }
  513. if (r.noChannel || r.status >= 400) {
  514. return { available: false, detail: `端点存在但不可用:${r.detail}`, hint: this.hint };
  515. }
  516. return { available: true, detail: '端点可用' };
  517. },
  518. async call() {
  519. throw new ProviderUnavailableError('唇形驱动通道不可用', { endpoint: this.endpoint(), hint: this.hint });
  520. },
  521. },
  522. // ---------- TTS:口播配音 ----------
  523. tts: {
  524. id: 'tts',
  525. kind: 'tts',
  526. label: '语音合成(口播配音)',
  527. available: false, // ← 平台暂无通道
  528. note: '平台暂无通道,留接口 + 外部服务对接说明',
  529. defaultVoice: () => overrides('tts').voice || process.env.FEME_TTS_VOICE || 'zh-CN-XiaoxiaoNeural',
  530. defaultModel: () => overrides('tts').model || process.env.FEME_TTS_MODEL || 'tts-1',
  531. endpoint: () => overrides('tts').endpoint || '/audio/speech',
  532. hint:
  533. 'TTS 通道平台暂无。可选方案:\n' +
  534. ' A) 对接 OpenAI 兼容 /v1/audio/speech:设置 FEME_TTS_ENDPOINT 与 FEME_TTS_MODEL;\n' +
  535. ' B) 本地 edge-tts(免费、中文音色多):pip install edge-tts,本技能会自动探测并调用;\n' +
  536. ' C) 自备配音音频放入 work/audio/voiceover.mp3,用 --from 跳过本步。',
  537. async probe() {
  538. // 优先探测本地 edge-tts(免费、中文音色多),再探网关端点
  539. const local = await probeLocalTTS();
  540. if (local.available) {
  541. return { available: true, provider: 'edge-tts(local)', detail: local.detail };
  542. }
  543. const r = await probeEndpoint(this.endpoint(), {
  544. method: 'POST',
  545. body: { model: this.defaultModel(), input: 'probe', voice: this.defaultVoice() },
  546. timeout: 20_000,
  547. });
  548. if (!r.exists) {
  549. return { available: false, provider: '平台网关', detail: `平台无 TTS 通道(${r.detail});本地 edge-tts 亦未安装`, hint: this.hint };
  550. }
  551. if (r.noChannel || r.status >= 400) {
  552. return { available: false, provider: '平台网关', detail: `TTS 端点不可用:${r.detail}`, hint: this.hint };
  553. }
  554. return { available: true, provider: '平台网关', detail: '端点可用' };
  555. },
  556. async call({ text, outPath, voice }) {
  557. const local = await probeLocalTTS();
  558. if (local.available) return synthLocalTTS({ text, outPath, voice: voice || this.defaultVoice() });
  559. throw new ProviderUnavailableError('TTS 通道不可用', { endpoint: this.endpoint(), hint: this.hint });
  560. },
  561. },
  562. };
  563. // ==================== 本地 TTS 探测(edge-tts)====================
  564. async function probeLocalTTS() {
  565. const { spawnSync } = await import('node:child_process');
  566. try {
  567. const r = spawnSync('edge-tts', ['--version'], { encoding: 'utf8', timeout: 10_000 });
  568. if (r.status === 0) {
  569. return { available: true, provider: 'edge-tts(local)', detail: `本地 edge-tts 可用:${(r.stdout || '').trim().split('\n')[0]}` };
  570. }
  571. } catch {
  572. /* 未安装 */
  573. }
  574. return { available: false, provider: 'edge-tts(local)', detail: '未检测到本地 edge-tts' };
  575. }
  576. async function synthLocalTTS({ text, outPath, voice }) {
  577. const { spawn } = await import('node:child_process');
  578. const args = ['--voice', voice, '--text', text, '--write-media', outPath];
  579. await new Promise((resolve, reject) => {
  580. const p = spawn('edge-tts', args);
  581. let err = '';
  582. p.stderr.on('data', (d) => (err += d));
  583. p.on('error', reject);
  584. p.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`edge-tts 退出码 ${code}: ${err.slice(0, 300)}`))));
  585. });
  586. return { outPath, voice, provider: 'edge-tts(local)' };
  587. }
  588. // ==================== 注册表 API ====================
  589. export const KINDS = ['chat', 'image', 'jimeng', 'music', 'video', 'lipsync', 'tts'];
  590. export function getProvider(kind) {
  591. const p = PROVIDERS[kind];
  592. if (!p) {
  593. throw new Error(`未知 provider 类型「${kind}」。可用:${KINDS.join(' / ')}`);
  594. }
  595. return p;
  596. }
  597. /** 探活全部 provider(并行),返回结构化能力矩阵 */
  598. export async function probeAll({ deep = false } = {}) {
  599. const results = await Promise.all(
  600. KINDS.map(async (k) => {
  601. const p = PROVIDERS[k];
  602. try {
  603. const r = await p.probe({ deep });
  604. return { kind: k, label: p.label, available: r.available, detail: r.detail, model: r.model || p.defaultModel?.(), hint: r.hint || (r.available ? undefined : p.hint) };
  605. } catch (e) {
  606. return { kind: k, label: p.label, available: false, detail: `探活异常:${e.message.split('\n')[0]}`, hint: p.hint };
  607. }
  608. })
  609. );
  610. return results;
  611. }
  612. /**
  613. * 统一调用入口。未开通通道会抛 ProviderUnavailableError(带 hint),
  614. * 绝不会「假装成功」。
  615. */
  616. export async function invoke(kind, params) {
  617. const p = getProvider(kind);
  618. return p.call(params);
  619. }