| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125 |
- // 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 <png> 人脸关键帧
- --audio <wav> 驱动音频(人声干声)
- --from <file> 分镜(批量处理 lipSync:true 的镜头)
- --shot <id> 只处理某镜
- --out <dir> 输出目录(默认 work/lipsync)
- --model <name> 覆盖模型
- ⚠️ 现状:平台暂无唇形驱动通道。替代方案:
- 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 <png> 与 --audio <wav>');
- 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);
- });
- }
|