fix: surface unsupported video codec on import, fix Input leaks

This commit is contained in:
Maze Winther 2026-04-25 02:46:31 +02:00
parent cceb835a84
commit 0dffefdd59
4 changed files with 166 additions and 145 deletions

View File

@ -1,43 +1,81 @@
import { Input, ALL_FORMATS, BlobSource } from "mediabunny";
import {
Input,
ALL_FORMATS,
BlobSource,
VideoSampleSink,
type VideoCodec,
} from "mediabunny";
import { createTimelineAudioBuffer } from "@/media/audio";
import type { SceneTracks } from "@/timeline";
import type { MediaAsset } from "@/media/types";
import { TICKS_PER_SECOND } from "@/wasm";
import { renderThumbnailDataUrl } from "./thumbnail";
export async function getVideoInfo({
videoFile,
}: {
videoFile: File;
}): Promise<{
export type VideoFileData = {
duration: number;
width: number;
height: number;
fps: number;
hasAudio: boolean;
}> {
codec: VideoCodec | null;
canDecode: boolean;
thumbnailUrl: string | null;
};
export async function readVideoFile({
file,
}: {
file: File;
}): Promise<VideoFileData> {
const input = new Input({
source: new BlobSource(videoFile),
source: new BlobSource(file),
formats: ALL_FORMATS,
});
const duration = await input.computeDuration();
const videoTrack = await input.getPrimaryVideoTrack();
try {
const duration = await input.computeDuration();
const videoTrack = await input.getPrimaryVideoTrack();
if (!videoTrack) {
throw new Error("No video track found in the file");
if (!videoTrack) {
throw new Error("No video track found in the file");
}
const canDecode = await videoTrack.canDecode();
const packetStats = await videoTrack.computePacketStats(100);
const audioTrack = await input.getPrimaryAudioTrack();
let thumbnailUrl: string | null = null;
if (canDecode) {
const sink = new VideoSampleSink(videoTrack);
const frame = await sink.getSample(1);
if (frame) {
try {
thumbnailUrl = renderThumbnailDataUrl({
width: videoTrack.displayWidth,
height: videoTrack.displayHeight,
draw: ({ context, width, height }) => {
frame.draw(context, 0, 0, width, height);
},
});
} finally {
frame.close();
}
}
}
return {
duration,
width: videoTrack.displayWidth,
height: videoTrack.displayHeight,
fps: packetStats.averagePacketRate,
hasAudio: audioTrack !== null,
codec: videoTrack.codec,
canDecode,
thumbnailUrl,
};
} finally {
input.dispose();
}
const packetStats = await videoTrack.computePacketStats(100);
const fps = packetStats.averagePacketRate;
const audioTrack = await input.getPrimaryAudioTrack();
return {
duration,
width: videoTrack.displayWidth,
height: videoTrack.displayHeight,
fps,
hasAudio: audioTrack !== null,
};
}
const SAMPLE_RATE = 44100;

View File

@ -1,15 +1,25 @@
import { Input, ALL_FORMATS, BlobSource, VideoSampleSink } from "mediabunny";
import { toast } from "sonner";
import { getMediaTypeFromFile } from "@/media/media-utils";
import { formatStorageBytes } from "@/services/storage/quota";
import { storageService } from "@/services/storage/service";
import type { MediaAsset } from "@/media/types";
import { getVideoInfo } from "./mediabunny";
import { readVideoFile } from "./mediabunny";
import type { VideoFileData } from "./mediabunny";
import { renderThumbnailDataUrl } from "./thumbnail";
export interface ProcessedMediaAsset extends Omit<MediaAsset, "id"> {}
const THUMBNAIL_MAX_WIDTH = 1280;
const THUMBNAIL_MAX_HEIGHT = 720;
const getUnsupportedVideoDescription = ({
codec,
}: {
codec: VideoFileData["codec"];
}): string => {
const codecLabel = codec ? codec.toUpperCase() : "this video codec";
return codec === "hevc"
? `${codecLabel} cannot be decoded in this browser, so this clip may not preview correctly. Convert it to H.264 MP4 or try importing it in Safari.`
: `${codecLabel} cannot be decoded in this browser, so this clip may not preview correctly. Convert it to H.264 MP4 and reimport it.`;
};
const getStorageLimitDescription = ({
fileSize,
@ -29,103 +39,6 @@ const getStorageLimitDescription = ({
})} is safely available in browser storage.`;
};
const getThumbnailSize = ({
width,
height,
}: {
width: number;
height: number;
}): { width: number; height: number } => {
const aspectRatio = width / height;
let targetWidth = width;
let targetHeight = height;
if (targetWidth > THUMBNAIL_MAX_WIDTH) {
targetWidth = THUMBNAIL_MAX_WIDTH;
targetHeight = Math.round(targetWidth / aspectRatio);
}
if (targetHeight > THUMBNAIL_MAX_HEIGHT) {
targetHeight = THUMBNAIL_MAX_HEIGHT;
targetWidth = Math.round(targetHeight * aspectRatio);
}
return { width: targetWidth, height: targetHeight };
};
const renderToThumbnailDataUrl = ({
width,
height,
draw,
}: {
width: number;
height: number;
draw: ({
context,
width,
height,
}: {
context: CanvasRenderingContext2D;
width: number;
height: number;
}) => void;
}): string => {
const size = getThumbnailSize({ width, height });
const canvas = document.createElement("canvas");
canvas.width = size.width;
canvas.height = size.height;
const context = canvas.getContext("2d");
if (!context) {
throw new Error("Could not get canvas context");
}
draw({ context, width: size.width, height: size.height });
return canvas.toDataURL("image/jpeg", 0.8);
};
async function generateThumbnail({
videoFile,
timeInSeconds,
}: {
videoFile: File;
timeInSeconds: number;
}): Promise<string> {
const input = new Input({
source: new BlobSource(videoFile),
formats: ALL_FORMATS,
});
const videoTrack = await input.getPrimaryVideoTrack();
if (!videoTrack) {
throw new Error("No video track found in the file");
}
const canDecode = await videoTrack.canDecode();
if (!canDecode) {
throw new Error("Video codec not supported for decoding");
}
const sink = new VideoSampleSink(videoTrack);
const frame = await sink.getSample(timeInSeconds);
if (!frame) {
throw new Error("Could not get frame at specified time");
}
try {
return renderToThumbnailDataUrl({
width: videoTrack.displayWidth,
height: videoTrack.displayHeight,
draw: ({ context, width, height }) => {
frame.draw(context, 0, 0, width, height);
},
});
} finally {
frame.close();
}
}
async function generateImageThumbnail({
imageFile,
}: {
@ -137,7 +50,7 @@ async function generateImageThumbnail({
image.addEventListener("load", () => {
try {
const thumbnailUrl = renderToThumbnailDataUrl({
const thumbnailUrl = renderThumbnailDataUrl({
width: image.naturalWidth,
height: image.naturalHeight,
draw: ({ context, width, height }) => {
@ -220,24 +133,34 @@ export async function processMediaAssets({
height = result.height;
} else if (fileType === "video") {
try {
const videoInfo = await getVideoInfo({ videoFile: file });
duration = videoInfo.duration;
width = videoInfo.width;
height = videoInfo.height;
fps = Number.isFinite(videoInfo.fps)
? Math.round(videoInfo.fps)
const videoData = await readVideoFile({ file });
duration = videoData.duration;
width = videoData.width;
height = videoData.height;
fps = Number.isFinite(videoData.fps)
? Math.round(videoData.fps)
: undefined;
hasAudio = videoInfo.hasAudio;
hasAudio = videoData.hasAudio;
thumbnailUrl = videoData.thumbnailUrl ?? undefined;
thumbnailUrl = await generateThumbnail({
videoFile: file,
timeInSeconds: 1,
});
if (!videoData.canDecode) {
toast.error(`Can't preview ${file.name}`, {
description: getUnsupportedVideoDescription({
codec: videoData.codec,
}),
});
}
} catch (error) {
console.warn("Video processing failed", error);
const message =
error instanceof Error
? error.message
: "Could not process video";
toast.error(`Couldn't process ${file.name}`, {
description: message,
});
}
} else if (fileType === "audio") {
// For audio, we don't set width/height/fps (they'll be undefined)
duration = await getMediaDuration({ file });
}
@ -264,7 +187,7 @@ export async function processMediaAssets({
} catch (error) {
console.error("Error processing file:", file.name, error);
toast.error(`Failed to process ${file.name}`);
URL.revokeObjectURL(url); // Clean up on error
URL.revokeObjectURL(url);
}
}

