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", () => ({
createAvcEncodingConfig: vi.fn(() => ({})),
AVC_LEVEL_4_0: "avc1.420028",
AVC_LEVEL_4_0: "avc1.640028",
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", () => ({
createAvcEncodingConfig: vi.fn(() => ({})),
AVC_LEVEL_4_0: "avc1.420028",
AVC_LEVEL_4_0: "avc1.640028",
AVC_LEVEL_5_1: "avc1.640033",
}));

46
src/hooks/useStitchVideos.ts

@ -39,7 +39,7 @@ const normalizeRotation = (value: unknown): Rotation => {
const probeVideoMetadata = async (
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 input = new Input({
source,
@ -71,11 +71,23 @@ const probeVideoMetadata = async (
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 {
width: ensureEvenDimension(widthCandidate),
height: ensureEvenDimension(heightCandidate),
rotation: normalizeRotation(track.rotation),
bitrate,
duration,
};
} finally {
input.dispose();
@ -84,18 +96,20 @@ const probeVideoMetadata = async (
const determineEncodeParameters = async (
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 maxHeight = 0;
let maxSourceBitrate = 0;
let totalDuration = 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]);
const { width, height, rotation: trackRotation, bitrate, duration } = await probeVideoMetadata(blobs[i]);
maxWidth = Math.max(maxWidth, width);
maxHeight = Math.max(maxHeight, height);
maxSourceBitrate = Math.max(maxSourceBitrate, bitrate);
totalDuration += duration;
if (rotation === null) {
rotation = trackRotation;
} else if (trackRotation !== rotation) {
@ -114,6 +128,7 @@ const determineEncodeParameters = async (
height: FALLBACK_HEIGHT,
rotation: rotation ?? (0 as Rotation),
maxSourceBitrate,
totalDuration,
};
}
@ -122,6 +137,7 @@ const determineEncodeParameters = async (
height: ensureEvenDimension(maxHeight),
rotation: rotation ?? (0 as Rotation),
maxSourceBitrate,
totalDuration,
};
};
@ -225,6 +241,7 @@ export async function stitchVideosAsync(
height: probedHeight,
rotation: aggregateRotation,
maxSourceBitrate,
totalDuration: probedVideoDuration,
} = await determineEncodeParameters(videoBlobs);
const safeWidth = probedWidth > 0 ? probedWidth : FALLBACK_WIDTH;
@ -312,6 +329,18 @@ export async function stitchVideosAsync(
await output.start();
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 {
// Track the highest timestamp we've written to ensure monotonicity
// 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
await videoSource!.close();
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("constants", () => {
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", () => {

3
src/lib/video-encoding.ts

@ -4,7 +4,7 @@ import type { VideoEncodingConfig } from 'mediabunny';
const DEFAULT_KEYFRAME_INTERVAL = 1.0;
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 createAvcEncodingConfig = (
@ -22,6 +22,7 @@ export const createAvcEncodingConfig = (
latencyMode: 'quality',
fullCodecString: codecString,
hardwareAcceleration: useHardwareAcceleration ? 'prefer-hardware' : 'prefer-software',
sizeChangeBehavior: 'cover',
onEncoderConfig: (config) => {
config.avc = { ...(config.avc ?? {}), format: 'avc' };
if (!config.latencyMode) {

Loading…
Cancel
Save