diff --git a/bun.lock b/bun.lock index 5092015..4337d87 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,8 @@ "@fontsource/radio-canada-big": "^5.2.7", "@imagemagick/magick-wasm": "^0.0.37", "@stripe/stripe-js": "^8.7.0", + "@mediabunny/ac3": "^1.35.1", + "@mediabunny/mp3-encoder": "^1.35.1", "byte-data": "^19.0.1", "client-zip": "^2.5.0", "clsx": "^2.1.1", @@ -20,6 +22,7 @@ "lucide-svelte": "^0.554.0", "music-metadata": "^11.12.0", "overlayscrollbars": "^2.14.0", + "mediabunny": "^1.35.1", "overlayscrollbars-svelte": "^0.5.5", "p-queue": "^9.1.0", "riff-file": "^1.0.3", @@ -175,6 +178,10 @@ "@lix-js/server-protocol-schema": ["@lix-js/server-protocol-schema@0.1.1", "", {}, "sha512-jBeALB6prAbtr5q4vTuxnRZZv1M2rKe8iNqRQhFJ4Tv7150unEa0vKyz0hs8Gl3fUGsWaNJBh3J8++fpbrpRBQ=="], + "@mediabunny/ac3": ["@mediabunny/ac3@1.35.1", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-gLx3mFfs58/cdz2/f5Fp+6ZOrX5Jli3AZMXw/5EJcgm2VpnC/2oxtJyP1x/00PIS4UCE770slwIdz7U+2CQ31g=="], + + "@mediabunny/mp3-encoder": ["@mediabunny/mp3-encoder@1.35.1", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-iY6FcPs7GbHMs/ASPmdzwojKcBN4AfMa+zFh4KNZNaLToyR7aEZILj9FsPVJA11bshaoo80dTaBcn69i33JHVA=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], @@ -315,6 +322,10 @@ "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], + "@types/dom-mediacapture-transform": ["@types/dom-mediacapture-transform@0.1.11", "", { "dependencies": { "@types/dom-webcodecs": "*" } }, "sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ=="], + + "@types/dom-webcodecs": ["@types/dom-webcodecs@0.1.13", "", {}, "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ=="], + "@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], @@ -629,6 +640,8 @@ "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + "mediabunny": ["mediabunny@1.35.1", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-VrprpjkLTZyIyhzBAc9D3HqgXarAE+le7+6x0Sdu9WN2SD86L8bUy0hz06Xwf14dVPqS7OwpY2KOhlUyqmI2eQ=="], + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], diff --git a/messages/en.json b/messages/en.json index 82791cf..fdd4785 100644 --- a/messages/en.json +++ b/messages/en.json @@ -26,7 +26,7 @@ "audio": "Audio", "documents": "Documents", "video": "Video", - "video_server_processing": "Server supported", + "video_server_processing": "Local & server supported", "local_supported": "Local supported", "status": { "text": "Status: {status}", diff --git a/package.json b/package.json index 790a081..7f53746 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,8 @@ "@fontsource/radio-canada-big": "^5.2.7", "@imagemagick/magick-wasm": "^0.0.37", "@stripe/stripe-js": "^8.7.0", + "@mediabunny/ac3": "^1.35.1", + "@mediabunny/mp3-encoder": "^1.35.1", "byte-data": "^19.0.1", "client-zip": "^2.5.0", "clsx": "^2.1.1", @@ -54,6 +56,7 @@ "music-metadata": "^11.12.0", "overlayscrollbars": "^2.14.0", "overlayscrollbars-svelte": "^0.5.5", + "mediabunny": "^1.35.1", "p-queue": "^9.1.0", "riff-file": "^1.0.3", "sanitize-html": "^2.17.0", diff --git a/src/lib/converters/index.ts b/src/lib/converters/index.ts index 11089ae..82c4a7b 100644 --- a/src/lib/converters/index.ts +++ b/src/lib/converters/index.ts @@ -5,6 +5,7 @@ import { PandocConverter } from "./pandoc.svelte"; import { VertdConverter } from "./vertd.svelte"; import { MagickConverter } from "./magick.svelte"; import { DISABLE_ALL_EXTERNAL_REQUESTS } from "$lib/util/consts"; +import { MediabunnyConverter } from "./mediabunny.svelte"; const getConverters = (): Converter[] => { const converters: Converter[] = [ @@ -12,10 +13,12 @@ const getConverters = (): Converter[] => { new FFmpegConverter(), ]; - if (!DISABLE_ALL_EXTERNAL_REQUESTS) { + if (DISABLE_ALL_EXTERNAL_REQUESTS) { converters.push(new VertdConverter()); } + converters.push(new MediabunnyConverter()); + converters.push(new PandocConverter()); return converters; }; @@ -45,7 +48,7 @@ categories.audio.formats = .map((f) => f.name) || []; categories.video.formats = converters - .find((c) => c.name === "vertd") + .find((c) => c.name === "mediabunny") ?.supportedFormats.filter((f) => f.toSupported && f.isNative) .map((f) => f.name) || []; categories.image.formats = diff --git a/src/lib/converters/mediabunny.svelte.ts b/src/lib/converters/mediabunny.svelte.ts new file mode 100644 index 0000000..22c610e --- /dev/null +++ b/src/lib/converters/mediabunny.svelte.ts @@ -0,0 +1,184 @@ +import { VertFile } from "$lib/types"; +import { + BlobSource, + BufferTarget, + canEncodeAudio, + Conversion, + Input, + MATROSKA, + MkvOutputFormat, + MovOutputFormat, + MP4, + Mp4OutputFormat, + MPEG_TS, + MpegTsOutputFormat, + Output, + QTFF, + WEBM, + WebMOutputFormat, +} from "mediabunny"; +import { registerMp3Encoder } from "@mediabunny/mp3-encoder"; +import { registerAc3Decoder, registerAc3Encoder } from "@mediabunny/ac3"; +import { Converter, FormatInfo, type WorkerStatus } from "./converter.svelte"; +import { ToastManager } from "$lib/util/toast.svelte"; +import { error, log } from "$lib/util/logger"; + +// codec compatibility object, based on docs +// https://mediabunny.dev/guide/supported-formats-and-codecs#compatibility-table +const codecCompatibility = { + video: { + mp4: ['avc', 'hevc', 'vp8', 'vp9', 'av1'], + m4v: ['avc', 'hevc', 'vp8', 'vp9', 'av1'], + f4v: ['avc', 'hevc', 'vp8', 'vp9', 'av1'], + '3gp': ['avc', 'hevc', 'vp8', 'vp9', 'av1'], + '3g2': ['avc', 'hevc', 'vp8', 'vp9', 'av1'], + mkv: ['avc', 'hevc', 'vp8', 'vp9', 'av1'], + webm: ['vp8', 'vp9', 'av1'], + mov: ['avc', 'hevc', 'vp8', 'vp9', 'av1'], + ts: ['avc', 'hevc'], + }, + audio: { + mp4: ['aac', 'opus', 'mp3', 'vorbis', 'flac', 'ac3', 'eac3', 'pcm-s16', 'pcm-s16be', 'pcm-s24', 'pcm-s24be', 'pcm-s32', 'pcm-s32be', 'pcm-f32', 'pcm-f64'], + m4v: ['aac', 'opus', 'mp3', 'vorbis', 'flac', 'ac3', 'eac3', 'pcm-s16', 'pcm-s16be', 'pcm-s24', 'pcm-s24be', 'pcm-s32', 'pcm-s32be', 'pcm-f32', 'pcm-f64'], + f4v: ['aac', 'opus', 'mp3', 'vorbis', 'flac', 'ac3', 'eac3', 'pcm-s16', 'pcm-s16be', 'pcm-s24', 'pcm-s24be', 'pcm-s32', 'pcm-s32be', 'pcm-f32', 'pcm-f64'], + '3gp': ['aac', 'opus', 'mp3', 'vorbis', 'flac', 'ac3', 'eac3', 'pcm-s16', 'pcm-s16be', 'pcm-s24', 'pcm-s24be', 'pcm-s32', 'pcm-s32be', 'pcm-f32', 'pcm-f64'], + '3g2': ['aac', 'opus', 'mp3', 'vorbis', 'flac', 'ac3', 'eac3', 'pcm-s16', 'pcm-s16be', 'pcm-s24', 'pcm-s24be', 'pcm-s32', 'pcm-s32be', 'pcm-f32', 'pcm-f64'], + mkv: ['aac', 'opus', 'mp3', 'vorbis', 'flac', 'ac3', 'eac3', 'pcm-u8', 'pcm-s16', 'pcm-s24', 'pcm-s32', 'pcm-f32', 'pcm-f64'], + webm: ['opus', 'vorbis'], + mov: ['aac', 'opus', 'mp3', 'vorbis', 'flac', 'ac3', 'eac3', 'pcm-u8', 'pcm-s8', 'pcm-s16', 'pcm-s16be', 'pcm-s24', 'pcm-s24be', 'pcm-s32', 'pcm-s32be', 'pcm-f32', 'pcm-f32be', 'pcm-f64', 'ulaw', 'alaw'], + ts: ['aac', 'mp3', 'ac3', 'eac3'], + }, +} as const; + +export class MediabunnyConverter extends Converter { + public name = "mediabunny"; + public status: WorkerStatus = $state("ready"); + public reportsProgress: boolean = true; + + private activeConversions = new Map(); + + public supportedFormats: FormatInfo[] = [ + new FormatInfo("mp4", true, true), + new FormatInfo("m4v", true, true), + new FormatInfo("mkv", true, true), + new FormatInfo("webm", true, true), + new FormatInfo("mov", true, true), + + // mp4-based formats (should work) + new FormatInfo("f4v", true, true), + new FormatInfo("3gp", true, true), + new FormatInfo("3g2", true, true), + new FormatInfo("ts", true, true), + ]; + + constructor() { + super(); + + // additional mediabunny coders + // currently both official ones -- maybe add our own in the future + this.initializeCodecs(); + } + + private async initializeCodecs(): Promise { + if (!(await canEncodeAudio("mp3"))) { + // Only register the custom encoder if there's no native support + registerMp3Encoder(); + } + registerAc3Decoder(); + registerAc3Encoder(); + } + + public async convert(file: VertFile, to: string): Promise { + const input = new Input({ + // TODO: add settings & special handling for certain formats & codecs + formats: [MP4, QTFF, MATROSKA, WEBM, MPEG_TS], + source: new BlobSource(file.file), + }); + + const output = new Output({ + format: this.format(to), + target: new BufferTarget(), + }); + + const conversion = await Conversion.init({ + input, + output, + }); + + if (!conversion.isValid) { + for (const discarded of conversion.discardedTracks) { + ToastManager.add({ + type: "error", + message: `Mediabunny discarded unsupported track: ${discarded.reason}`, + }); + } + + throw new Error(`Mediabunny conversion not valid`); + } + + conversion.onProgress = (progress) => { + file.progress = progress * 100; + }; + + this.activeConversions.set(file.id, conversion); + await conversion.execute(); + this.activeConversions.delete(file.id); + + if (!output.target.buffer) { + throw new Error("Mediabunny conversion failed: no output buffer"); + } + + const toFormat = to.startsWith(".") ? to.slice(1) : to; + const originalName = file.file.name.split(".").slice(0, -1).join("."); + const f = new File( + [output.target.buffer], + `${originalName}.${toFormat}`, + { + type: "application/octet-stream", + }, + ); + + return new VertFile(f, toFormat); + } + + private format(ext: string) { + switch (ext) { + // i'm seeing this "ISMV" format from microsoft, so maybe? + case ".mp4": + case ".m4v": + case ".f4v": + case ".3gp": + case ".3g2": + return new Mp4OutputFormat(); + case ".mkv": + return new MkvOutputFormat(); + case ".webm": + return new WebMOutputFormat(); + case ".mov": + return new MovOutputFormat(); + case ".ts": + return new MpegTsOutputFormat(); // FIXME: audio tracks discarded - prob needs another audio codec + default: + throw new Error(`Unsupported format: ${ext}`); + } + } + + public async cancel(input: VertFile): Promise { + const conversion = this.activeConversions.get(input.id); + if (!conversion) { + error( + ["converters", this.name], + `no active conversion found for file ${input.name}`, + ); + return; + } + + log( + ["converters", this.name], + `cancelling conversion for file ${input.name}`, + ); + + conversion.cancel(); + this.activeConversions.delete(input.id); + } +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index c063d64..954ad0d 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -66,10 +66,20 @@ }; if (!DISABLE_ALL_EXTERNAL_REQUESTS) { + const formats = Array.from( + new Set([ + ...getSupportedFormats("vertd").split(", "), + ...getSupportedFormats("mediabunny").split(", "), + ]), + ) + .filter((f) => f !== "none") + .join(", "); + output.Video = { - formats: getSupportedFormats("vertd"), + formats, icon: Film, title: m["upload.cards.video"](), + // TODO: add "partial" state? somehow figure out diff between vertd and mediabunny status: $vertdLoaded === true ? "ready" : "not-ready", // not using converter.status for this }; } @@ -231,9 +241,11 @@

{/if}

- {@html sanitize(m["upload.cards.status.text"]({ - status: getStatusText(s.status), - }))} + {@html sanitize( + m["upload.cards.status.text"]({ + status: getStatusText(s.status), + }), + )}