| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| import { FFmpeg } from '@ffmpeg/ffmpeg'; |
| import { fetchFile } from '@ffmpeg/util'; |
|
|
| async function createFFmpeg(): Promise<FFmpeg> { |
| const ffmpeg = new FFmpeg(); |
| |
| |
| await ffmpeg.load(); |
| return ffmpeg; |
| } |
| export type ConcatInput = { |
| blob: Blob; |
| |
| ext?: 'webm' | 'mp4' | 'mov'; |
| durationSeconds?: number; |
| }; |
|
|
| export type ConcatResult = { |
| blob: Blob; |
| durationSeconds: number; |
| filename: string; |
| }; |
|
|
| |
| |
| |
| |
| |
| |
|
|
| export async function concatClips(
|
| inputs: ConcatInput[],
|
| onProgress?: (progress: number) => void,
|
| ): Promise<ConcatResult> {
|
| if (inputs.length === 0) {
|
| throw new Error('No clips to concatenate');
|
| }
|
|
|
| const ffmpeg = await createFFmpeg(); |
| const logs: string[] = []; |
|
|
| const logHandler = ({ message }: { message: string }) => { |
| const trimmed = message.trim(); |
| if (trimmed) logs.push(trimmed); |
| if (logs.length > 80) logs.splice(0, logs.length - 80); |
| }; |
| const progressHandler = ({ progress }: { progress: number }) => { |
| onProgress?.(Math.max(0, Math.min(1, progress))); |
| }; |
|
|
| ffmpeg.on('log', logHandler); |
| ffmpeg.on('progress', progressHandler); |
|
|
| const runId = Math.random().toString(36).slice(2, 8); |
| const inputFiles: string[] = []; |
| for (let i = 0; i < inputs.length; i++) { |
| const input = inputs[i]!; |
| const ext = input.ext ?? 'webm'; |
| const filename = `input_${runId}_${i}.${ext}`; |
| await ffmpeg.writeFile(filename, await fetchFile(input.blob)); |
| inputFiles.push(filename); |
| } |
|
|
| const outputName = `matcha-moments-${runId}.webm`; |
|
|
| async function runOrThrow(args: string[], label: string) { |
| logs.length = 0; |
| const exitCode = await ffmpeg.exec(args); |
| if (exitCode !== 0) { |
| const detail = logs.slice(-12).join(' | '); |
| throw new Error( |
| detail |
| ? `${label} failed with ffmpeg exit code ${exitCode}: ${detail}` |
| : `${label} failed with ffmpeg exit code ${exitCode}`, |
| ); |
| } |
| } |
|
|
| async function removeOutput() { |
| try { |
| await ffmpeg.deleteFile(outputName); |
| } catch { |
| |
| } |
| } |
|
|
| async function runNormalizedConcat() { |
| const inputArgs = inputFiles.flatMap((name) => ['-i', name]); |
| const normalizedStreams = inputFiles |
| .map((_, i) => { |
| const video = |
| `[${i}:v]scale=720:1280:force_original_aspect_ratio=decrease,` + |
| 'pad=720:1280:(ow-iw)/2:(oh-ih)/2,' + |
| `setsar=1,fps=30,setpts=PTS-STARTPTS[v${i}]`; |
| const audio = |
| `[${i}:a]aresample=async=1:first_pts=0,asetpts=PTS-STARTPTS[a${i}]`; |
| return `${video};${audio}`; |
| }) |
| .join(';'); |
| const concatInputs = inputFiles.map((_, i) => `[v${i}][a${i}]`).join(''); |
| const filterComplex = `${normalizedStreams};${concatInputs}concat=n=${inputFiles.length}:v=1:a=1[v][a]`; |
|
|
| await runOrThrow([ |
| '-y', |
| ...inputArgs, |
| '-filter_complex', |
| filterComplex, |
| '-map', |
| '[v]', |
| '-map', |
| '[a]', |
| '-c:v', |
| 'libvpx', |
| '-b:v', |
| '1.8M', |
| '-deadline', |
| 'realtime', |
| '-cpu-used', |
| '5', |
| '-c:a', |
| 'libopus', |
| '-b:a', |
| '96k', |
| '-shortest', |
| '-avoid_negative_ts', |
| 'make_zero', |
| outputName, |
| ], 'Normalized concat'); |
| } |
|
|
| try { |
| await runNormalizedConcat(); |
| } catch (err) { |
| await removeOutput(); |
| throw err instanceof Error ? err : new Error(String(err)); |
| } |
|
|
| const data = await ffmpeg.readFile(outputName); |
|
|
|
|
|
|
| const bytes =
|
| typeof data === 'string' ? new TextEncoder().encode(data) : new Uint8Array(data);
|
| const buf = new ArrayBuffer(bytes.byteLength);
|
| new Uint8Array(buf).set(bytes);
|
| const blob = new Blob([buf], { type: 'video/webm' });
|
|
|
|
|
| for (const name of inputFiles) {
|
| try {
|
| await ffmpeg.deleteFile(name);
|
| } catch {
|
|
|
| }
|
| } |
| try { |
| await ffmpeg.deleteFile(outputName); |
| } catch { |
| |
| } |
| ffmpeg.off('log', logHandler); |
| ffmpeg.off('progress', progressHandler); |
| ffmpeg.terminate(); |
|
|
| |
| const duration = await probeDuration(blob);
|
|
|
| return {
|
| blob,
|
| durationSeconds: duration,
|
| filename: outputName,
|
| };
|
| }
|
|
|
| async function probeDuration(blob: Blob): Promise<number> {
|
| return await new Promise<number>((resolve) => {
|
| const url = URL.createObjectURL(blob);
|
| const video = document.createElement('video');
|
| video.preload = 'metadata';
|
| video.muted = true;
|
| video.onloadedmetadata = () => {
|
| resolve(Number.isFinite(video.duration) ? Math.max(0, video.duration) : 0);
|
| URL.revokeObjectURL(url);
|
| };
|
| video.onerror = () => {
|
| resolve(0);
|
| URL.revokeObjectURL(url);
|
| };
|
| video.src = url;
|
| });
|
| }
|
|
|