Browse Source

fix: resolve Discord audio playback issues in exported MP4s

Switch AVC_LEVEL_4_0 from Baseline profile (avc1.420028) to High profile
(avc1.640028) for broader platform compatibility. Feed audio to the muxer
before video frames so packets are properly interleaved — previously all
audio was dumped after video, causing Discord to skip the audio stream.
Probe total video duration upfront to trim audio to match video length.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 5 months ago
parent
commit
76a55ac23b
  1. 2
      src/hooks/__tests__/useApplySpeedCurve.test.ts
  2. 2
      src/hooks/__tests__/useStitchVideos.test.ts
  3. 46
      src/hooks/useStitchVideos.ts
  4. 2
      src/lib/__tests__/video-encoding.test.ts
  5. 3
      src/lib/video-encoding.ts

2
src/hooks/__tests__/useApplySpeedCurve.test.ts

@ -82,7 +82,7 @@ vi.mock("mediabunny", () => {
vi.mock("@/lib/video-encoding", () => ({ vi.mock("@/lib/video-encoding", () => ({
createAvcEncodingConfig: vi.fn(() => ({})), createAvcEncodingConfig: vi.fn(() => ({})),
AVC_LEVEL_4_0: "avc1.420028", AVC_LEVEL_4_0: "avc1.640028",
AVC_LEVEL_5_1: "avc1.640033", AVC_LEVEL_5_1: "avc1.640033",
})); }));

2
src/hooks/__tests__/useStitchVideos.test.ts

@ -114,7 +114,7 @@ vi.mock("mediabunny", () => {
vi.mock("@/lib/video-encoding", () => ({ vi.mock("@/lib/video-encoding", () => ({
createAvcEncodingConfig: vi.fn(() => ({})), createAvcEncodingConfig: vi.fn(() => ({})),
AVC_LEVEL_4_0: "avc1.420028", AVC_LEVEL_4_0: "avc1.640028",
AVC_LEVEL_5_1: "avc1.640033", AVC_LEVEL_5_1: "avc1.640033",
})); }));

46
src/hooks/useStitchVideos.ts

@ -39,7 +39,7 @@ const normalizeRotation = (value: unknown): Rotation => {
const probeVideoMetadata = async ( const probeVideoMetadata = async (
blob: Blob blob: Blob
): Promise<{ width: number; height: number; rotation: Rotation; bitrate: number }> => { ): Promise<{ width: number; height: number; rotation: Rotation; bitrate: number; duration: number }> => {
const source = new BlobSource(blob); const source = new BlobSource(blob);
const input = new Input({ const input = new Input({
source, source,
@ -71,11 +71,23 @@ const probeVideoMetadata = async (
console.warn('Failed to compute packet stats for bitrate', e); console.warn('Failed to compute packet stats for bitrate', e);
} }
// Probe duration
let duration = 0;
try {
const d = await input.computeDuration();
if (Number.isFinite(d) && d > 0) {
duration = d;
}
} catch (e) {
console.warn('Failed to compute duration', e);
}
return { return {
width: ensureEvenDimension(widthCandidate), width: ensureEvenDimension(widthCandidate),
height: ensureEvenDimension(heightCandidate), height: ensureEvenDimension(heightCandidate),
rotation: normalizeRotation(track.rotation), rotation: normalizeRotation(track.rotation),
bitrate, bitrate,
duration,
}; };
} finally { } finally {
input.dispose(); input.dispose();
@ -84,18 +96,20 @@ const probeVideoMetadata = async (
const determineEncodeParameters = async ( const determineEncodeParameters = async (
blobs: Blob[] blobs: Blob[]
): Promise<{ width: number; height: number; rotation: Rotation; maxSourceBitrate: number }> => { ): Promise<{ width: number; height: number; rotation: Rotation; maxSourceBitrate: number; totalDuration: number }> => {
let maxWidth = 0; let maxWidth = 0;
let maxHeight = 0; let maxHeight = 0;
let maxSourceBitrate = 0; let maxSourceBitrate = 0;
let totalDuration = 0;
let rotation: Rotation | null = null; let rotation: Rotation | null = null;
for (let i = 0; i < blobs.length; i++) { for (let i = 0; i < blobs.length; i++) {
try { try {
const { width, height, rotation: trackRotation, bitrate } = await probeVideoMetadata(blobs[i]); const { width, height, rotation: trackRotation, bitrate, duration } = await probeVideoMetadata(blobs[i]);
maxWidth = Math.max(maxWidth, width); maxWidth = Math.max(maxWidth, width);
maxHeight = Math.max(maxHeight, height); maxHeight = Math.max(maxHeight, height);
maxSourceBitrate = Math.max(maxSourceBitrate, bitrate); maxSourceBitrate = Math.max(maxSourceBitrate, bitrate);
totalDuration += duration;
if (rotation === null) { if (rotation === null) {
rotation = trackRotation; rotation = trackRotation;
} else if (trackRotation !== rotation) { } else if (trackRotation !== rotation) {
@ -114,6 +128,7 @@ const determineEncodeParameters = async (
height: FALLBACK_HEIGHT, height: FALLBACK_HEIGHT,
rotation: rotation ?? (0 as Rotation), rotation: rotation ?? (0 as Rotation),
maxSourceBitrate, maxSourceBitrate,
totalDuration,
}; };
} }
@ -122,6 +137,7 @@ const determineEncodeParameters = async (
height: ensureEvenDimension(maxHeight), height: ensureEvenDimension(maxHeight),
rotation: rotation ?? (0 as Rotation), rotation: rotation ?? (0 as Rotation),
maxSourceBitrate, maxSourceBitrate,
totalDuration,
}; };
}; };
@ -225,6 +241,7 @@ export async function stitchVideosAsync(
height: probedHeight, height: probedHeight,
rotation: aggregateRotation, rotation: aggregateRotation,
maxSourceBitrate, maxSourceBitrate,
totalDuration: probedVideoDuration,
} = await determineEncodeParameters(videoBlobs); } = await determineEncodeParameters(videoBlobs);
const safeWidth = probedWidth > 0 ? probedWidth : FALLBACK_WIDTH; const safeWidth = probedWidth > 0 ? probedWidth : FALLBACK_WIDTH;
@ -312,6 +329,18 @@ export async function stitchVideosAsync(
await output.start(); await output.start();
outputStarted = true; outputStarted = true;
// Feed audio early so the muxer can interleave audio packets with video frames.
// Writing audio after all video causes broken interleaving (Discord won't play audio).
if (audioSource && pendingAudioBuffer) {
updateProgress('processing', 'Encoding audio track...', 12);
const trimTarget = probedVideoDuration > 0 ? probedVideoDuration : audioData!.duration;
const trimmedBuffer = trimAudioBuffer(pendingAudioBuffer, trimTarget);
await audioSource.add(trimmedBuffer);
await audioSource.close();
audioSource = null;
pendingAudioBuffer = null;
}
try { try {
// Track the highest timestamp we've written to ensure monotonicity // Track the highest timestamp we've written to ensure monotonicity
// Start at -frameInterval so first frame can be at timestamp 0 // Start at -frameInterval so first frame can be at timestamp 0
@ -414,17 +443,6 @@ export async function stitchVideosAsync(
} }
} }
// Phase 2: Apply audio, trimmed to match the stitched video duration
if (audioSource && pendingAudioBuffer) {
updateProgress('processing', 'Encoding audio track...', 92);
const videoDuration = highestWrittenTimestamp + frameInterval;
const trimmedBuffer = trimAudioBuffer(pendingAudioBuffer, videoDuration);
await audioSource.add(trimmedBuffer);
await audioSource.close();
audioSource = null; // Already closed
}
// Flush encoder before finalizing container // Flush encoder before finalizing container
await videoSource!.close(); await videoSource!.close();
videoSource = null; // Already closed, prevent double-close in finally videoSource = null; // Already closed, prevent double-close in finally

2
src/lib/__tests__/video-encoding.test.ts

@ -8,7 +8,7 @@ import {
describe("video-encoding", () => { describe("video-encoding", () => {
describe("constants", () => { describe("constants", () => {
it("AVC_LEVEL_4_0 is correct codec string", () => { it("AVC_LEVEL_4_0 is correct codec string", () => {
expect(AVC_LEVEL_4_0).toBe("avc1.420028"); expect(AVC_LEVEL_4_0).toBe("avc1.640028");
}); });
it("AVC_LEVEL_5_1 is correct codec string", () => { it("AVC_LEVEL_5_1 is correct codec string", () => {

3
src/lib/video-encoding.ts

@ -4,7 +4,7 @@ import type { VideoEncodingConfig } from 'mediabunny';
const DEFAULT_KEYFRAME_INTERVAL = 1.0; const DEFAULT_KEYFRAME_INTERVAL = 1.0;
const MAX_OUTPUT_FPS = 60; const MAX_OUTPUT_FPS = 60;
export const AVC_LEVEL_4_0 = 'avc1.420028'; export const AVC_LEVEL_4_0 = 'avc1.640028';
export const AVC_LEVEL_5_1 = 'avc1.640033'; export const AVC_LEVEL_5_1 = 'avc1.640033';
export const createAvcEncodingConfig = ( export const createAvcEncodingConfig = (
@ -22,6 +22,7 @@ export const createAvcEncodingConfig = (
latencyMode: 'quality', latencyMode: 'quality',
fullCodecString: codecString, fullCodecString: codecString,
hardwareAcceleration: useHardwareAcceleration ? 'prefer-hardware' : 'prefer-software', hardwareAcceleration: useHardwareAcceleration ? 'prefer-hardware' : 'prefer-software',
sizeChangeBehavior: 'cover',
onEncoderConfig: (config) => { onEncoderConfig: (config) => {
config.avc = { ...(config.avc ?? {}), format: 'avc' }; config.avc = { ...(config.avc ?? {}), format: 'avc' };
if (!config.latencyMode) { if (!config.latencyMode) {

Loading…
Cancel
Save