// scripts/lipsync.mjs — 唇形驱动(provider: lipsync) // // ⚠️ 现状:平台暂无唇形驱动通道。 // 本脚本: // · probe() 真实探活当前端点,明确告知是否可用; // · 不可用时抛出 ProviderUnavailableError,并给出三条可操作替代路线; // · 若用户自建服务(SadTalker / Wav2Lip / LatentSync),用 FEME_LIPSYNC_ENDPOINT 指过来即可跑通。 import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { parseArgs, readJSON, writeJSON, paths, ensureWork, log, invoke, download, } from '../lib/index.mjs'; import { getProvider } from '../lib/providers.mjs'; function pickUrl(result, re = /\.(mp4|mov|webm)/i) { const urls = []; const walk = (o, d = 0) => { if (!o || d > 6) return; if (typeof o === 'string' && /^https?:\/\//.test(o) && re.test(o)) urls.push(o); else if (Array.isArray(o)) o.forEach((x) => walk(x, d + 1)); else if (typeof o === 'object') Object.values(o).forEach((v) => walk(v, d + 1)); }; walk(result); return urls[0] || null; } export async function driveLip(shot, { faceImage, audio, outDir, model } = {}) { const p = getProvider('lipsync'); const dir = outDir || paths.lipsync; mkdirSync(dir, { recursive: true }); const result = await p.call({ image: faceImage, audio, model: model || p.defaultModel() }); const url = pickUrl(result.result ?? result); if (!url) throw new Error(`唇形通道返回成功但未包含视频 URL:${JSON.stringify(result).slice(0, 300)}`); const buf = await download(url); const name = shot?.id ? `lip-${String(shot.id).padStart(2, '0')}.mp4` : 'lipsync.mp4'; const out = join(dir, name); writeFileSync(out, buf); return { path: out, bytes: buf.length, url, raw: result }; } export async function main(argv = process.argv.slice(2)) { const { _, flags } = parseArgs(argv); const face = flags.face || _[0]; const audio = flags.audio || _[1]; if (flags.help || (!face && !flags.probe)) { console.log(` fmode-mv lipsync — 唇形驱动(lipsync 通道) 用法: fmode-mv lipsync --probe # 探活当前通道 fmode-mv lipsync --face work/frames/shot-01.png --audio work/audio/vocals.wav --shot 1 fmode-mv lipsync --from work/storyboard.json --audio work/audio/vocals.wav # 批量 选项: --probe 只探活,不生成 --face 人脸关键帧 --audio 驱动音频(人声干声) --from 分镜(批量处理 lipSync:true 的镜头) --shot 只处理某镜 --out 输出目录(默认 work/lipsync) --model 覆盖模型 ⚠️ 现状:平台暂无唇形驱动通道。替代方案: A) 自建 SadTalker / Wav2Lip / LatentSync,然后: export FEME_LIPSYNC_ENDPOINT=https://your-host/lipsync 本命令即可直接跑通(请求体:{image, audio, model}) B) 不做唇形:人物镜头改用「表情/头部微动」的图生视频镜头,观感可接受 C) 纯空镜 B-roll + 字幕(纯音乐 MV 常见做法,零风险) `); return 0; } const p = getProvider('lipsync'); log.step('唇形通道探活'); const pr = await p.probe({}); log.key('状态', pr.available ? '可用' : '不可用'); log.info(pr.detail); if (flags.probe) return pr.available ? 0 : 3; if (!pr.available) { throw Object.assign(new Error(`唇形驱动通道不可用:${pr.detail}`), { hint: p.hint, name: 'ProviderUnavailableError' }); } ensureWork(); if (flags.from) { const sb = readJSON(String(flags.from)); const jobs = sb.shots.filter((s) => s.lipSync && (!flags.shot || s.id === Number(flags.shot))); if (!jobs.length) { log.warn('分镜里没有标记 lipSync:true 的镜头,无需处理'); return 0; } for (const shot of jobs) { const faceImg = join(paths.frames, `shot-${String(shot.id).padStart(2, '0')}.png`); if (!existsSync(faceImg)) { log.err(`[shot ${shot.id}] 缺少人脸关键帧 ${faceImg}`); continue; } const r = await driveLip(shot, { faceImage: faceImg, audio: flags.audio }); log.ok(`[shot ${shot.id}] → ${r.path}`); } return 0; } if (!face || !audio) throw new Error('需要 --face 与 --audio '); if (!existsSync(face)) throw new Error(`人脸图片不存在:${face}`); if (!existsSync(audio)) throw new Error(`音频不存在:${audio}`); const r = await driveLip({ id: flags.shot ? Number(flags.shot) : null }, { faceImage: face, audio }); log.ok(`→ ${r.path}`); return 0; } if (import.meta.url === `file://${process.argv[1]}`) { main().then((c) => process.exit(Number(c) || 0)).catch(async (e) => { const { reportUnavailable, log: L } = await import('../lib/index.mjs'); if (e?.name === 'ProviderUnavailableError' || e?.hint) { reportUnavailable('lipsync', e); process.exit(3); } L.err(e.message); process.exit(1); }); }