browser-ffmpeg.service.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531
  1. import { Injectable, inject } from '@angular/core';
  2. import { FFmpeg } from '@ffmpeg/ffmpeg';
  3. import { fetchFile, toBlobURL } from '@ffmpeg/util';
  4. import { QiniuUploadService } from './qiniu-upload.service';
  5. // ===================================================================
  6. // 合成相关公共类型(原来在 src/app/pipelines/composite-sse.ts 中,
  7. // 后端 SSE 客户端已废弃,类型集中到此处)
  8. // ===================================================================
  9. export type CompositeStage = 'download' | 'compose' | 'concat' | 'cleanup';
  10. /**
  11. * 单段输入。按 (image/video, audio/silent) 四态分发:
  12. * - imageUrl + audioUrl -> 图片 + 解说(按音频时长)
  13. * - videoUrl + audioUrl -> 动态视频 + 解说(按音频时长)
  14. * - videoUrl 无 audioUrl -> 纯动态视频(按视频原长)
  15. * - imageUrl 无 audioUrl -> 静态画面(按 duration,默认 3s)
  16. */
  17. export interface CompositeSegmentInput {
  18. id?: string;
  19. imageUrl?: string;
  20. videoUrl?: string;
  21. audioUrl?: string;
  22. /** 仅在 imageUrl 无 audioUrl 时生效;缺省 3 秒 */
  23. duration?: number;
  24. }
  25. export interface CompositeProgressEvent {
  26. stage: CompositeStage;
  27. current?: number;
  28. total?: number;
  29. percent?: number;
  30. message?: string;
  31. }
  32. export interface CompositeDoneEvent {
  33. success: boolean;
  34. videoUrl: string;
  35. filename: string;
  36. size: number;
  37. segments: number;
  38. }
  39. export interface CompositeCallbacks {
  40. onStage?: (data: { stage: CompositeStage; message?: string }) => void;
  41. onProgress?: (data: CompositeProgressEvent) => void;
  42. onDone?: (data: CompositeDoneEvent) => void;
  43. onError?: (data: { error: string }) => void;
  44. }
  45. export interface CompositeHandle {
  46. abort: () => void;
  47. promise: Promise<CompositeDoneEvent>;
  48. }
  49. export interface BrowserAudioExtractionResult {
  50. blob: Blob;
  51. base64: string;
  52. mimeType: string;
  53. filename: string;
  54. sizeMB: number;
  55. }
  56. /**
  57. * 浏览器端合成参数。与 composeVideoStream 的入参兼容。
  58. */
  59. export interface BrowserComposeParams extends CompositeCallbacks {
  60. segments: CompositeSegmentInput[];
  61. title: string;
  62. /** 输出短边像素,默认 720(1280x720)。可选 1080。 */
  63. shortSide?: 720 | 1080;
  64. /** 浏览器端 fetchFile 失败时是否走后端 /backend/api/video-proxy 兜底。默认 true。 */
  65. useBackendProxyFallback?: boolean;
  66. }
  67. @Injectable({ providedIn: 'root' })
  68. export class BrowserFfmpegService {
  69. private ffmpeg?: FFmpeg;
  70. private loading?: Promise<void>;
  71. private qiniu = inject(QiniuUploadService);
  72. async extractAudioForGemini(
  73. input: File | Blob | string,
  74. filename = `audio-${Date.now()}.m4a`,
  75. ): Promise<BrowserAudioExtractionResult> {
  76. const ffmpeg = await this.getFfmpeg();
  77. const inputName = this.inputName(input);
  78. const outputName = filename.replace(/\.[^.]+$/, '') + '.m4a';
  79. await ffmpeg.writeFile(inputName, await fetchFile(input));
  80. const exitCode = await ffmpeg.exec([
  81. '-i', inputName,
  82. '-vn',
  83. '-c:a', 'aac',
  84. '-b:a', '64k',
  85. '-ar', '16000',
  86. '-ac', '1',
  87. outputName,
  88. ], 300000);
  89. if (exitCode !== 0) {
  90. await this.safeDelete(ffmpeg, inputName);
  91. throw new Error(`浏览器音频提取失败 (exit=${exitCode})`);
  92. }
  93. const data = await ffmpeg.readFile(outputName);
  94. await this.safeDelete(ffmpeg, inputName);
  95. await this.safeDelete(ffmpeg, outputName);
  96. const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data;
  97. const copy = new Uint8Array(bytes.byteLength);
  98. copy.set(bytes);
  99. const blob = new Blob([copy.buffer], { type: 'audio/mp4' });
  100. const base64 = await this.blobToBase64(blob);
  101. return {
  102. blob,
  103. base64,
  104. mimeType: 'audio/mp4',
  105. filename: outputName,
  106. sizeMB: blob.size / 1024 / 1024,
  107. };
  108. }
  109. async extractAudioBlob(
  110. input: File | Blob | string,
  111. filename = `extracted-${Date.now()}.m4a`,
  112. ): Promise<File> {
  113. const result = await this.extractAudioForGemini(input, filename);
  114. return new File([result.blob], result.filename, { type: result.mimeType });
  115. }
  116. private async getFfmpeg(): Promise<FFmpeg> {
  117. if (!this.ffmpeg) {
  118. this.ffmpeg = new FFmpeg();
  119. }
  120. if (!this.ffmpeg.loaded) {
  121. this.loading ??= this.load(this.ffmpeg).finally(() => {
  122. this.loading = undefined;
  123. });
  124. await this.loading;
  125. }
  126. return this.ffmpeg;
  127. }
  128. private async load(ffmpeg: FFmpeg): Promise<void> {
  129. const baseUrl = new URL('assets/ffmpeg/', document.baseURI).toString();
  130. const workerBaseUrl = new URL('worker/', baseUrl).toString();
  131. await ffmpeg.load({
  132. classWorkerURL: `${workerBaseUrl}worker.js`,
  133. coreURL: await toBlobURL(`${baseUrl}ffmpeg-core.js`, 'text/javascript'),
  134. wasmURL: await toBlobURL(`${baseUrl}ffmpeg-core.wasm`, 'application/wasm'),
  135. });
  136. }
  137. private inputName(input: File | Blob | string): string {
  138. if (typeof input === 'string') {
  139. const clean = input.split('?')[0].split('#')[0];
  140. const tail = clean.split('/').pop() || '';
  141. return this.safeName(tail || `input-${Date.now()}.mp4`);
  142. }
  143. if (input instanceof File && input.name) {
  144. return this.safeName(input.name);
  145. }
  146. return `input-${Date.now()}.mp4`;
  147. }
  148. private safeName(name: string): string {
  149. const ext = (name.match(/\.[a-z0-9]+$/i)?.[0] || '.mp4').toLowerCase();
  150. const base = name.replace(/\.[^.]+$/, '').replace(/[^a-zA-Z0-9_-]/g, '_') || 'input';
  151. return `${base}${ext}`;
  152. }
  153. private async safeDelete(ffmpeg: FFmpeg, path: string): Promise<void> {
  154. try {
  155. await ffmpeg.deleteFile(path);
  156. } catch {
  157. // best effort cleanup
  158. }
  159. }
  160. private blobToBase64(blob: Blob): Promise<string> {
  161. return new Promise((resolve, reject) => {
  162. const reader = new FileReader();
  163. reader.onload = () => {
  164. const value = String(reader.result || '');
  165. resolve(value.includes(',') ? value.split(',')[1] : value);
  166. };
  167. reader.onerror = () => reject(reader.error || new Error('Blob to base64 failed'));
  168. reader.readAsDataURL(blob);
  169. });
  170. }
  171. // ===================================================================
  172. // 视频合成(浏览器端) — 替代 server.js 的 /api/video/composite/stream
  173. //
  174. // 与后端一致的四态分发:
  175. // A: imageUrl + audioUrl -> 图片 + 解说,按音频时长
  176. // B: videoUrl + audioUrl -> 视频 + 解说,按音频时长
  177. // C: videoUrl 无 audioUrl -> 纯视频 + 静音轨,按视频原长
  178. // D: imageUrl 无 audioUrl -> 静态画面 + 静音轨,按 duration
  179. //
  180. // 统一规格(保证 concat -c copy 可用):
  181. // 视频 1280x720(或 1920x1080)/ 30fps / yuv420p / libx264 ultrafast
  182. // 音频 AAC 128k / 44100 / stereo
  183. // ===================================================================
  184. composeVideo(params: BrowserComposeParams): CompositeHandle {
  185. let aborted = false;
  186. const abortController = new AbortController();
  187. const promise: Promise<CompositeDoneEvent> = (async () => {
  188. const { segments, title } = params;
  189. const shortSide = params.shortSide ?? 720;
  190. const useProxy = params.useBackendProxyFallback !== false;
  191. if (!segments?.length) throw new Error('segments empty');
  192. const ffmpeg = await this.getFfmpeg();
  193. const tempFiles = new Set<string>();
  194. // 进度权重:单段编码占 80%(按段均分),拼接 15%,上传 5%
  195. const segWeight = 80 / segments.length;
  196. let baseProgress = 0;
  197. let currentSegProgress = 0;
  198. const emitProgress = (
  199. stage: CompositeStage,
  200. message?: string,
  201. current?: number,
  202. total?: number,
  203. ) => {
  204. const percent = Math.min(100, Math.round(baseProgress + currentSegProgress));
  205. params.onProgress?.({ stage, percent, current, total, message });
  206. };
  207. const emitStage = (stage: CompositeStage, message?: string) => {
  208. params.onStage?.({ stage, message });
  209. };
  210. const onEncodeProgress = (ratio: number) => {
  211. currentSegProgress = Math.max(0, Math.min(segWeight, segWeight * (Number(ratio) || 0)));
  212. params.onProgress?.({
  213. stage: 'compose',
  214. percent: Math.min(100, Math.round(baseProgress + currentSegProgress)),
  215. });
  216. };
  217. try {
  218. emitStage('download', `prepare ${segments.length} segments`);
  219. const segmentNames: string[] = [];
  220. for (let i = 0; i < segments.length; i += 1) {
  221. if (aborted) throw new Error('aborted');
  222. currentSegProgress = 0;
  223. emitStage('compose', `compose ${i + 1}/${segments.length}`);
  224. emitProgress('compose', `compose ${i + 1}/${segments.length}`, i + 1, segments.length);
  225. const segName = await this.composeOneSegment(
  226. ffmpeg,
  227. segments[i],
  228. i,
  229. shortSide,
  230. tempFiles,
  231. useProxy,
  232. abortController.signal,
  233. onEncodeProgress,
  234. );
  235. segmentNames.push(segName);
  236. baseProgress += segWeight;
  237. currentSegProgress = 0;
  238. }
  239. if (aborted) throw new Error('aborted');
  240. emitStage('concat', `concat ${segmentNames.length} segments`);
  241. emitProgress('concat', `concat ${segmentNames.length} segments`);
  242. const listText = segmentNames.map((n) => `file '${n}'`).join('\n');
  243. await ffmpeg.writeFile('list.txt', new TextEncoder().encode(listText));
  244. tempFiles.add('list.txt');
  245. const finalName = `final-${Date.now()}.mp4`;
  246. const concatExit = await ffmpeg.exec([
  247. '-f', 'concat',
  248. '-safe', '0',
  249. '-i', 'list.txt',
  250. '-c', 'copy',
  251. '-movflags', '+faststart',
  252. finalName,
  253. ]);
  254. if (concatExit !== 0) throw new Error(`concat failed (exit=${concatExit})`);
  255. tempFiles.add(finalName);
  256. baseProgress = 95;
  257. const data = await ffmpeg.readFile(finalName);
  258. const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data;
  259. const copy = new Uint8Array(bytes.byteLength);
  260. copy.set(bytes);
  261. const blob = new Blob([copy.buffer], { type: 'video/mp4' });
  262. const size = blob.size;
  263. emitStage('cleanup', 'upload final mp4');
  264. const safeTitle = String(title || 'video')
  265. .replace(/[^a-zA-Z0-9\u4e00-\u9fff_-]/g, '_')
  266. .substring(0, 50) || 'video';
  267. const filename = `${safeTitle}-${Date.now()}.mp4`;
  268. const url = await new Promise<string>((resolve, reject) => {
  269. this.qiniu
  270. .uploadFileWithProgress(blob, filename, 'video/mp4', 'video')
  271. .subscribe({
  272. next: (ev) => {
  273. if (ev.state === 'progress') {
  274. params.onProgress?.({
  275. stage: 'cleanup',
  276. percent: Math.min(100, 95 + Math.round((ev.progress || 0) * 0.05)),
  277. message: `uploading ${Math.round((ev.progress || 0) * 100)}%`,
  278. });
  279. } else if (ev.state === 'done' && ev.url) {
  280. resolve(ev.url);
  281. }
  282. },
  283. error: (err) => reject(err instanceof Error ? err : new Error(String(err))),
  284. });
  285. });
  286. await Promise.all([...tempFiles].map((n) => this.safeDelete(ffmpeg, n)));
  287. const done: CompositeDoneEvent = {
  288. success: true,
  289. videoUrl: url,
  290. filename,
  291. size,
  292. segments: segments.length,
  293. };
  294. params.onProgress?.({ stage: 'cleanup', percent: 100 });
  295. params.onDone?.(done);
  296. return done;
  297. } catch (err: any) {
  298. await Promise.all([...tempFiles].map((n) => this.safeDelete(ffmpeg, n))).catch(() => {});
  299. const msg = err?.message || String(err);
  300. params.onError?.({ error: msg });
  301. throw err instanceof Error ? err : new Error(msg);
  302. }
  303. })();
  304. return {
  305. abort: () => {
  306. aborted = true;
  307. abortController.abort();
  308. try { this.ffmpeg?.terminate(); } catch { /* noop */ }
  309. this.ffmpeg = undefined;
  310. this.loading = undefined;
  311. },
  312. promise,
  313. };
  314. }
  315. private async composeOneSegment(
  316. ffmpeg: FFmpeg,
  317. seg: CompositeSegmentInput,
  318. idx: number,
  319. shortSide: 720 | 1080,
  320. tempFiles: Set<string>,
  321. useProxy: boolean,
  322. signal: AbortSignal,
  323. onEncodeProgress?: (ratio: number) => void,
  324. ): Promise<string> {
  325. const W = shortSide === 1080 ? 1920 : 1280;
  326. const H = shortSide;
  327. const baseVf = `scale=${W}:${H}:force_original_aspect_ratio=decrease,pad=${W}:${H}:(ow-iw)/2:(oh-ih)/2:black`;
  328. const audioArgs = ['-c:a', 'aac', '-b:a', '128k', '-ar', '44100', '-ac', '2'];
  329. const videoArgs = ['-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', '-r', '30', '-g', '60'];
  330. const hasImage = !!seg.imageUrl;
  331. const hasVideo = !!seg.videoUrl;
  332. const hasAudio = !!seg.audioUrl;
  333. if (!hasImage && !hasVideo) {
  334. throw new Error(`segment ${seg.id ?? idx} missing imageUrl/videoUrl`);
  335. }
  336. const segName = `seg-${idx}.mp4`;
  337. let audioName = '';
  338. let audioDuration = 0;
  339. // 仅在「真正编码段视频」的 exec 期间挂载 progress 监听,避免 probeDuration 触发段内进度。
  340. const runEncode = async (args: string[]): Promise<number> => {
  341. const handler = (e: { progress: number }) => {
  342. onEncodeProgress?.(Number(e.progress) || 0);
  343. };
  344. ffmpeg.on('progress', handler as any);
  345. try {
  346. return await ffmpeg.exec(args);
  347. } finally {
  348. try { ffmpeg.off('progress', handler as any); } catch { /* noop */ }
  349. }
  350. };
  351. if (hasAudio) {
  352. audioName = `audio-${idx}${this.guessExt(seg.audioUrl!, 'm4a')}`;
  353. await ffmpeg.writeFile(audioName, await this.fetchAsset(seg.audioUrl!, useProxy, signal));
  354. tempFiles.add(audioName);
  355. audioDuration = await this.probeDuration(ffmpeg, audioName);
  356. if (!audioDuration) throw new Error(`segment ${seg.id ?? idx} audio duration probe failed`);
  357. }
  358. let exit = 0;
  359. if (hasVideo) {
  360. const videoName = `video-${idx}.mp4`;
  361. await ffmpeg.writeFile(videoName, await this.fetchAsset(seg.videoUrl!, useProxy, signal));
  362. tempFiles.add(videoName);
  363. const videoDuration = await this.probeDuration(ffmpeg, videoName).catch(() => 0);
  364. if (hasAudio) {
  365. // Case B: video + audio, align to audio duration
  366. let vfChain = baseVf;
  367. if (videoDuration > 0 && videoDuration < audioDuration - 0.05) {
  368. const padSec = (audioDuration - videoDuration).toFixed(3);
  369. vfChain += `,tpad=stop_mode=clone:stop_duration=${padSec}`;
  370. }
  371. exit = await runEncode([
  372. '-i', videoName,
  373. '-i', audioName,
  374. '-map', '0:v:0',
  375. '-map', '1:a:0',
  376. ...videoArgs,
  377. '-vf', vfChain,
  378. ...audioArgs,
  379. '-t', String(audioDuration),
  380. segName,
  381. ]);
  382. } else {
  383. // Case C: video only + silent audio
  384. exit = await runEncode([
  385. '-i', videoName,
  386. '-f', 'lavfi', '-i', 'anullsrc=channel_layout=stereo:sample_rate=44100',
  387. '-map', '0:v:0',
  388. '-map', '1:a:0',
  389. ...videoArgs,
  390. '-vf', baseVf,
  391. ...audioArgs,
  392. '-shortest',
  393. segName,
  394. ]);
  395. }
  396. } else {
  397. const imgName = `img-${idx}${this.guessExt(seg.imageUrl!, 'jpg')}`;
  398. await ffmpeg.writeFile(imgName, await this.fetchAsset(seg.imageUrl!, useProxy, signal));
  399. tempFiles.add(imgName);
  400. if (hasAudio) {
  401. // Case A: image + audio
  402. exit = await runEncode([
  403. '-loop', '1', '-i', imgName,
  404. '-i', audioName,
  405. ...videoArgs,
  406. '-vf', baseVf,
  407. ...audioArgs,
  408. '-t', String(audioDuration),
  409. '-shortest',
  410. segName,
  411. ]);
  412. } else {
  413. // Case D: image only + silent audio (use seg.duration, default 3s)
  414. const dur = Math.max(0.5, Number(seg.duration) || 3);
  415. exit = await runEncode([
  416. '-loop', '1', '-i', imgName,
  417. '-f', 'lavfi', '-i', 'anullsrc=channel_layout=stereo:sample_rate=44100',
  418. ...videoArgs,
  419. '-vf', baseVf,
  420. ...audioArgs,
  421. '-t', String(dur),
  422. '-shortest',
  423. segName,
  424. ]);
  425. }
  426. }
  427. if (exit !== 0) throw new Error(`segment ${seg.id ?? idx} encode failed (exit=${exit})`);
  428. tempFiles.add(segName);
  429. return segName;
  430. }
  431. /** 通过运行 ffmpeg -i <name> -f null - 解析 stderr 拿到时长(秒)。 */
  432. private async probeDuration(ffmpeg: FFmpeg, name: string): Promise<number> {
  433. let dur = 0;
  434. const handler = ({ message }: { message: string }) => {
  435. const m = message?.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/);
  436. if (m) {
  437. dur = (+m[1]) * 3600 + (+m[2]) * 60 + parseFloat(m[3]);
  438. }
  439. };
  440. ffmpeg.on('log', handler as any);
  441. try {
  442. await ffmpeg.exec(['-i', name, '-f', 'null', '-']);
  443. } catch {
  444. // ffmpeg 用 null muxer 时偶尔非零退出,时长仍能从 stderr 拿到
  445. } finally {
  446. try { ffmpeg.off('log', handler as any); } catch { /* noop */ }
  447. }
  448. return dur;
  449. }
  450. /** 拉取远程资源到 Uint8Array。直连失败(CORS/network)时回退到 /backend/api/video-proxy。 */
  451. private async fetchAsset(
  452. url: string,
  453. useProxy: boolean,
  454. signal: AbortSignal,
  455. ): Promise<Uint8Array> {
  456. if (signal.aborted) throw new Error('aborted');
  457. try {
  458. const resp = await fetch(url, { signal });
  459. if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
  460. return new Uint8Array(await resp.arrayBuffer());
  461. } catch (err) {
  462. if (!useProxy) throw err;
  463. const proxied = `/backend/api/video-proxy?url=${encodeURIComponent(url)}`;
  464. const resp = await fetch(proxied, { signal });
  465. if (!resp.ok) throw new Error(`proxy fetch failed HTTP ${resp.status}`);
  466. return new Uint8Array(await resp.arrayBuffer());
  467. }
  468. }
  469. private guessExt(url: string, fallback: string): string {
  470. const m = url.split('?')[0].match(/\.([a-z0-9]{2,5})$/i);
  471. return `.${(m ? m[1] : fallback).toLowerCase()}`;
  472. }
  473. }