View File

@ -0,0 +1,56 @@
const THUMBNAIL_MAX_WIDTH = 1280;
const THUMBNAIL_MAX_HEIGHT = 720;
export function thumbnailSize({
width,
height,
}: {
width: number;
height: number;
}): { width: number; height: number } {
const aspectRatio = width / height;
let targetWidth = width;
let targetHeight = height;
if (targetWidth > THUMBNAIL_MAX_WIDTH) {
targetWidth = THUMBNAIL_MAX_WIDTH;
targetHeight = Math.round(targetWidth / aspectRatio);
}
if (targetHeight > THUMBNAIL_MAX_HEIGHT) {
targetHeight = THUMBNAIL_MAX_HEIGHT;
targetWidth = Math.round(targetHeight * aspectRatio);
}
return { width: targetWidth, height: targetHeight };
}
export function renderThumbnailDataUrl({
width,
height,
draw,
}: {
width: number;
height: number;
draw: ({
context,
width,
height,
}: {
context: CanvasRenderingContext2D;
width: number;
height: number;
}) => void;
}): string {
const size = thumbnailSize({ width, height });
const canvas = document.createElement("canvas");
canvas.width = size.width;
canvas.height = size.height;
const context = canvas.getContext("2d");
if (!context) {
throw new Error("Could not get canvas context");
}
draw({ context, width: size.width, height: size.height });
return canvas.toDataURL("image/jpeg", 0.8);
}

View File

@ -7,6 +7,7 @@ import {
} from "mediabunny";
interface VideoSinkData {
input: Input;
sink: CanvasSink;
iterator: AsyncGenerator<WrappedCanvas, void, unknown> | null;
currentFrame: WrappedCanvas | null;
@ -262,12 +263,12 @@ export class VideoCache {
mediaId: string;
file: File;
}): Promise<void> {
try {
const input = new Input({
source: new BlobSource(file),
formats: ALL_FORMATS,
});
const input = new Input({
source: new BlobSource(file),
formats: ALL_FORMATS,
});
try {
const videoTrack = await input.getPrimaryVideoTrack();
if (!videoTrack) {
throw new Error("No video track found");
@ -284,6 +285,7 @@ export class VideoCache {
});
this.sinks.set(mediaId, {
input,
sink,
iterator: null,
currentFrame: null,
@ -293,6 +295,7 @@ export class VideoCache {
prefetchPromise: null,
});
} catch (error) {
input.dispose();
console.error(`Failed to initialize video sink for ${mediaId}:`, error);
throw error;
}
@ -305,6 +308,7 @@ export class VideoCache {
void sinkData.iterator.return();
}
sinkData.input.dispose();
this.sinks.delete(mediaId);
}