Spaces:
Running
Running
File size: 6,104 Bytes
0c88bc5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 | /**
* Client-side video concatenation using ffmpeg.wasm.
*
* Why client-side: Humeo's existing /api/public/reviews/submit endpoint accepts
* a single video file. To avoid backend changes for v1, we stitch the 5 clips
* in the browser before upload.
*
* Cost: ~8MB of WASM lazy-loaded after the customer finishes recording.
* Performance: ~50 seconds of total video concatenates in 5-10s on a modern phone.
*
* Future: if cafes complain about phone heat or battery, swap to multi-clip
* upload + ffmpeg-on-server. The Humeo BE worker (processInterview.ts) already
* uses ffmpeg.
*/
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile } from '@ffmpeg/util';
async function createFFmpeg(): Promise<FFmpeg> {
const ffmpeg = new FFmpeg();
// ffmpeg.wasm fetches its core from a CDN by default; for SharedArrayBuffer
// support we need the COOP/COEP headers set in next.config.js.
await ffmpeg.load();
return ffmpeg;
}
export type ConcatInput = {
blob: Blob;
/** Hint for input file extension; .webm fallback otherwise. */
ext?: 'webm' | 'mp4' | 'mov';
};
export type ConcatResult = {
blob: Blob;
durationSeconds: number;
filename: string;
};
/**
* Concatenate an ordered list of recorded video Blobs into a single output blob.
*
* Normalize each clip independently, reset audio/video PTS per clip, then use
* ffmpeg's concat filter. The concat demuxer path can preserve timestamp gaps
* between MediaRecorder clips, which shows up as frozen video at clip joins.
*/
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 {
/* ignore */
}
}
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);
// ffmpeg.readFile returns string | Uint8Array; for a binary file it's Uint8Array.
// Copy into a fresh ArrayBuffer-backed view so TS / Blob APIs are happy
// even if the underlying buffer was a SharedArrayBuffer.
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' });
// Cleanup
for (const name of inputFiles) {
try {
await ffmpeg.deleteFile(name);
} catch {
/* ignore */
}
}
try {
await ffmpeg.deleteFile(outputName);
} catch {
/* ignore */
}
ffmpeg.off('log', logHandler);
ffmpeg.off('progress', progressHandler);
ffmpeg.terminate();
// Probe duration via the same helper used elsewhere.
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;
});
}
|