// scripts/jimeng.mjs — 即梦通道直调工具 // // 用途:把即梦的能力暴露到命令行,既可单独验证通道,也可做 KB 里那些脚本能做的事 // (超清、局部重绘、动作模仿、数字人…),而不必再复制 KB 的硬编码脚本。 // // 所有调用都会:调用前打印预估费用 → 调用后打印实扣/耗时/产物路径与字节数。 import { existsSync, mkdirSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { parseArgs, log, humanSize, fileBytes, CostLedger, JIMENG, ENDPOINTS, jimengAvailable, maskToken, jimengToken, VIDEO_FRAMES, secondsToFrames, framesToSeconds, estimateCost, uploadToCdn, submit, poll, fetchWork, downloadTo, runTask, } from '../lib/index.mjs'; import { getProvider } from '../lib/providers.mjs'; function printEndpoints() { log.raw(''); log.raw(' 即梦端点清单(¥ 为单价;实测 2026-10-13)'); log.raw(' ' + '─'.repeat(66)); const rows = Object.entries(ENDPOINTS); const w = Math.max(...rows.map(([k]) => k.length)); for (const [k, v] of rows) { log.raw(` ${k.padEnd(w)} ${v.path.padEnd(28)} ¥${v.cost}/${v.unit} ${v.label}`); } log.raw(''); log.raw(` 通道基址 : ${JIMENG.base}`); log.raw(` 作品记录 : ${JIMENG.parseBase}/classes/${JIMENG.workClass}/{workId}`); log.raw(` 七牛公司 : ${JIMENG.cdnCompany}(可用 FMODE_CDN_COMPANY 覆盖)`); log.raw(` 视频帧数 : [${VIDEO_FRAMES.min}, ${VIDEO_FRAMES.max}] 帧 = ${framesToSeconds(VIDEO_FRAMES.min)}~${framesToSeconds(VIDEO_FRAMES.max)} 秒`); log.raw(''); } function printStatus() { const t = jimengToken(); log.raw(''); log.raw(' 即梦通道状态'); log.raw(' ' + '─'.repeat(66)); log.key('sessionToken', t ? `${maskToken(t)}(已加载)` : '未找到 ← 需设置 FMODE_SESSION_TOKEN 或 /opt/data/fmode-identity.json'); log.key('通道基址', JIMENG.base); log.key('可用性', jimengAvailable() ? '✅ 凭据就绪' : '⚠️ 缺凭据'); log.raw(''); } export async function main(argv = process.argv.slice(2)) { const { _, flags } = parseArgs(argv); const action = _[0] || (flags.status ? 'status' : ''); if (flags.help || !action) { console.log(` fmode-mv jimeng — 即梦通道直调(✅ 实测可用) 用法: fmode-mv jimeng --status # 通道状态(不产生费用) fmode-mv jimeng --endpoints # 端点清单 + 费用表 fmode-mv jimeng --work # 查某个 workId 的结果直链 fmode-mv jimeng --test # 真跑一张最小尺寸文生图(¥0.20) # 文生图 fmode-mv jimeng text2img --prompt "..." [--width 1024 --height 1024] [--model v3|v31|v4] [--out f.png] # 图生图(多图参考,自动压缩+上传七牛) fmode-mv jimeng img2img --prompt "..." --ref a.png --ref b.png [--width --height --scale 0.6] [--out f.png] # 图生视频 fmode-mv jimeng img2video --first a.png [--last b.png] --prompt "..." [--seconds 5] [--resolution 720p|1080p|pro] # 智能超清 fmode-mv jimeng upscale --ref a.png [--resolution 4k|8k] [--out f.png] # 动作模仿(模板视频需公网直链) fmode-mv jimeng actor --ref a.png --video-url "https://..." [--version 2] [--out f.mp4] # 数字人(三步流程) fmode-mv jimeng digital-human --ref a.png --audio a.mp3 [--prompt "动作描述"] [--out f.mp4] # 传图到公网(拿直链) fmode-mv jimeng upload --ref a.png 选项: --prompt "..." 提示词 --ref 输入图(可重复传多张) --first/--last 图生视频的首帧 / 尾帧 --audio 数字人音频(mp3/wav) --video-url 动作模仿的模板视频公网直链 --seconds 视频时长(会吸附到 5~10 秒,接口限 121~241 帧) --resolution ... 720p(默认)/1080p/pro;超清用 4k/8k --out 输出文件 --width/--height 图像尺寸(默认 1024×1024) --scale <0-1> 图生图文本影响程度(默认 0.6) --dry-run 只打印将发送的请求,不调用(零费用) ⚠️ 凭据:sessionToken 只从 FMODE_SESSION_TOKEN 或 /opt/data/fmode-identity.json 运行时读取, 本仓不写入、不提交任何 token。 `); return 0; } const ledger = new CostLedger('jimeng 直调'); const outDir = String(flags.out ? resolve(flags.out, '..') : join(resolve('.'), 'out', 'jimeng')); const outFile = flags.out ? resolve(String(flags.out)) : null; const provider = getProvider('jimeng'); // ---------- 只读动作 ---------- if (action === 'status' || flags.status) { printStatus(); return jimengAvailable() ? 0 : 1; } if (action === 'endpoints' || flags.endpoints) { printEndpoints(); return 0; } if (flags.work) { const workId = String(flags.work); const rec = await fetchWork(workId); log.raw(''); log.key('workId', workId); log.key('images', rec.images.length ? rec.images.join('\n ') : '(无)'); log.key('videos', rec.videos.length ? rec.videos.join('\n ') : '(无)'); log.key('实扣', rec.usage?.cost != null ? `¥${rec.usage.cost}` : '未记录'); return 0; } if (action === 'upload') { const refs = toArray(flags.ref); if (!refs.length) throw new Error('需要 --ref '); for (const f of refs) { const url = await uploadToCdn(f); log.ok(`${f} → ${url}`); } return 0; } // ---------- 需要凭据 ---------- if (!jimengAvailable()) { log.err('未找到 sessionToken'); log.dim('设置 FMODE_SESSION_TOKEN,或在 /opt/data/fmode-identity.json 写入 session_token'); return 1; } if (flags['dry-run']) { log.raw(''); log.raw(` [dry-run] action=${action}`); log.raw(` ${JSON.stringify({ ...flags, ref: toArray(flags.ref) }, null, 2)}`); log.raw(' 未调用通道,无费用。'); return 0; } if (action === 'test') { log.step('即梦通道自检:真跑一张 1024×1024 文生图(¥0.20)'); const f = await provider.text2img({ prompt: 'a single red apple resting on a wooden table, soft window daylight, photorealistic, shallow depth of field', width: 1024, height: 1024, outFile: outFile || join(outDir, 'selftest.png'), ledger, }); log.ok(`→ ${f}(${humanSize(fileBytes(f))})`); log.raw(ledger.summary()); return 0; } if (action === 'text2img') { const f = await provider.text2img({ prompt: String(flags.prompt || ''), width: Number(flags.width) || 1024, height: Number(flags.height) || 1024, model: String(flags.model || 'v3'), outFile: outFile || join(outDir, `text2img-${Date.now()}.png`), ledger, }); log.ok(`→ ${f}(${humanSize(fileBytes(f))})`); log.raw(ledger.summary()); return 0; } if (action === 'img2img') { const refs = toArray(flags.ref); if (!refs.length) throw new Error('需要 --ref (可多张)'); const f = await provider.img2img({ prompt: String(flags.prompt || ''), refImagePaths: refs, width: Number(flags.width) || 2560, height: Number(flags.height) || 1440, scale: flags.scale != null ? Number(flags.scale) : 0.6, outFile: outFile || join(outDir, `img2img-${Date.now()}.png`), ledger, }); log.ok(`→ ${f}(${humanSize(fileBytes(f))})`); log.raw(ledger.summary()); return 0; } if (action === 'img2video') { const first = String(flags.first || toArray(flags.ref)[0] || ''); if (!first) throw new Error('需要 --first '); const seconds = Number(flags.seconds) || 5; const frames = secondsToFrames(seconds); log.raw(''); log.raw(` 时长 ${seconds}s → 吸附为 ${framesToSeconds(frames)}s(${frames} 帧);接口限 ${VIDEO_FRAMES.min}~${VIDEO_FRAMES.max} 帧`); const r = await provider.img2video({ prompt: String(flags.prompt || ''), firstImagePath: first, lastImagePath: flags.last ? String(flags.last) : undefined, seconds, resolution: String(flags.resolution || '720p'), method: flags.last ? '3' : '2', outFile: outFile || join(outDir, `clip-${Date.now()}.mp4`), ledger, }); log.ok(`→ ${r.files[0]}(${humanSize(fileBytes(r.files[0]))},method=${r.method}${r.degraded ? ' 已降级' : ''},workId=${r.workId})`); log.raw(ledger.summary()); return 0; } if (action === 'upscale') { const refs = toArray(flags.ref); if (!refs.length) throw new Error('需要 --ref '); const f = await provider.upscale({ imagePath: refs[0], resolution: String(flags.resolution || '4k'), outFile: outFile || join(outDir, `upscale-${Date.now()}.png`), ledger, }); log.ok(`→ ${f}(${humanSize(fileBytes(f))})`); log.raw(ledger.summary()); return 0; } if (action === 'actor') { const refs = toArray(flags.ref); if (!refs.length) throw new Error('需要 --ref '); if (!flags['video-url']) throw new Error('需要 --video-url <公网直链 mp4>'); const f = await provider.actor({ imagePath: refs[0], templateVideoUrl: String(flags['video-url']), version: Number(flags.version) || 2, outFile: outFile || join(outDir, `actor-${Date.now()}.mp4`), ledger, }); log.ok(`→ ${f}(${humanSize(fileBytes(f))})`); log.raw(ledger.summary()); return 0; } if (action === 'digital-human' || action === 'digitalhuman') { const refs = toArray(flags.ref); if (!refs.length) throw new Error('需要 --ref (人物图)'); if (!flags.audio) throw new Error('需要 --audio (配音音频)'); const f = await provider.digitalHuman({ imagePath: refs[0], audioPath: String(flags.audio), prompt: String(flags.prompt || ''), outFile: outFile || join(outDir, `digital-human-${Date.now()}.mp4`), ledger, }); log.ok(`→ ${f}(${humanSize(fileBytes(f))})`); log.raw(ledger.summary()); return 0; } // 兜底:允许直接按端点名提交(高级用法) if (ENDPOINTS[action]) { const body = {}; if (flags.prompt) body.prompt = String(flags.prompt); if (flags.width || flags.height) body.sizeDate = { width: Number(flags.width) || 1024, height: Number(flags.height) || 1024 }; log.raw(` 直接提交端点 ${action}(${ENDPOINTS[action].label})`); const est = estimateCost(action); ledger.printEstimate(action, '', est); const r = await runTask(action, body, { kind: ENDPOINTS[action].unit === '秒' ? 'video' : 'image', outDir, prefix: action, estimateCost: est, }); log.ok(`→ ${r.files.join(', ')}(workId=${r.workId})`); log.raw(ledger.summary()); return 0; } log.err(`未知动作:${action}`); log.dim('可用:status / endpoints / --work / --test / text2img / img2img / img2video / upscale / actor / digital-human / upload'); return 1; } function toArray(v) { if (v == null || v === true) return []; return Array.isArray(v) ? v.map(String) : [String(v)]; } export { submit, poll, fetchWork, downloadTo, mkdirSync, existsSync };