lipsync.mjs 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
  1. // scripts/lipsync.mjs — 唇形驱动(provider: lipsync)
  2. //
  3. // ⚠️ 现状:平台暂无唇形驱动通道。
  4. // 本脚本:
  5. // · probe() 真实探活当前端点,明确告知是否可用;
  6. // · 不可用时抛出 ProviderUnavailableError,并给出三条可操作替代路线;
  7. // · 若用户自建服务(SadTalker / Wav2Lip / LatentSync),用 FEME_LIPSYNC_ENDPOINT 指过来即可跑通。
  8. import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
  9. import { join } from 'node:path';
  10. import {
  11. parseArgs, readJSON, writeJSON, paths, ensureWork, log, invoke, download,
  12. } from '../lib/index.mjs';
  13. import { getProvider } from '../lib/providers.mjs';
  14. function pickUrl(result, re = /\.(mp4|mov|webm)/i) {
  15. const urls = [];
  16. const walk = (o, d = 0) => {
  17. if (!o || d > 6) return;
  18. if (typeof o === 'string' && /^https?:\/\//.test(o) && re.test(o)) urls.push(o);
  19. else if (Array.isArray(o)) o.forEach((x) => walk(x, d + 1));
  20. else if (typeof o === 'object') Object.values(o).forEach((v) => walk(v, d + 1));
  21. };
  22. walk(result);
  23. return urls[0] || null;
  24. }
  25. export async function driveLip(shot, { faceImage, audio, outDir, model } = {}) {
  26. const p = getProvider('lipsync');
  27. const dir = outDir || paths.lipsync;
  28. mkdirSync(dir, { recursive: true });
  29. const result = await p.call({ image: faceImage, audio, model: model || p.defaultModel() });
  30. const url = pickUrl(result.result ?? result);
  31. if (!url) throw new Error(`唇形通道返回成功但未包含视频 URL:${JSON.stringify(result).slice(0, 300)}`);
  32. const buf = await download(url);
  33. const name = shot?.id ? `lip-${String(shot.id).padStart(2, '0')}.mp4` : 'lipsync.mp4';
  34. const out = join(dir, name);
  35. writeFileSync(out, buf);
  36. return { path: out, bytes: buf.length, url, raw: result };
  37. }
  38. export async function main(argv = process.argv.slice(2)) {
  39. const { _, flags } = parseArgs(argv);
  40. const face = flags.face || _[0];
  41. const audio = flags.audio || _[1];
  42. if (flags.help || (!face && !flags.probe)) {
  43. console.log(`
  44. fmode-mv lipsync — 唇形驱动(lipsync 通道)
  45. 用法:
  46. fmode-mv lipsync --probe # 探活当前通道
  47. fmode-mv lipsync --face work/frames/shot-01.png --audio work/audio/vocals.wav --shot 1
  48. fmode-mv lipsync --from work/storyboard.json --audio work/audio/vocals.wav # 批量
  49. 选项:
  50. --probe 只探活,不生成
  51. --face <png> 人脸关键帧
  52. --audio <wav> 驱动音频(人声干声)
  53. --from <file> 分镜(批量处理 lipSync:true 的镜头)
  54. --shot <id> 只处理某镜
  55. --out <dir> 输出目录(默认 work/lipsync)
  56. --model <name> 覆盖模型
  57. ⚠️ 现状:平台暂无唇形驱动通道。替代方案:
  58. A) 自建 SadTalker / Wav2Lip / LatentSync,然后:
  59. export FEME_LIPSYNC_ENDPOINT=https://your-host/lipsync
  60. 本命令即可直接跑通(请求体:{image, audio, model})
  61. B) 不做唇形:人物镜头改用「表情/头部微动」的图生视频镜头,观感可接受
  62. C) 纯空镜 B-roll + 字幕(纯音乐 MV 常见做法,零风险)
  63. `);
  64. return 0;
  65. }
  66. const p = getProvider('lipsync');
  67. log.step('唇形通道探活');
  68. const pr = await p.probe({});
  69. log.key('状态', pr.available ? '可用' : '不可用');
  70. log.info(pr.detail);
  71. if (flags.probe) return pr.available ? 0 : 3;
  72. if (!pr.available) {
  73. throw Object.assign(new Error(`唇形驱动通道不可用:${pr.detail}`), { hint: p.hint, name: 'ProviderUnavailableError' });
  74. }
  75. ensureWork();
  76. if (flags.from) {
  77. const sb = readJSON(String(flags.from));
  78. const jobs = sb.shots.filter((s) => s.lipSync && (!flags.shot || s.id === Number(flags.shot)));
  79. if (!jobs.length) {
  80. log.warn('分镜里没有标记 lipSync:true 的镜头,无需处理');
  81. return 0;
  82. }
  83. for (const shot of jobs) {
  84. const faceImg = join(paths.frames, `shot-${String(shot.id).padStart(2, '0')}.png`);
  85. if (!existsSync(faceImg)) {
  86. log.err(`[shot ${shot.id}] 缺少人脸关键帧 ${faceImg}`);
  87. continue;
  88. }
  89. const r = await driveLip(shot, { faceImage: faceImg, audio: flags.audio });
  90. log.ok(`[shot ${shot.id}] → ${r.path}`);
  91. }
  92. return 0;
  93. }
  94. if (!face || !audio) throw new Error('需要 --face <png> 与 --audio <wav>');
  95. if (!existsSync(face)) throw new Error(`人脸图片不存在:${face}`);
  96. if (!existsSync(audio)) throw new Error(`音频不存在:${audio}`);
  97. const r = await driveLip({ id: flags.shot ? Number(flags.shot) : null }, { faceImage: face, audio });
  98. log.ok(`→ ${r.path}`);
  99. return 0;
  100. }
  101. if (import.meta.url === `file://${process.argv[1]}`) {
  102. main().then((c) => process.exit(Number(c) || 0)).catch(async (e) => {
  103. const { reportUnavailable, log: L } = await import('../lib/index.mjs');
  104. if (e?.name === 'ProviderUnavailableError' || e?.hint) {
  105. reportUnavailable('lipsync', e);
  106. process.exit(3);
  107. }
  108. L.err(e.message);
  109. process.exit(1);
  110. });
  111. }