fix: surface unsupported video codec on import, fix Input leaks
This commit is contained in:
parent
cceb835a84
commit
0dffefdd59
|
|
@ -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 { createTimelineAudioBuffer } from "@/media/audio";
|
||||||
import type { SceneTracks } from "@/timeline";
|
import type { SceneTracks } from "@/timeline";
|
||||||
import type { MediaAsset } from "@/media/types";
|
import type { MediaAsset } from "@/media/types";
|
||||||
import { TICKS_PER_SECOND } from "@/wasm";
|
import { TICKS_PER_SECOND } from "@/wasm";
|
||||||
|
import { renderThumbnailDataUrl } from "./thumbnail";
|
||||||
|
|
||||||
export async function getVideoInfo({
|
export type VideoFileData = {
|
||||||
videoFile,
|
|
||||||
}: {
|
|
||||||
videoFile: File;
|
|
||||||
}): Promise<{
|
|
||||||
duration: number;
|
duration: number;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
fps: number;
|
fps: number;
|
||||||
hasAudio: boolean;
|
hasAudio: boolean;
|
||||||
}> {
|
codec: VideoCodec | null;
|
||||||
|
canDecode: boolean;
|
||||||
|
thumbnailUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function readVideoFile({
|
||||||
|
file,
|
||||||
|
}: {
|
||||||
|
file: File;
|
||||||
|
}): Promise<VideoFileData> {
|
||||||
const input = new Input({
|
const input = new Input({
|
||||||
source: new BlobSource(videoFile),
|
source: new BlobSource(file),
|
||||||
formats: ALL_FORMATS,
|
formats: ALL_FORMATS,
|
||||||
});
|
});
|
||||||
|
|
||||||
const duration = await input.computeDuration();
|
try {
|
||||||
const videoTrack = await input.getPrimaryVideoTrack();
|
const duration = await input.computeDuration();
|
||||||
|
const videoTrack = await input.getPrimaryVideoTrack();
|
||||||
|
|
||||||
if (!videoTrack) {
|
if (!videoTrack) {
|
||||||
throw new Error("No video track found in the file");
|
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;
|
const SAMPLE_RATE = 44100;
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,25 @@
|
||||||
import { Input, ALL_FORMATS, BlobSource, VideoSampleSink } from "mediabunny";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { getMediaTypeFromFile } from "@/media/media-utils";
|
import { getMediaTypeFromFile } from "@/media/media-utils";
|
||||||
import { formatStorageBytes } from "@/services/storage/quota";
|
import { formatStorageBytes } from "@/services/storage/quota";
|
||||||
import { storageService } from "@/services/storage/service";
|
import { storageService } from "@/services/storage/service";
|
||||||
import type { MediaAsset } from "@/media/types";
|
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"> {}
|
export interface ProcessedMediaAsset extends Omit<MediaAsset, "id"> {}
|
||||||
|
|
||||||
const THUMBNAIL_MAX_WIDTH = 1280;
|
const getUnsupportedVideoDescription = ({
|
||||||
const THUMBNAIL_MAX_HEIGHT = 720;
|
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 = ({
|
const getStorageLimitDescription = ({
|
||||||
fileSize,
|
fileSize,
|
||||||
|
|
@ -29,103 +39,6 @@ const getStorageLimitDescription = ({
|
||||||
})} is safely available in browser storage.`;
|
})} 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({
|
async function generateImageThumbnail({
|
||||||
imageFile,
|
imageFile,
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -137,7 +50,7 @@ async function generateImageThumbnail({
|
||||||
|
|
||||||
image.addEventListener("load", () => {
|
image.addEventListener("load", () => {
|
||||||
try {
|
try {
|
||||||
const thumbnailUrl = renderToThumbnailDataUrl({
|
const thumbnailUrl = renderThumbnailDataUrl({
|
||||||
width: image.naturalWidth,
|
width: image.naturalWidth,
|
||||||
height: image.naturalHeight,
|
height: image.naturalHeight,
|
||||||
draw: ({ context, width, height }) => {
|
draw: ({ context, width, height }) => {
|
||||||
|
|
@ -220,24 +133,34 @@ export async function processMediaAssets({
|
||||||
height = result.height;
|
height = result.height;
|
||||||
} else if (fileType === "video") {
|
} else if (fileType === "video") {
|
||||||
try {
|
try {
|
||||||
const videoInfo = await getVideoInfo({ videoFile: file });
|
const videoData = await readVideoFile({ file });
|
||||||
duration = videoInfo.duration;
|
duration = videoData.duration;
|
||||||
width = videoInfo.width;
|
width = videoData.width;
|
||||||
height = videoInfo.height;
|
height = videoData.height;
|
||||||
fps = Number.isFinite(videoInfo.fps)
|
fps = Number.isFinite(videoData.fps)
|
||||||
? Math.round(videoInfo.fps)
|
? Math.round(videoData.fps)
|
||||||
: undefined;
|
: undefined;
|
||||||
hasAudio = videoInfo.hasAudio;
|
hasAudio = videoData.hasAudio;
|
||||||
|
thumbnailUrl = videoData.thumbnailUrl ?? undefined;
|
||||||
|
|
||||||
thumbnailUrl = await generateThumbnail({
|
if (!videoData.canDecode) {
|
||||||
videoFile: file,
|
toast.error(`Can't preview ${file.name}`, {
|
||||||
timeInSeconds: 1,
|
description: getUnsupportedVideoDescription({
|
||||||
});
|
codec: videoData.codec,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} 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") {
|
} else if (fileType === "audio") {
|
||||||
// For audio, we don't set width/height/fps (they'll be undefined)
|
|
||||||
duration = await getMediaDuration({ file });
|
duration = await getMediaDuration({ file });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -264,7 +187,7 @@ export async function processMediaAssets({
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error processing file:", file.name, error);
|
console.error("Error processing file:", file.name, error);
|
||||||
toast.error(`Failed to process ${file.name}`);
|
toast.error(`Failed to process ${file.name}`);
|
||||||
URL.revokeObjectURL(url); // Clean up on error
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
} from "mediabunny";
|
} from "mediabunny";
|
||||||
|
|
||||||
interface VideoSinkData {
|
interface VideoSinkData {
|
||||||
|
input: Input;
|
||||||
sink: CanvasSink;
|
sink: CanvasSink;
|
||||||
iterator: AsyncGenerator<WrappedCanvas, void, unknown> | null;
|
iterator: AsyncGenerator<WrappedCanvas, void, unknown> | null;
|
||||||
currentFrame: WrappedCanvas | null;
|
currentFrame: WrappedCanvas | null;
|
||||||
|
|
@ -262,12 +263,12 @@ export class VideoCache {
|
||||||
mediaId: string;
|
mediaId: string;
|
||||||
file: File;
|
file: File;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
try {
|
const input = new Input({
|
||||||
const input = new Input({
|
source: new BlobSource(file),
|
||||||
source: new BlobSource(file),
|
formats: ALL_FORMATS,
|
||||||
formats: ALL_FORMATS,
|
});
|
||||||
});
|
|
||||||
|
|
||||||
|
try {
|
||||||
const videoTrack = await input.getPrimaryVideoTrack();
|
const videoTrack = await input.getPrimaryVideoTrack();
|
||||||
if (!videoTrack) {
|
if (!videoTrack) {
|
||||||
throw new Error("No video track found");
|
throw new Error("No video track found");
|
||||||
|
|
@ -284,6 +285,7 @@ export class VideoCache {
|
||||||
});
|
});
|
||||||
|
|
||||||
this.sinks.set(mediaId, {
|
this.sinks.set(mediaId, {
|
||||||
|
input,
|
||||||
sink,
|
sink,
|
||||||
iterator: null,
|
iterator: null,
|
||||||
currentFrame: null,
|
currentFrame: null,
|
||||||
|
|
@ -293,6 +295,7 @@ export class VideoCache {
|
||||||
prefetchPromise: null,
|
prefetchPromise: null,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
input.dispose();
|
||||||
console.error(`Failed to initialize video sink for ${mediaId}:`, error);
|
console.error(`Failed to initialize video sink for ${mediaId}:`, error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
@ -305,6 +308,7 @@ export class VideoCache {
|
||||||
void sinkData.iterator.return();
|
void sinkData.iterator.return();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sinkData.input.dispose();
|
||||||
this.sinks.delete(mediaId);
|
this.sinks.delete(mediaId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue