| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531 |
- import { Injectable, inject } from '@angular/core';
- import { FFmpeg } from '@ffmpeg/ffmpeg';
- import { fetchFile, toBlobURL } from '@ffmpeg/util';
- import { QiniuUploadService } from './qiniu-upload.service';
- // ===================================================================
- // 合成相关公共类型(原来在 src/app/pipelines/composite-sse.ts 中,
- // 后端 SSE 客户端已废弃,类型集中到此处)
- // ===================================================================
- export type CompositeStage = 'download' | 'compose' | 'concat' | 'cleanup';
- /**
- * 单段输入。按 (image/video, audio/silent) 四态分发:
- * - imageUrl + audioUrl -> 图片 + 解说(按音频时长)
- * - videoUrl + audioUrl -> 动态视频 + 解说(按音频时长)
- * - videoUrl 无 audioUrl -> 纯动态视频(按视频原长)
- * - imageUrl 无 audioUrl -> 静态画面(按 duration,默认 3s)
- */
- export interface CompositeSegmentInput {
- id?: string;
- imageUrl?: string;
- videoUrl?: string;
- audioUrl?: string;
- /** 仅在 imageUrl 无 audioUrl 时生效;缺省 3 秒 */
- duration?: number;
- }
- export interface CompositeProgressEvent {
- stage: CompositeStage;
- current?: number;
- total?: number;
- percent?: number;
- message?: string;
- }
- export interface CompositeDoneEvent {
- success: boolean;
- videoUrl: string;
- filename: string;
- size: number;
- segments: number;
- }
- export interface CompositeCallbacks {
- onStage?: (data: { stage: CompositeStage; message?: string }) => void;
- onProgress?: (data: CompositeProgressEvent) => void;
- onDone?: (data: CompositeDoneEvent) => void;
- onError?: (data: { error: string }) => void;
- }
- export interface CompositeHandle {
- abort: () => void;
- promise: Promise<CompositeDoneEvent>;
- }
- export interface BrowserAudioExtractionResult {
- blob: Blob;
- base64: string;
- mimeType: string;
- filename: string;
- sizeMB: number;
- }
- /**
- * 浏览器端合成参数。与 composeVideoStream 的入参兼容。
- */
- export interface BrowserComposeParams extends CompositeCallbacks {
- segments: CompositeSegmentInput[];
- title: string;
- /** 输出短边像素,默认 720(1280x720)。可选 1080。 */
- shortSide?: 720 | 1080;
- /** 浏览器端 fetchFile 失败时是否走后端 /backend/api/video-proxy 兜底。默认 true。 */
- useBackendProxyFallback?: boolean;
- }
- @Injectable({ providedIn: 'root' })
- export class BrowserFfmpegService {
- private ffmpeg?: FFmpeg;
- private loading?: Promise<void>;
- private qiniu = inject(QiniuUploadService);
- async extractAudioForGemini(
- input: File | Blob | string,
- filename = `audio-${Date.now()}.m4a`,
- ): Promise<BrowserAudioExtractionResult> {
- const ffmpeg = await this.getFfmpeg();
- const inputName = this.inputName(input);
- const outputName = filename.replace(/\.[^.]+$/, '') + '.m4a';
- await ffmpeg.writeFile(inputName, await fetchFile(input));
- const exitCode = await ffmpeg.exec([
- '-i', inputName,
- '-vn',
- '-c:a', 'aac',
- '-b:a', '64k',
- '-ar', '16000',
- '-ac', '1',
- outputName,
- ], 300000);
- if (exitCode !== 0) {
- await this.safeDelete(ffmpeg, inputName);
- throw new Error(`浏览器音频提取失败 (exit=${exitCode})`);
- }
- const data = await ffmpeg.readFile(outputName);
- await this.safeDelete(ffmpeg, inputName);
- await this.safeDelete(ffmpeg, outputName);
- const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data;
- const copy = new Uint8Array(bytes.byteLength);
- copy.set(bytes);
- const blob = new Blob([copy.buffer], { type: 'audio/mp4' });
- const base64 = await this.blobToBase64(blob);
- return {
- blob,
- base64,
- mimeType: 'audio/mp4',
- filename: outputName,
- sizeMB: blob.size / 1024 / 1024,
- };
- }
- async extractAudioBlob(
- input: File | Blob | string,
- filename = `extracted-${Date.now()}.m4a`,
- ): Promise<File> {
- const result = await this.extractAudioForGemini(input, filename);
- return new File([result.blob], result.filename, { type: result.mimeType });
- }
- private async getFfmpeg(): Promise<FFmpeg> {
- if (!this.ffmpeg) {
- this.ffmpeg = new FFmpeg();
- }
- if (!this.ffmpeg.loaded) {
- this.loading ??= this.load(this.ffmpeg).finally(() => {
- this.loading = undefined;
- });
- await this.loading;
- }
- return this.ffmpeg;
- }
- private async load(ffmpeg: FFmpeg): Promise<void> {
- const baseUrl = new URL('assets/ffmpeg/', document.baseURI).toString();
- const workerBaseUrl = new URL('worker/', baseUrl).toString();
- await ffmpeg.load({
- classWorkerURL: `${workerBaseUrl}worker.js`,
- coreURL: await toBlobURL(`${baseUrl}ffmpeg-core.js`, 'text/javascript'),
- wasmURL: await toBlobURL(`${baseUrl}ffmpeg-core.wasm`, 'application/wasm'),
- });
- }
- private inputName(input: File | Blob | string): string {
- if (typeof input === 'string') {
- const clean = input.split('?')[0].split('#')[0];
- const tail = clean.split('/').pop() || '';
- return this.safeName(tail || `input-${Date.now()}.mp4`);
- }
- if (input instanceof File && input.name) {
- return this.safeName(input.name);
- }
- return `input-${Date.now()}.mp4`;
- }
- private safeName(name: string): string {
- const ext = (name.match(/\.[a-z0-9]+$/i)?.[0] || '.mp4').toLowerCase();
- const base = name.replace(/\.[^.]+$/, '').replace(/[^a-zA-Z0-9_-]/g, '_') || 'input';
- return `${base}${ext}`;
- }
- private async safeDelete(ffmpeg: FFmpeg, path: string): Promise<void> {
- try {
- await ffmpeg.deleteFile(path);
- } catch {
- // best effort cleanup
- }
- }
- private blobToBase64(blob: Blob): Promise<string> {
- return new Promise((resolve, reject) => {
- const reader = new FileReader();
- reader.onload = () => {
- const value = String(reader.result || '');
- resolve(value.includes(',') ? value.split(',')[1] : value);
- };
- reader.onerror = () => reject(reader.error || new Error('Blob to base64 failed'));
- reader.readAsDataURL(blob);
- });
- }
- // ===================================================================
- // 视频合成(浏览器端) — 替代 server.js 的 /api/video/composite/stream
- //
- // 与后端一致的四态分发:
- // A: imageUrl + audioUrl -> 图片 + 解说,按音频时长
- // B: videoUrl + audioUrl -> 视频 + 解说,按音频时长
- // C: videoUrl 无 audioUrl -> 纯视频 + 静音轨,按视频原长
- // D: imageUrl 无 audioUrl -> 静态画面 + 静音轨,按 duration
- //
- // 统一规格(保证 concat -c copy 可用):
- // 视频 1280x720(或 1920x1080)/ 30fps / yuv420p / libx264 ultrafast
- // 音频 AAC 128k / 44100 / stereo
- // ===================================================================
- composeVideo(params: BrowserComposeParams): CompositeHandle {
- let aborted = false;
- const abortController = new AbortController();
- const promise: Promise<CompositeDoneEvent> = (async () => {
- const { segments, title } = params;
- const shortSide = params.shortSide ?? 720;
- const useProxy = params.useBackendProxyFallback !== false;
- if (!segments?.length) throw new Error('segments empty');
- const ffmpeg = await this.getFfmpeg();
- const tempFiles = new Set<string>();
- // 进度权重:单段编码占 80%(按段均分),拼接 15%,上传 5%
- const segWeight = 80 / segments.length;
- let baseProgress = 0;
- let currentSegProgress = 0;
- const emitProgress = (
- stage: CompositeStage,
- message?: string,
- current?: number,
- total?: number,
- ) => {
- const percent = Math.min(100, Math.round(baseProgress + currentSegProgress));
- params.onProgress?.({ stage, percent, current, total, message });
- };
- const emitStage = (stage: CompositeStage, message?: string) => {
- params.onStage?.({ stage, message });
- };
- const onEncodeProgress = (ratio: number) => {
- currentSegProgress = Math.max(0, Math.min(segWeight, segWeight * (Number(ratio) || 0)));
- params.onProgress?.({
- stage: 'compose',
- percent: Math.min(100, Math.round(baseProgress + currentSegProgress)),
- });
- };
- try {
- emitStage('download', `prepare ${segments.length} segments`);
- const segmentNames: string[] = [];
- for (let i = 0; i < segments.length; i += 1) {
- if (aborted) throw new Error('aborted');
- currentSegProgress = 0;
- emitStage('compose', `compose ${i + 1}/${segments.length}`);
- emitProgress('compose', `compose ${i + 1}/${segments.length}`, i + 1, segments.length);
- const segName = await this.composeOneSegment(
- ffmpeg,
- segments[i],
- i,
- shortSide,
- tempFiles,
- useProxy,
- abortController.signal,
- onEncodeProgress,
- );
- segmentNames.push(segName);
- baseProgress += segWeight;
- currentSegProgress = 0;
- }
- if (aborted) throw new Error('aborted');
- emitStage('concat', `concat ${segmentNames.length} segments`);
- emitProgress('concat', `concat ${segmentNames.length} segments`);
- const listText = segmentNames.map((n) => `file '${n}'`).join('\n');
- await ffmpeg.writeFile('list.txt', new TextEncoder().encode(listText));
- tempFiles.add('list.txt');
- const finalName = `final-${Date.now()}.mp4`;
- const concatExit = await ffmpeg.exec([
- '-f', 'concat',
- '-safe', '0',
- '-i', 'list.txt',
- '-c', 'copy',
- '-movflags', '+faststart',
- finalName,
- ]);
- if (concatExit !== 0) throw new Error(`concat failed (exit=${concatExit})`);
- tempFiles.add(finalName);
- baseProgress = 95;
- const data = await ffmpeg.readFile(finalName);
- const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data;
- const copy = new Uint8Array(bytes.byteLength);
- copy.set(bytes);
- const blob = new Blob([copy.buffer], { type: 'video/mp4' });
- const size = blob.size;
- emitStage('cleanup', 'upload final mp4');
- const safeTitle = String(title || 'video')
- .replace(/[^a-zA-Z0-9\u4e00-\u9fff_-]/g, '_')
- .substring(0, 50) || 'video';
- const filename = `${safeTitle}-${Date.now()}.mp4`;
- const url = await new Promise<string>((resolve, reject) => {
- this.qiniu
- .uploadFileWithProgress(blob, filename, 'video/mp4', 'video')
- .subscribe({
- next: (ev) => {
- if (ev.state === 'progress') {
- params.onProgress?.({
- stage: 'cleanup',
- percent: Math.min(100, 95 + Math.round((ev.progress || 0) * 0.05)),
- message: `uploading ${Math.round((ev.progress || 0) * 100)}%`,
- });
- } else if (ev.state === 'done' && ev.url) {
- resolve(ev.url);
- }
- },
- error: (err) => reject(err instanceof Error ? err : new Error(String(err))),
- });
- });
- await Promise.all([...tempFiles].map((n) => this.safeDelete(ffmpeg, n)));
- const done: CompositeDoneEvent = {
- success: true,
- videoUrl: url,
- filename,
- size,
- segments: segments.length,
- };
- params.onProgress?.({ stage: 'cleanup', percent: 100 });
- params.onDone?.(done);
- return done;
- } catch (err: any) {
- await Promise.all([...tempFiles].map((n) => this.safeDelete(ffmpeg, n))).catch(() => {});
- const msg = err?.message || String(err);
- params.onError?.({ error: msg });
- throw err instanceof Error ? err : new Error(msg);
- }
- })();
- return {
- abort: () => {
- aborted = true;
- abortController.abort();
- try { this.ffmpeg?.terminate(); } catch { /* noop */ }
- this.ffmpeg = undefined;
- this.loading = undefined;
- },
- promise,
- };
- }
- private async composeOneSegment(
- ffmpeg: FFmpeg,
- seg: CompositeSegmentInput,
- idx: number,
- shortSide: 720 | 1080,
- tempFiles: Set<string>,
- useProxy: boolean,
- signal: AbortSignal,
- onEncodeProgress?: (ratio: number) => void,
- ): Promise<string> {
- const W = shortSide === 1080 ? 1920 : 1280;
- const H = shortSide;
- const baseVf = `scale=${W}:${H}:force_original_aspect_ratio=decrease,pad=${W}:${H}:(ow-iw)/2:(oh-ih)/2:black`;
- const audioArgs = ['-c:a', 'aac', '-b:a', '128k', '-ar', '44100', '-ac', '2'];
- const videoArgs = ['-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', '-r', '30', '-g', '60'];
- const hasImage = !!seg.imageUrl;
- const hasVideo = !!seg.videoUrl;
- const hasAudio = !!seg.audioUrl;
- if (!hasImage && !hasVideo) {
- throw new Error(`segment ${seg.id ?? idx} missing imageUrl/videoUrl`);
- }
- const segName = `seg-${idx}.mp4`;
- let audioName = '';
- let audioDuration = 0;
- // 仅在「真正编码段视频」的 exec 期间挂载 progress 监听,避免 probeDuration 触发段内进度。
- const runEncode = async (args: string[]): Promise<number> => {
- const handler = (e: { progress: number }) => {
- onEncodeProgress?.(Number(e.progress) || 0);
- };
- ffmpeg.on('progress', handler as any);
- try {
- return await ffmpeg.exec(args);
- } finally {
- try { ffmpeg.off('progress', handler as any); } catch { /* noop */ }
- }
- };
- if (hasAudio) {
- audioName = `audio-${idx}${this.guessExt(seg.audioUrl!, 'm4a')}`;
- await ffmpeg.writeFile(audioName, await this.fetchAsset(seg.audioUrl!, useProxy, signal));
- tempFiles.add(audioName);
- audioDuration = await this.probeDuration(ffmpeg, audioName);
- if (!audioDuration) throw new Error(`segment ${seg.id ?? idx} audio duration probe failed`);
- }
- let exit = 0;
- if (hasVideo) {
- const videoName = `video-${idx}.mp4`;
- await ffmpeg.writeFile(videoName, await this.fetchAsset(seg.videoUrl!, useProxy, signal));
- tempFiles.add(videoName);
- const videoDuration = await this.probeDuration(ffmpeg, videoName).catch(() => 0);
- if (hasAudio) {
- // Case B: video + audio, align to audio duration
- let vfChain = baseVf;
- if (videoDuration > 0 && videoDuration < audioDuration - 0.05) {
- const padSec = (audioDuration - videoDuration).toFixed(3);
- vfChain += `,tpad=stop_mode=clone:stop_duration=${padSec}`;
- }
- exit = await runEncode([
- '-i', videoName,
- '-i', audioName,
- '-map', '0:v:0',
- '-map', '1:a:0',
- ...videoArgs,
- '-vf', vfChain,
- ...audioArgs,
- '-t', String(audioDuration),
- segName,
- ]);
- } else {
- // Case C: video only + silent audio
- exit = await runEncode([
- '-i', videoName,
- '-f', 'lavfi', '-i', 'anullsrc=channel_layout=stereo:sample_rate=44100',
- '-map', '0:v:0',
- '-map', '1:a:0',
- ...videoArgs,
- '-vf', baseVf,
- ...audioArgs,
- '-shortest',
- segName,
- ]);
- }
- } else {
- const imgName = `img-${idx}${this.guessExt(seg.imageUrl!, 'jpg')}`;
- await ffmpeg.writeFile(imgName, await this.fetchAsset(seg.imageUrl!, useProxy, signal));
- tempFiles.add(imgName);
- if (hasAudio) {
- // Case A: image + audio
- exit = await runEncode([
- '-loop', '1', '-i', imgName,
- '-i', audioName,
- ...videoArgs,
- '-vf', baseVf,
- ...audioArgs,
- '-t', String(audioDuration),
- '-shortest',
- segName,
- ]);
- } else {
- // Case D: image only + silent audio (use seg.duration, default 3s)
- const dur = Math.max(0.5, Number(seg.duration) || 3);
- exit = await runEncode([
- '-loop', '1', '-i', imgName,
- '-f', 'lavfi', '-i', 'anullsrc=channel_layout=stereo:sample_rate=44100',
- ...videoArgs,
- '-vf', baseVf,
- ...audioArgs,
- '-t', String(dur),
- '-shortest',
- segName,
- ]);
- }
- }
- if (exit !== 0) throw new Error(`segment ${seg.id ?? idx} encode failed (exit=${exit})`);
- tempFiles.add(segName);
- return segName;
- }
- /** 通过运行 ffmpeg -i <name> -f null - 解析 stderr 拿到时长(秒)。 */
- private async probeDuration(ffmpeg: FFmpeg, name: string): Promise<number> {
- let dur = 0;
- const handler = ({ message }: { message: string }) => {
- const m = message?.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/);
- if (m) {
- dur = (+m[1]) * 3600 + (+m[2]) * 60 + parseFloat(m[3]);
- }
- };
- ffmpeg.on('log', handler as any);
- try {
- await ffmpeg.exec(['-i', name, '-f', 'null', '-']);
- } catch {
- // ffmpeg 用 null muxer 时偶尔非零退出,时长仍能从 stderr 拿到
- } finally {
- try { ffmpeg.off('log', handler as any); } catch { /* noop */ }
- }
- return dur;
- }
- /** 拉取远程资源到 Uint8Array。直连失败(CORS/network)时回退到 /backend/api/video-proxy。 */
- private async fetchAsset(
- url: string,
- useProxy: boolean,
- signal: AbortSignal,
- ): Promise<Uint8Array> {
- if (signal.aborted) throw new Error('aborted');
- try {
- const resp = await fetch(url, { signal });
- if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
- return new Uint8Array(await resp.arrayBuffer());
- } catch (err) {
- if (!useProxy) throw err;
- const proxied = `/backend/api/video-proxy?url=${encodeURIComponent(url)}`;
- const resp = await fetch(proxied, { signal });
- if (!resp.ok) throw new Error(`proxy fetch failed HTTP ${resp.status}`);
- return new Uint8Array(await resp.arrayBuffer());
- }
- }
- private guessExt(url: string, fallback: string): string {
- const m = url.split('?')[0].match(/\.([a-z0-9]{2,5})$/i);
- return `.${(m ? m[1] : fallback).toLowerCase()}`;
- }
- }
|