Browse Source
- Port useAudioVisualization.ts with waveform peak extraction - Port useAudioMixing.ts with prepareAudioAsync standalone function - Port useStitchVideos.ts with stitchVideosAsync and checkEncoderSupport - Export applyFades from useAudioMixing for shared use - Remove preview mode logic from useStitchVideos (always full quality) - Inline constants (DEFAULT_BITRATE, MAX_OUTPUT_FPS) in useStitchVideos - All hooks use MediaBunny for audio/video processing Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>handoff-20260429-1057
3 changed files with 857 additions and 0 deletions
@ -0,0 +1,213 @@ |
|||
'use client'; |
|||
|
|||
import { useCallback } from 'react'; |
|||
import { |
|||
Input, |
|||
AudioBufferSink, |
|||
BlobSource, |
|||
ALL_FORMATS, |
|||
} from 'mediabunny'; |
|||
|
|||
const MINIMUM_AUDIO_DURATION = 0.1; |
|||
|
|||
interface AudioProcessingOptions { |
|||
offset?: number; |
|||
fadeIn?: number; |
|||
fadeOut?: number; |
|||
} |
|||
|
|||
interface AudioMixProgress { |
|||
message: string; |
|||
progress: number; |
|||
} |
|||
|
|||
interface AudioData { |
|||
buffer: AudioBuffer; |
|||
duration: number; |
|||
} |
|||
|
|||
interface UseAudioMixingReturn { |
|||
prepareAudio: ( |
|||
audioBlob: Blob, |
|||
videoDuration: number, |
|||
onProgress?: (progress: AudioMixProgress) => void, |
|||
options?: AudioProcessingOptions |
|||
) => Promise<AudioData | null>; |
|||
} |
|||
|
|||
/** |
|||
* Standalone async function to prepare audio for mixing with video |
|||
*/ |
|||
export async function prepareAudioAsync( |
|||
audioBlob: Blob, |
|||
videoDuration: number, |
|||
onProgress?: (progress: AudioMixProgress) => void, |
|||
options?: AudioProcessingOptions |
|||
): Promise<AudioData | null> { |
|||
try { |
|||
onProgress?.({ message: 'Loading audio file...', progress: 10 }); |
|||
const blobSource = new BlobSource(audioBlob); |
|||
const input = new Input({ source: blobSource, formats: ALL_FORMATS }); |
|||
|
|||
onProgress?.({ message: 'Reading audio tracks...', progress: 20 }); |
|||
const audioTracks = await input.getAudioTracks(); |
|||
if (audioTracks.length === 0) { |
|||
throw new Error('No audio tracks found in file'); |
|||
} |
|||
|
|||
const audioTrack = audioTracks[0]; |
|||
const sink = new AudioBufferSink(audioTrack); |
|||
const audioDuration = await input.computeDuration(); |
|||
|
|||
onProgress?.({ message: 'Decoding audio...', progress: 30 }); |
|||
const decodedBuffers: AudioBuffer[] = []; |
|||
for await (const wrappedBuffer of sink.buffers(0, Math.max(videoDuration, audioDuration))) { |
|||
if (wrappedBuffer?.buffer) { |
|||
decodedBuffers.push(wrappedBuffer.buffer); |
|||
} |
|||
} |
|||
|
|||
if (decodedBuffers.length === 0) { |
|||
throw new Error('Failed to decode audio'); |
|||
} |
|||
|
|||
const sampleRate = decodedBuffers[0].sampleRate; |
|||
const channels = decodedBuffers[0].numberOfChannels; |
|||
const targetDuration = Math.max(MINIMUM_AUDIO_DURATION, videoDuration); |
|||
const totalSamples = Math.max(1, Math.floor(targetDuration * sampleRate)); |
|||
|
|||
const mergedBuffer = new AudioBuffer({ |
|||
length: totalSamples, |
|||
numberOfChannels: channels, |
|||
sampleRate, |
|||
}); |
|||
|
|||
const offsetSeconds = options?.offset ?? 0; |
|||
const offsetSamples = Math.floor(offsetSeconds * sampleRate); |
|||
|
|||
let writeOffset = 0; |
|||
let sourceSkipSamples = 0; |
|||
|
|||
if (offsetSamples > 0) { |
|||
writeOffset = Math.min(offsetSamples, totalSamples); |
|||
} else if (offsetSamples < 0) { |
|||
sourceSkipSamples = Math.abs(offsetSamples); |
|||
} |
|||
|
|||
let samplesToSkip = sourceSkipSamples; |
|||
let bufferIndex = 0; |
|||
let bufferOffset = 0; |
|||
|
|||
while (samplesToSkip > 0 && bufferIndex < decodedBuffers.length) { |
|||
const buffer = decodedBuffers[bufferIndex]; |
|||
const availableInBuffer = buffer.length - bufferOffset; |
|||
if (samplesToSkip >= availableInBuffer) { |
|||
samplesToSkip -= availableInBuffer; |
|||
bufferIndex++; |
|||
bufferOffset = 0; |
|||
} else { |
|||
bufferOffset = samplesToSkip; |
|||
samplesToSkip = 0; |
|||
} |
|||
} |
|||
|
|||
while (writeOffset < totalSamples && bufferIndex < decodedBuffers.length) { |
|||
const buffer = decodedBuffers[bufferIndex]; |
|||
const remainingSamples = totalSamples - writeOffset; |
|||
const availableInBuffer = buffer.length - bufferOffset; |
|||
const writeLength = Math.min(availableInBuffer, remainingSamples); |
|||
|
|||
for (let channel = 0; channel < channels; channel++) { |
|||
const channelData = buffer.getChannelData(channel).subarray(bufferOffset, bufferOffset + writeLength); |
|||
mergedBuffer.getChannelData(channel).set(channelData, writeOffset); |
|||
} |
|||
|
|||
writeOffset += writeLength; |
|||
bufferOffset += writeLength; |
|||
|
|||
if (bufferOffset >= buffer.length) { |
|||
bufferIndex++; |
|||
bufferOffset = 0; |
|||
} |
|||
} |
|||
|
|||
while (writeOffset < totalSamples && decodedBuffers.length > 0) { |
|||
for (const buffer of decodedBuffers) { |
|||
const remainingSamples = totalSamples - writeOffset; |
|||
if (remainingSamples <= 0) break; |
|||
const writeLength = Math.min(buffer.length, remainingSamples); |
|||
for (let channel = 0; channel < channels; channel++) { |
|||
const channelData = buffer.getChannelData(channel).subarray(0, writeLength); |
|||
mergedBuffer.getChannelData(channel).set(channelData, writeOffset); |
|||
} |
|||
writeOffset += writeLength; |
|||
} |
|||
} |
|||
|
|||
applyFades(mergedBuffer, options); |
|||
onProgress?.({ message: 'Audio ready for mixing', progress: 95 }); |
|||
|
|||
return { |
|||
buffer: mergedBuffer, |
|||
duration: totalSamples / sampleRate, |
|||
}; |
|||
} catch (error) { |
|||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'; |
|||
console.error('Audio mixing error:', error); |
|||
throw new Error(`Failed to process audio: ${errorMessage}`); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* React hook wrapper for prepareAudioAsync |
|||
*/ |
|||
export const useAudioMixing = (): UseAudioMixingReturn => { |
|||
const prepareAudio = useCallback( |
|||
async ( |
|||
audioBlob: Blob, |
|||
videoDuration: number, |
|||
onProgress?: (progress: AudioMixProgress) => void, |
|||
options?: AudioProcessingOptions |
|||
): Promise<AudioData | null> => { |
|||
return prepareAudioAsync(audioBlob, videoDuration, onProgress, options); |
|||
}, |
|||
[] |
|||
); |
|||
|
|||
return { prepareAudio }; |
|||
}; |
|||
|
|||
export function applyFades(buffer: AudioBuffer, options?: { fadeIn?: number; fadeOut?: number }) { |
|||
if (!options) return; |
|||
const fadeInSeconds = Math.max(0, options.fadeIn ?? 0); |
|||
const fadeOutSeconds = Math.max(0, options.fadeOut ?? 0); |
|||
if (fadeInSeconds === 0 && fadeOutSeconds === 0) return; |
|||
|
|||
const totalSamples = buffer.length; |
|||
if (totalSamples === 0) return; |
|||
|
|||
let fadeInSamples = Math.min(totalSamples, Math.floor(fadeInSeconds * buffer.sampleRate)); |
|||
let fadeOutSamples = Math.min(totalSamples, Math.floor(fadeOutSeconds * buffer.sampleRate)); |
|||
|
|||
if (fadeInSamples + fadeOutSamples > totalSamples) { |
|||
const scale = totalSamples / Math.max(1, fadeInSamples + fadeOutSamples); |
|||
fadeInSamples = Math.floor(fadeInSamples * scale); |
|||
fadeOutSamples = Math.floor(fadeOutSamples * scale); |
|||
} |
|||
|
|||
for (let channel = 0; channel < buffer.numberOfChannels; channel++) { |
|||
const channelData = buffer.getChannelData(channel); |
|||
if (fadeInSamples > 0) { |
|||
for (let i = 0; i < fadeInSamples; i++) { |
|||
channelData[i] *= i / fadeInSamples; |
|||
} |
|||
} |
|||
if (fadeOutSamples > 0) { |
|||
for (let i = 0; i < fadeOutSamples; i++) { |
|||
const sampleIndex = totalSamples - fadeOutSamples + i; |
|||
if (sampleIndex < 0 || sampleIndex >= totalSamples) continue; |
|||
channelData[sampleIndex] *= (fadeOutSamples - i) / fadeOutSamples; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,144 @@ |
|||
import { useState, useEffect } from 'react'; |
|||
|
|||
export interface WaveformData { |
|||
channelData: Float32Array[]; |
|||
sampleRate: number; |
|||
duration: number; |
|||
peaks: number[]; |
|||
} |
|||
|
|||
const waveformCache = new Map<string, WaveformData>(); |
|||
const blobKeyStore = new WeakMap<Blob, string>(); |
|||
let blobKeyCounter = 0; |
|||
let sharedAudioContext: AudioContext | null = null; |
|||
|
|||
const getBlobCacheKey = (audioFile: File | Blob | null) => { |
|||
if (!audioFile) return null; |
|||
if (audioFile instanceof File) { |
|||
return `${audioFile.name}-${audioFile.size}-${audioFile.lastModified}`; |
|||
} |
|||
const existing = blobKeyStore.get(audioFile); |
|||
if (existing) return existing; |
|||
const generated = `blob-${blobKeyCounter++}-${audioFile.size}-${audioFile.type ?? 'unknown'}`; |
|||
blobKeyStore.set(audioFile, generated); |
|||
return generated; |
|||
}; |
|||
|
|||
const getSharedAudioContext = () => { |
|||
if (sharedAudioContext) { |
|||
return sharedAudioContext; |
|||
} |
|||
const AudioCtor = |
|||
typeof window !== 'undefined' |
|||
? window.AudioContext || (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext |
|||
: null; |
|||
if (!AudioCtor) { |
|||
throw new Error('AudioContext is not supported in this environment'); |
|||
} |
|||
sharedAudioContext = new AudioCtor(); |
|||
return sharedAudioContext; |
|||
}; |
|||
|
|||
const decodeAudioBuffer = async (context: AudioContext, arrayBuffer: ArrayBuffer) => { |
|||
if ('decodeAudioData' in context && context.decodeAudioData.length === 1) { |
|||
return context.decodeAudioData(arrayBuffer); |
|||
} |
|||
return new Promise<AudioBuffer>((resolve, reject) => { |
|||
context.decodeAudioData(arrayBuffer, resolve, reject); |
|||
}); |
|||
}; |
|||
|
|||
export function useAudioVisualization(audioFile: File | Blob | null) { |
|||
const [waveformData, setWaveformData] = useState<WaveformData | null>(null); |
|||
const [isLoading, setIsLoading] = useState(false); |
|||
const [error, setError] = useState<string | null>(null); |
|||
|
|||
useEffect(() => { |
|||
if (!audioFile) { |
|||
setWaveformData(null); |
|||
setError(null); |
|||
setIsLoading(false); |
|||
return; |
|||
} |
|||
|
|||
const cacheKey = getBlobCacheKey(audioFile); |
|||
if (cacheKey && waveformCache.has(cacheKey)) { |
|||
setWaveformData(waveformCache.get(cacheKey)!); |
|||
setIsLoading(false); |
|||
setError(null); |
|||
return; |
|||
} |
|||
|
|||
let isCancelled = false; |
|||
|
|||
const processAudio = async () => { |
|||
try { |
|||
setIsLoading(true); |
|||
setError(null); |
|||
|
|||
const arrayBuffer = await audioFile.arrayBuffer(); |
|||
const audioContext = getSharedAudioContext(); |
|||
const audioBuffer = await decodeAudioBuffer(audioContext, arrayBuffer); |
|||
|
|||
const channelData = Array.from({ length: audioBuffer.numberOfChannels }, (_, i) => |
|||
audioBuffer.getChannelData(i) |
|||
); |
|||
|
|||
const peaks = calculatePeaks(channelData, 256); |
|||
|
|||
const nextWaveform: WaveformData = { |
|||
channelData, |
|||
sampleRate: audioBuffer.sampleRate, |
|||
duration: audioBuffer.duration, |
|||
peaks, |
|||
}; |
|||
|
|||
if (cacheKey) { |
|||
waveformCache.set(cacheKey, nextWaveform); |
|||
} |
|||
|
|||
if (!isCancelled) { |
|||
setWaveformData(nextWaveform); |
|||
} |
|||
} catch (err) { |
|||
const message = err instanceof Error ? err.message : 'Failed to process audio'; |
|||
setError(message); |
|||
console.error('Audio processing error:', err); |
|||
} finally { |
|||
if (!isCancelled) { |
|||
setIsLoading(false); |
|||
} |
|||
} |
|||
}; |
|||
|
|||
processAudio(); |
|||
|
|||
return () => { |
|||
isCancelled = true; |
|||
}; |
|||
}, [audioFile]); |
|||
|
|||
return { waveformData, isLoading, error }; |
|||
} |
|||
|
|||
function calculatePeaks(channelData: Float32Array[], resolution: number): number[] { |
|||
const peaks: number[] = []; |
|||
const totalSamples = channelData[0]?.length ?? 0; |
|||
const samplesPerPeak = Math.max(1, Math.floor(totalSamples / resolution)); |
|||
|
|||
for (let i = 0; i < resolution; i++) { |
|||
let max = 0; |
|||
const start = i * samplesPerPeak; |
|||
const end = Math.min(start + samplesPerPeak, totalSamples); |
|||
|
|||
for (const channel of channelData) { |
|||
for (let j = start; j < end; j++) { |
|||
max = Math.max(max, Math.abs(channel[j] ?? 0)); |
|||
} |
|||
} |
|||
|
|||
peaks.push(max); |
|||
} |
|||
|
|||
return peaks; |
|||
} |
|||
@ -0,0 +1,500 @@ |
|||
'use client'; |
|||
|
|||
import { useState, useCallback } from 'react'; |
|||
import { |
|||
Input, |
|||
Output, |
|||
VideoSampleSink, |
|||
VideoSampleSource, |
|||
AudioBufferSource, |
|||
BlobSource, |
|||
ALL_FORMATS, |
|||
BufferTarget, |
|||
Mp4OutputFormat, |
|||
getFirstEncodableAudioCodec, |
|||
canEncodeVideo, |
|||
} from 'mediabunny'; |
|||
import type { Rotation } from 'mediabunny'; |
|||
import { createAvcEncodingConfig, AVC_LEVEL_4_0, AVC_LEVEL_5_1 } from '@/lib/video-encoding'; |
|||
|
|||
// Inlined constants from easy-peasy-ease
|
|||
const DEFAULT_BITRATE = 8_000_000; // 8 Mbps
|
|||
const MAX_OUTPUT_FPS = 60; |
|||
|
|||
const FALLBACK_WIDTH = 1920; |
|||
const FALLBACK_HEIGHT = 1080; |
|||
const BASELINE_PIXEL_LIMIT = 1920 * 1080; |
|||
|
|||
const ensureEvenDimension = (value: number): number => { |
|||
if (!Number.isFinite(value) || value <= 0) { |
|||
return 0; |
|||
} |
|||
const even = value % 2 === 0 ? value : value - 1; |
|||
return even > 0 ? even : 2; |
|||
}; |
|||
|
|||
const normalizeRotation = (value: unknown): Rotation => { |
|||
return value === 0 || value === 90 || value === 180 || value === 270 ? value : 0; |
|||
}; |
|||
|
|||
const probeVideoMetadata = async ( |
|||
blob: Blob |
|||
): Promise<{ width: number; height: number; rotation: Rotation; bitrate: number }> => { |
|||
const source = new BlobSource(blob); |
|||
const input = new Input({ |
|||
source, |
|||
formats: ALL_FORMATS, |
|||
}); |
|||
try { |
|||
const videoTracks = await input.getVideoTracks(); |
|||
if (videoTracks.length === 0) { |
|||
throw new Error('No video tracks found while probing dimensions.'); |
|||
} |
|||
const track = videoTracks[0]; |
|||
const widthCandidate = |
|||
(typeof track.displayWidth === 'number' && track.displayWidth > 0 |
|||
? track.displayWidth |
|||
: track.codedWidth) ?? FALLBACK_WIDTH; |
|||
const heightCandidate = |
|||
(typeof track.displayHeight === 'number' && track.displayHeight > 0 |
|||
? track.displayHeight |
|||
: track.codedHeight) ?? FALLBACK_HEIGHT; |
|||
|
|||
// Probe bitrate from packet stats
|
|||
let bitrate = 0; |
|||
try { |
|||
const packetStats = await track.computePacketStats(); |
|||
if (packetStats?.averageBitrate && Number.isFinite(packetStats.averageBitrate)) { |
|||
bitrate = packetStats.averageBitrate; |
|||
} |
|||
} catch (e) { |
|||
console.warn('Failed to compute packet stats for bitrate', e); |
|||
} |
|||
|
|||
return { |
|||
width: ensureEvenDimension(widthCandidate), |
|||
height: ensureEvenDimension(heightCandidate), |
|||
rotation: normalizeRotation(track.rotation), |
|||
bitrate, |
|||
}; |
|||
} finally { |
|||
input.dispose(); |
|||
} |
|||
}; |
|||
|
|||
const determineEncodeParameters = async ( |
|||
blobs: Blob[] |
|||
): Promise<{ width: number; height: number; rotation: Rotation; maxSourceBitrate: number }> => { |
|||
let maxWidth = 0; |
|||
let maxHeight = 0; |
|||
let maxSourceBitrate = 0; |
|||
let rotation: Rotation | null = null; |
|||
|
|||
for (let i = 0; i < blobs.length; i++) { |
|||
try { |
|||
const { width, height, rotation: trackRotation, bitrate } = await probeVideoMetadata(blobs[i]); |
|||
maxWidth = Math.max(maxWidth, width); |
|||
maxHeight = Math.max(maxHeight, height); |
|||
maxSourceBitrate = Math.max(maxSourceBitrate, bitrate); |
|||
if (rotation === null) { |
|||
rotation = trackRotation; |
|||
} else if (trackRotation !== rotation) { |
|||
console.warn( |
|||
`Rotation mismatch detected for video ${i + 1} (got ${trackRotation}, expected ${rotation}). Using the first rotation value.` |
|||
); |
|||
} |
|||
} catch (error) { |
|||
console.warn(`Failed to probe metadata for video ${i + 1}`, error); |
|||
} |
|||
} |
|||
|
|||
if (maxWidth <= 0 || maxHeight <= 0) { |
|||
return { |
|||
width: FALLBACK_WIDTH, |
|||
height: FALLBACK_HEIGHT, |
|||
rotation: rotation ?? (0 as Rotation), |
|||
maxSourceBitrate, |
|||
}; |
|||
} |
|||
|
|||
return { |
|||
width: ensureEvenDimension(maxWidth), |
|||
height: ensureEvenDimension(maxHeight), |
|||
rotation: rotation ?? (0 as Rotation), |
|||
maxSourceBitrate, |
|||
}; |
|||
}; |
|||
|
|||
export interface StitchProgress { |
|||
status: 'idle' | 'processing' | 'complete' | 'error'; |
|||
message: string; |
|||
progress: number; // 0-100
|
|||
currentVideo?: number; // Which video is being processed (1-indexed)
|
|||
totalVideos?: number; |
|||
error?: string; |
|||
} |
|||
|
|||
export interface AudioData { |
|||
buffer: AudioBuffer; |
|||
duration: number; |
|||
} |
|||
|
|||
/** |
|||
* Check if the device encoder supports AVC encoding |
|||
*/ |
|||
export async function checkEncoderSupport(): Promise<boolean> { |
|||
try { |
|||
return await canEncodeVideo('avc', { |
|||
width: 1920, |
|||
height: 1080, |
|||
bitrate: DEFAULT_BITRATE, |
|||
}); |
|||
} catch { |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Standalone async function to stitch multiple video blobs together sequentially |
|||
*/ |
|||
export async function stitchVideosAsync( |
|||
videoBlobs: Blob[], |
|||
audioData?: AudioData | null, |
|||
onProgress?: (progress: StitchProgress) => void |
|||
): Promise<Blob> { |
|||
try { |
|||
// Initialize progress
|
|||
const initialProgress: StitchProgress = { |
|||
status: 'processing', |
|||
message: 'Initializing stitching...', |
|||
progress: 0, |
|||
totalVideos: videoBlobs.length, |
|||
}; |
|||
onProgress?.(initialProgress); |
|||
|
|||
if (videoBlobs.length === 0) { |
|||
throw new Error('No videos to stitch'); |
|||
} |
|||
|
|||
// Helper to update progress
|
|||
const updateProgress = ( |
|||
status: StitchProgress['status'], |
|||
message: string, |
|||
progressValue: number, |
|||
currentVideo?: number |
|||
) => { |
|||
const p: StitchProgress = { |
|||
status, |
|||
message, |
|||
progress: progressValue, |
|||
currentVideo, |
|||
totalVideos: videoBlobs.length, |
|||
}; |
|||
onProgress?.(p); |
|||
}; |
|||
|
|||
updateProgress('processing', 'Analyzing video metadata...', 5); |
|||
const { |
|||
width: probedWidth, |
|||
height: probedHeight, |
|||
rotation: aggregateRotation, |
|||
maxSourceBitrate, |
|||
} = await determineEncodeParameters(videoBlobs); |
|||
|
|||
const safeWidth = probedWidth > 0 ? probedWidth : FALLBACK_WIDTH; |
|||
const safeHeight = probedHeight > 0 ? probedHeight : FALLBACK_HEIGHT; |
|||
|
|||
const codecProfile = |
|||
safeWidth * safeHeight > BASELINE_PIXEL_LIMIT ? AVC_LEVEL_5_1 : AVC_LEVEL_4_0; |
|||
|
|||
// Use highest of default/source bitrate
|
|||
const candidateBitrate = Math.max( |
|||
DEFAULT_BITRATE, |
|||
Number.isFinite(maxSourceBitrate) && maxSourceBitrate > 0 ? maxSourceBitrate : 0 |
|||
); |
|||
const resolvedBitrate = Math.max(1, Math.floor(candidateBitrate)); |
|||
|
|||
const supportsConfig = await canEncodeVideo('avc', { |
|||
width: safeWidth, |
|||
height: safeHeight, |
|||
bitrate: resolvedBitrate, |
|||
fullCodecString: codecProfile, |
|||
}); |
|||
|
|||
if (!supportsConfig) { |
|||
throw new Error( |
|||
`Device encoder cannot output ${safeWidth}x${safeHeight} using profile ${codecProfile}. Reduce the resolution or bitrate and try again.` |
|||
); |
|||
} |
|||
|
|||
console.log('Stitch encoder configuration', { |
|||
width: safeWidth, |
|||
height: safeHeight, |
|||
codecProfile, |
|||
bitrate: resolvedBitrate, |
|||
maxSourceBitrate, |
|||
rotation: aggregateRotation, |
|||
}); |
|||
|
|||
// Create output
|
|||
updateProgress('processing', 'Creating output container...', 10); |
|||
|
|||
const videoSource = new VideoSampleSource( |
|||
createAvcEncodingConfig(resolvedBitrate, safeWidth, safeHeight, codecProfile) |
|||
); |
|||
|
|||
const bufferTarget = new BufferTarget(); |
|||
const output = new Output({ |
|||
format: new Mp4OutputFormat({ fastStart: 'in-memory' }), |
|||
target: bufferTarget, |
|||
}); |
|||
|
|||
output.addVideoTrack(videoSource, { rotation: aggregateRotation, frameRate: MAX_OUTPUT_FPS }); |
|||
|
|||
// Add audio track if provided
|
|||
let audioSource: AudioBufferSource | undefined; |
|||
let pendingAudioBuffer: AudioBuffer | null = null; |
|||
if (audioData) { |
|||
updateProgress('processing', 'Detecting supported audio codec...', 8); |
|||
|
|||
// Detect the best supported audio codec for MP4
|
|||
// Try common codecs in order of preference: aac, mp3 (no opus - Twitter doesn't support it)
|
|||
const audioCodec = await getFirstEncodableAudioCodec(['aac', 'mp3'], { |
|||
numberOfChannels: audioData.buffer.numberOfChannels, |
|||
sampleRate: audioData.buffer.sampleRate, |
|||
bitrate: 128000, |
|||
}); |
|||
|
|||
if (!audioCodec) { |
|||
console.warn('No supported audio codec found, continuing without audio'); |
|||
} else { |
|||
updateProgress('processing', `Adding audio track (${audioCodec})...`, 10); |
|||
|
|||
// Create audio source from the decoded audio buffer
|
|||
audioSource = new AudioBufferSource({ |
|||
codec: audioCodec, |
|||
bitrate: 128000, |
|||
}); |
|||
|
|||
output.addAudioTrack(audioSource); |
|||
pendingAudioBuffer = audioData.buffer; |
|||
} |
|||
} |
|||
|
|||
await output.start(); |
|||
|
|||
// Track the highest timestamp we've written to ensure monotonicity
|
|||
// Start at -frameInterval so first frame can be at timestamp 0
|
|||
const frameInterval = 1 / MAX_OUTPUT_FPS; |
|||
let highestWrittenTimestamp = -frameInterval; |
|||
|
|||
// Process each video blob
|
|||
for (let videoIndex = 0; videoIndex < videoBlobs.length; videoIndex++) { |
|||
const videoBlob = videoBlobs[videoIndex]; |
|||
const videoNumber = videoIndex + 1; |
|||
|
|||
updateProgress( |
|||
'processing', |
|||
`Processing video ${videoNumber}/${videoBlobs.length}...`, |
|||
5 + (videoIndex / videoBlobs.length) * 90, |
|||
videoNumber |
|||
); |
|||
|
|||
// Create input for this video
|
|||
const blobSource = new BlobSource(videoBlob); |
|||
const input = new Input({ |
|||
source: blobSource, |
|||
formats: ALL_FORMATS, |
|||
}); |
|||
|
|||
try { |
|||
const videoTracks = await input.getVideoTracks(); |
|||
if (videoTracks.length === 0) { |
|||
console.warn(`No video tracks in video ${videoNumber}`); |
|||
continue; |
|||
} |
|||
|
|||
const videoTrack = videoTracks[0]; |
|||
const sink = new VideoSampleSink(videoTrack); |
|||
|
|||
// Get duration of this video
|
|||
const videoDuration = await input.computeDuration(); |
|||
|
|||
// Track the base offset for this video segment
|
|||
const segmentBaseTime = highestWrittenTimestamp; |
|||
// Track minimum timestamp in this segment to normalize
|
|||
let segmentMinTimestamp: number | null = null; |
|||
|
|||
// Read and write samples from this video
|
|||
let samplesFromThisVideo = 0; |
|||
for await (const sample of sink.samples(0, videoDuration)) { |
|||
const originalTimestamp = sample.timestamp ?? 0; |
|||
|
|||
// On first sample, record the minimum timestamp to normalize from
|
|||
if (segmentMinTimestamp === null) { |
|||
segmentMinTimestamp = originalTimestamp; |
|||
} |
|||
|
|||
// Normalize timestamp relative to segment start, then offset by base time
|
|||
const normalizedTimestamp = originalTimestamp - segmentMinTimestamp; |
|||
const adjustedTimestamp = segmentBaseTime + normalizedTimestamp; |
|||
|
|||
// Snap to 60fps grid for consistent framerate
|
|||
const snappedTimestamp = Math.round(adjustedTimestamp / frameInterval) * frameInterval; |
|||
|
|||
// Skip duplicate frames that land on the same timestamp slot
|
|||
// This ensures strict 60fps without exceeding the target rate
|
|||
if (snappedTimestamp <= highestWrittenTimestamp) { |
|||
sample.close(); |
|||
continue; |
|||
} |
|||
|
|||
sample.setTimestamp(snappedTimestamp); |
|||
sample.setDuration(frameInterval); |
|||
await videoSource.add(sample); |
|||
|
|||
// Update highest written timestamp
|
|||
highestWrittenTimestamp = snappedTimestamp; |
|||
|
|||
sample.close(); |
|||
samplesFromThisVideo++; |
|||
|
|||
// Update progress
|
|||
if (samplesFromThisVideo % 10 === 0) { |
|||
const videoProgress = samplesFromThisVideo / 300; // Rough estimate
|
|||
const overallProgress = |
|||
5 + ((videoIndex + videoProgress) / videoBlobs.length) * 90; |
|||
updateProgress( |
|||
'processing', |
|||
`Processing video ${videoNumber}/${videoBlobs.length}: ${samplesFromThisVideo} frames...`, |
|||
overallProgress, |
|||
videoNumber |
|||
); |
|||
} |
|||
} |
|||
} catch (videoError) { |
|||
const errorMsg = |
|||
videoError instanceof Error |
|||
? videoError.message |
|||
: `Failed to process video ${videoNumber}`; |
|||
console.error(`Error processing video ${videoNumber}:`, videoError); |
|||
throw new Error(errorMsg); |
|||
} finally { |
|||
input.dispose(); |
|||
} |
|||
} |
|||
|
|||
// Encode audio after all video frames have been queued so container metadata stays accurate
|
|||
if (audioSource && pendingAudioBuffer) { |
|||
updateProgress('processing', 'Encoding audio track...', 92); |
|||
await audioSource.add(pendingAudioBuffer); |
|||
await audioSource.close(); |
|||
} |
|||
|
|||
// Flush encoder before finalizing container
|
|||
await videoSource.close(); |
|||
updateProgress('processing', 'Finalizing stitched video...', 97); |
|||
|
|||
// Finalize output
|
|||
await output.finalize(); |
|||
const buffer = bufferTarget.buffer; |
|||
|
|||
if (!buffer) { |
|||
throw new Error('Failed to generate output buffer'); |
|||
} |
|||
|
|||
const outputBlob = new Blob([buffer], { type: 'video/mp4' }); |
|||
|
|||
updateProgress( |
|||
'complete', |
|||
`Successfully stitched ${videoBlobs.length} videos into ${(outputBlob.size / 1024 / 1024).toFixed(2)}MB file`, |
|||
100 |
|||
); |
|||
|
|||
return outputBlob; |
|||
} catch (error) { |
|||
const normalizedError = |
|||
error instanceof Error ? error : new Error(String(error)); |
|||
console.error('Video stitching error:', normalizedError); |
|||
|
|||
const errorProgress: StitchProgress = { |
|||
status: 'error', |
|||
message: `Error: ${normalizedError.message}`, |
|||
progress: 0, |
|||
error: normalizedError.message, |
|||
}; |
|||
|
|||
onProgress?.(errorProgress); |
|||
|
|||
throw normalizedError; |
|||
} |
|||
} |
|||
|
|||
interface UseStitchVideosReturn { |
|||
stitchVideos: ( |
|||
videoBlobs: Blob[], |
|||
audioData?: AudioData | null, |
|||
onProgress?: (progress: StitchProgress) => void |
|||
) => Promise<Blob | null>; |
|||
progress: StitchProgress; |
|||
reset: () => void; |
|||
} |
|||
|
|||
/** |
|||
* Hook for stitching multiple video blobs together sequentially |
|||
* Reads frames from each video and writes them to output in order |
|||
*/ |
|||
export const useStitchVideos = (): UseStitchVideosReturn => { |
|||
const [progress, setProgress] = useState<StitchProgress>({ |
|||
status: 'idle', |
|||
message: 'Ready', |
|||
progress: 0, |
|||
}); |
|||
|
|||
const stitchVideos = useCallback( |
|||
async ( |
|||
videoBlobs: Blob[], |
|||
audioData?: AudioData | null, |
|||
onProgress?: (progress: StitchProgress) => void |
|||
): Promise<Blob | null> => { |
|||
try { |
|||
setProgress({ |
|||
status: 'processing', |
|||
message: 'Initializing...', |
|||
progress: 0, |
|||
}); |
|||
|
|||
const result = await stitchVideosAsync(videoBlobs, audioData, (p) => { |
|||
setProgress(p); |
|||
onProgress?.(p); |
|||
}); |
|||
|
|||
return result; |
|||
} catch (error) { |
|||
const errorMessage = error instanceof Error ? error.message : 'Video stitching failed'; |
|||
setProgress({ |
|||
status: 'error', |
|||
message: errorMessage, |
|||
progress: 0, |
|||
error: errorMessage, |
|||
}); |
|||
return null; |
|||
} |
|||
}, |
|||
[] |
|||
); |
|||
|
|||
const reset = useCallback(() => { |
|||
setProgress({ |
|||
status: 'idle', |
|||
message: 'Ready', |
|||
progress: 0, |
|||
}); |
|||
}, []); |
|||
|
|||
return { |
|||
stitchVideos, |
|||
progress, |
|||
reset, |
|||
}; |
|||
}; |
|||
Loading…
Reference in new issue