jimeng.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  1. // scripts/jimeng.mjs — 即梦通道直调工具
  2. //
  3. // 用途:把即梦的能力暴露到命令行,既可单独验证通道,也可做 KB 里那些脚本能做的事
  4. // (超清、局部重绘、动作模仿、数字人…),而不必再复制 KB 的硬编码脚本。
  5. //
  6. // 所有调用都会:调用前打印预估费用 → 调用后打印实扣/耗时/产物路径与字节数。
  7. import { existsSync, mkdirSync } from 'node:fs';
  8. import { join, resolve } from 'node:path';
  9. import {
  10. parseArgs, log, humanSize, fileBytes, CostLedger,
  11. JIMENG, ENDPOINTS, jimengAvailable, maskToken, jimengToken,
  12. VIDEO_FRAMES, secondsToFrames, framesToSeconds, estimateCost,
  13. uploadToCdn, submit, poll, fetchWork, downloadTo, runTask,
  14. } from '../lib/index.mjs';
  15. import { getProvider } from '../lib/providers.mjs';
  16. function printEndpoints() {
  17. log.raw('');
  18. log.raw(' 即梦端点清单(¥ 为单价;实测 2026-10-13)');
  19. log.raw(' ' + '─'.repeat(66));
  20. const rows = Object.entries(ENDPOINTS);
  21. const w = Math.max(...rows.map(([k]) => k.length));
  22. for (const [k, v] of rows) {
  23. log.raw(` ${k.padEnd(w)} ${v.path.padEnd(28)} ¥${v.cost}/${v.unit} ${v.label}`);
  24. }
  25. log.raw('');
  26. log.raw(` 通道基址 : ${JIMENG.base}`);
  27. log.raw(` 作品记录 : ${JIMENG.parseBase}/classes/${JIMENG.workClass}/{workId}`);
  28. log.raw(` 七牛公司 : ${JIMENG.cdnCompany}(可用 FMODE_CDN_COMPANY 覆盖)`);
  29. log.raw(` 视频帧数 : [${VIDEO_FRAMES.min}, ${VIDEO_FRAMES.max}] 帧 = ${framesToSeconds(VIDEO_FRAMES.min)}~${framesToSeconds(VIDEO_FRAMES.max)} 秒`);
  30. log.raw('');
  31. }
  32. function printStatus() {
  33. const t = jimengToken();
  34. log.raw('');
  35. log.raw(' 即梦通道状态');
  36. log.raw(' ' + '─'.repeat(66));
  37. log.key('sessionToken', t ? `${maskToken(t)}(已加载)` : '未找到 ← 需设置 FMODE_SESSION_TOKEN 或 /opt/data/fmode-identity.json');
  38. log.key('通道基址', JIMENG.base);
  39. log.key('可用性', jimengAvailable() ? '✅ 凭据就绪' : '⚠️ 缺凭据');
  40. log.raw('');
  41. }
  42. export async function main(argv = process.argv.slice(2)) {
  43. const { _, flags } = parseArgs(argv);
  44. const action = _[0] || (flags.status ? 'status' : '');
  45. if (flags.help || !action) {
  46. console.log(`
  47. fmode-mv jimeng — 即梦通道直调(✅ 实测可用)
  48. 用法:
  49. fmode-mv jimeng --status # 通道状态(不产生费用)
  50. fmode-mv jimeng --endpoints # 端点清单 + 费用表
  51. fmode-mv jimeng --work <workId> # 查某个 workId 的结果直链
  52. fmode-mv jimeng --test # 真跑一张最小尺寸文生图(¥0.20)
  53. # 文生图
  54. fmode-mv jimeng text2img --prompt "..." [--width 1024 --height 1024] [--model v3|v31|v4] [--out f.png]
  55. # 图生图(多图参考,自动压缩+上传七牛)
  56. fmode-mv jimeng img2img --prompt "..." --ref a.png --ref b.png [--width --height --scale 0.6] [--out f.png]
  57. # 图生视频
  58. fmode-mv jimeng img2video --first a.png [--last b.png] --prompt "..." [--seconds 5] [--resolution 720p|1080p|pro]
  59. # 智能超清
  60. fmode-mv jimeng upscale --ref a.png [--resolution 4k|8k] [--out f.png]
  61. # 动作模仿(模板视频需公网直链)
  62. fmode-mv jimeng actor --ref a.png --video-url "https://..." [--version 2] [--out f.mp4]
  63. # 数字人(三步流程)
  64. fmode-mv jimeng digital-human --ref a.png --audio a.mp3 [--prompt "动作描述"] [--out f.mp4]
  65. # 传图到公网(拿直链)
  66. fmode-mv jimeng upload --ref a.png
  67. 选项:
  68. --prompt "..." 提示词
  69. --ref <file> 输入图(可重复传多张)
  70. --first/--last 图生视频的首帧 / 尾帧
  71. --audio <file> 数字人音频(mp3/wav)
  72. --video-url <url> 动作模仿的模板视频公网直链
  73. --seconds <n> 视频时长(会吸附到 5~10 秒,接口限 121~241 帧)
  74. --resolution ... 720p(默认)/1080p/pro;超清用 4k/8k
  75. --out <file> 输出文件
  76. --width/--height 图像尺寸(默认 1024×1024)
  77. --scale <0-1> 图生图文本影响程度(默认 0.6)
  78. --dry-run 只打印将发送的请求,不调用(零费用)
  79. ⚠️ 凭据:sessionToken 只从 FMODE_SESSION_TOKEN 或 /opt/data/fmode-identity.json 运行时读取,
  80. 本仓不写入、不提交任何 token。
  81. `);
  82. return 0;
  83. }
  84. const ledger = new CostLedger('jimeng 直调');
  85. const outDir = String(flags.out ? resolve(flags.out, '..') : join(resolve('.'), 'out', 'jimeng'));
  86. const outFile = flags.out ? resolve(String(flags.out)) : null;
  87. const provider = getProvider('jimeng');
  88. // ---------- 只读动作 ----------
  89. if (action === 'status' || flags.status) {
  90. printStatus();
  91. return jimengAvailable() ? 0 : 1;
  92. }
  93. if (action === 'endpoints' || flags.endpoints) {
  94. printEndpoints();
  95. return 0;
  96. }
  97. if (flags.work) {
  98. const workId = String(flags.work);
  99. const rec = await fetchWork(workId);
  100. log.raw('');
  101. log.key('workId', workId);
  102. log.key('images', rec.images.length ? rec.images.join('\n ') : '(无)');
  103. log.key('videos', rec.videos.length ? rec.videos.join('\n ') : '(无)');
  104. log.key('实扣', rec.usage?.cost != null ? `¥${rec.usage.cost}` : '未记录');
  105. return 0;
  106. }
  107. if (action === 'upload') {
  108. const refs = toArray(flags.ref);
  109. if (!refs.length) throw new Error('需要 --ref <file>');
  110. for (const f of refs) {
  111. const url = await uploadToCdn(f);
  112. log.ok(`${f} → ${url}`);
  113. }
  114. return 0;
  115. }
  116. // ---------- 需要凭据 ----------
  117. if (!jimengAvailable()) {
  118. log.err('未找到 sessionToken');
  119. log.dim('设置 FMODE_SESSION_TOKEN,或在 /opt/data/fmode-identity.json 写入 session_token');
  120. return 1;
  121. }
  122. if (flags['dry-run']) {
  123. log.raw('');
  124. log.raw(` [dry-run] action=${action}`);
  125. log.raw(` ${JSON.stringify({ ...flags, ref: toArray(flags.ref) }, null, 2)}`);
  126. log.raw(' 未调用通道,无费用。');
  127. return 0;
  128. }
  129. if (action === 'test') {
  130. log.step('即梦通道自检:真跑一张 1024×1024 文生图(¥0.20)');
  131. const f = await provider.text2img({
  132. prompt: 'a single red apple resting on a wooden table, soft window daylight, photorealistic, shallow depth of field',
  133. width: 1024,
  134. height: 1024,
  135. outFile: outFile || join(outDir, 'selftest.png'),
  136. ledger,
  137. });
  138. log.ok(`→ ${f}(${humanSize(fileBytes(f))})`);
  139. log.raw(ledger.summary());
  140. return 0;
  141. }
  142. if (action === 'text2img') {
  143. const f = await provider.text2img({
  144. prompt: String(flags.prompt || ''),
  145. width: Number(flags.width) || 1024,
  146. height: Number(flags.height) || 1024,
  147. model: String(flags.model || 'v3'),
  148. outFile: outFile || join(outDir, `text2img-${Date.now()}.png`),
  149. ledger,
  150. });
  151. log.ok(`→ ${f}(${humanSize(fileBytes(f))})`);
  152. log.raw(ledger.summary());
  153. return 0;
  154. }
  155. if (action === 'img2img') {
  156. const refs = toArray(flags.ref);
  157. if (!refs.length) throw new Error('需要 --ref <file>(可多张)');
  158. const f = await provider.img2img({
  159. prompt: String(flags.prompt || ''),
  160. refImagePaths: refs,
  161. width: Number(flags.width) || 2560,
  162. height: Number(flags.height) || 1440,
  163. scale: flags.scale != null ? Number(flags.scale) : 0.6,
  164. outFile: outFile || join(outDir, `img2img-${Date.now()}.png`),
  165. ledger,
  166. });
  167. log.ok(`→ ${f}(${humanSize(fileBytes(f))})`);
  168. log.raw(ledger.summary());
  169. return 0;
  170. }
  171. if (action === 'img2video') {
  172. const first = String(flags.first || toArray(flags.ref)[0] || '');
  173. if (!first) throw new Error('需要 --first <file>');
  174. const seconds = Number(flags.seconds) || 5;
  175. const frames = secondsToFrames(seconds);
  176. log.raw('');
  177. log.raw(` 时长 ${seconds}s → 吸附为 ${framesToSeconds(frames)}s(${frames} 帧);接口限 ${VIDEO_FRAMES.min}~${VIDEO_FRAMES.max} 帧`);
  178. const r = await provider.img2video({
  179. prompt: String(flags.prompt || ''),
  180. firstImagePath: first,
  181. lastImagePath: flags.last ? String(flags.last) : undefined,
  182. seconds,
  183. resolution: String(flags.resolution || '720p'),
  184. method: flags.last ? '3' : '2',
  185. outFile: outFile || join(outDir, `clip-${Date.now()}.mp4`),
  186. ledger,
  187. });
  188. log.ok(`→ ${r.files[0]}(${humanSize(fileBytes(r.files[0]))},method=${r.method}${r.degraded ? ' 已降级' : ''},workId=${r.workId})`);
  189. log.raw(ledger.summary());
  190. return 0;
  191. }
  192. if (action === 'upscale') {
  193. const refs = toArray(flags.ref);
  194. if (!refs.length) throw new Error('需要 --ref <file>');
  195. const f = await provider.upscale({
  196. imagePath: refs[0],
  197. resolution: String(flags.resolution || '4k'),
  198. outFile: outFile || join(outDir, `upscale-${Date.now()}.png`),
  199. ledger,
  200. });
  201. log.ok(`→ ${f}(${humanSize(fileBytes(f))})`);
  202. log.raw(ledger.summary());
  203. return 0;
  204. }
  205. if (action === 'actor') {
  206. const refs = toArray(flags.ref);
  207. if (!refs.length) throw new Error('需要 --ref <file>');
  208. if (!flags['video-url']) throw new Error('需要 --video-url <公网直链 mp4>');
  209. const f = await provider.actor({
  210. imagePath: refs[0],
  211. templateVideoUrl: String(flags['video-url']),
  212. version: Number(flags.version) || 2,
  213. outFile: outFile || join(outDir, `actor-${Date.now()}.mp4`),
  214. ledger,
  215. });
  216. log.ok(`→ ${f}(${humanSize(fileBytes(f))})`);
  217. log.raw(ledger.summary());
  218. return 0;
  219. }
  220. if (action === 'digital-human' || action === 'digitalhuman') {
  221. const refs = toArray(flags.ref);
  222. if (!refs.length) throw new Error('需要 --ref <file>(人物图)');
  223. if (!flags.audio) throw new Error('需要 --audio <file>(配音音频)');
  224. const f = await provider.digitalHuman({
  225. imagePath: refs[0],
  226. audioPath: String(flags.audio),
  227. prompt: String(flags.prompt || ''),
  228. outFile: outFile || join(outDir, `digital-human-${Date.now()}.mp4`),
  229. ledger,
  230. });
  231. log.ok(`→ ${f}(${humanSize(fileBytes(f))})`);
  232. log.raw(ledger.summary());
  233. return 0;
  234. }
  235. // 兜底:允许直接按端点名提交(高级用法)
  236. if (ENDPOINTS[action]) {
  237. const body = {};
  238. if (flags.prompt) body.prompt = String(flags.prompt);
  239. if (flags.width || flags.height) body.sizeDate = { width: Number(flags.width) || 1024, height: Number(flags.height) || 1024 };
  240. log.raw(` 直接提交端点 ${action}(${ENDPOINTS[action].label})`);
  241. const est = estimateCost(action);
  242. ledger.printEstimate(action, '', est);
  243. const r = await runTask(action, body, {
  244. kind: ENDPOINTS[action].unit === '秒' ? 'video' : 'image',
  245. outDir,
  246. prefix: action,
  247. estimateCost: est,
  248. });
  249. log.ok(`→ ${r.files.join(', ')}(workId=${r.workId})`);
  250. log.raw(ledger.summary());
  251. return 0;
  252. }
  253. log.err(`未知动作:${action}`);
  254. log.dim('可用:status / endpoints / --work / --test / text2img / img2img / img2video / upscale / actor / digital-human / upload');
  255. return 1;
  256. }
  257. function toArray(v) {
  258. if (v == null || v === true) return [];
  259. return Array.isArray(v) ? v.map(String) : [String(v)];
  260. }
  261. export { submit, poll, fetchWork, downloadTo, mkdirSync, existsSync };