From 91542138686005a81045938b8839bfd992eeccfd Mon Sep 17 00:00:00 2001 From: Zachary Date: Tue, 24 Feb 2026 18:18:25 +0100 Subject: [PATCH 1/6] Add maskable PWA icon (#227) --- static/lettermark_maskable.png | Bin 0 -> 25711 bytes static/manifest.json | 14 +++++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 static/lettermark_maskable.png diff --git a/static/lettermark_maskable.png b/static/lettermark_maskable.png new file mode 100644 index 0000000000000000000000000000000000000000..50462f34e8ef6cbe9a660e9482e5cfcdf1f1196e GIT binary patch literal 25711 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%0Aa&s4bI`*24N>?iJm`Nf~;R??{$P53_ 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 V)3+x(9KnBJ+LsJ2R$Q>T^M8-A{+<8; literal 0 HcmV?d00001 diff --git a/static/manifest.json b/static/manifest.json index 5a44463..7f83167 100644 --- a/static/manifest.json +++ b/static/manifest.json @@ -16,6 +16,18 @@ "src": "lettermark.jpg", "sizes": "512x512", "type": "image/jpeg" + }, + { + "src": "lettermark_maskable.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "lettermark_maskable.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" } ] -} +} \ No newline at end of file From 7f888e3f43acf97263de6385b408fcf51e0a7bfc Mon Sep 17 00:00:00 2001 From: not-nullptr Date: Thu, 26 Feb 2026 11:34:19 +0000 Subject: [PATCH 2/6] feat: preliminary mediabunny (sorry maya) --- src/lib/converters/mediabunny.svelte.ts | 98 +++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 src/lib/converters/mediabunny.svelte.ts diff --git a/src/lib/converters/mediabunny.svelte.ts b/src/lib/converters/mediabunny.svelte.ts new file mode 100644 index 0000000..fbd7654 --- /dev/null +++ b/src/lib/converters/mediabunny.svelte.ts @@ -0,0 +1,98 @@ +import { VertFile } from "$lib/types"; +import { + ALL_FORMATS, + BufferTarget, + Conversion, + Input, + MkvOutputFormat, + MovOutputFormat, + Mp4InputFormat, + Mp4OutputFormat, + Output, + ReadableStreamSource, + WebMInputFormat, + WebMOutputFormat, +} from "mediabunny"; +import { Converter, FormatInfo, type WorkerStatus } from "./converter.svelte"; +import { ToastManager } from "$lib/util/toast.svelte"; + +export class MediabunnyConverter extends Converter { + public name = "mediabunny"; + public status: WorkerStatus = $state("ready"); + + public supportedFormats: FormatInfo[] = [ + new FormatInfo("mp4", true, true), + new FormatInfo("mkv", false, true), + new FormatInfo("webm", true, true), + new FormatInfo("mov", false, true), + ]; + + constructor() { + super(); + } + + public async convert(file: VertFile, to: string): Promise { + const stream = file.file.stream(); // ReadableStream> + const input = new Input({ + formats: [new Mp4InputFormat(), new WebMInputFormat()], + source: new ReadableStreamSource(stream), + }); + + const toFormat = to.startsWith(".") ? to.slice(1) : to; + const originalName = file.file.name.split(".").slice(0, -1).join("."); + + 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`); + } + + await conversion.execute(); + + if (!output.target.buffer) { + throw new Error("Mediabunny conversion failed: no output buffer"); + } + + const f = new File( + [output.target.buffer], + `${originalName}.${toFormat}`, + { + type: "application/octet-stream", + }, + ); + + return new VertFile(f, toFormat); + } + + private format(ext: string) { + switch (ext) { + case ".mp4": + return new Mp4OutputFormat(); + case ".mkv": + return new MkvOutputFormat(); + case ".webm": + return new WebMOutputFormat(); + case ".mov": + return new MovOutputFormat(); + default: + throw new Error(`Unsupported format: ${ext}`); + } + } + + public async cancel(input: VertFile): Promise {} +} From 9e58c693a0c640510dbd4ea714e048fba721d19f Mon Sep 17 00:00:00 2001 From: Maya Date: Fri, 27 Feb 2026 21:49:56 +0300 Subject: [PATCH 3/6] feat: mediabunny new formats, fix bugs --- bun.lock | 16 ++++++++--- package.json | 1 + src/lib/converters/index.ts | 7 +++-- src/lib/converters/mediabunny.svelte.ts | 35 ++++++++++++++++++------- 4 files changed, 44 insertions(+), 15 deletions(-) diff --git a/bun.lock b/bun.lock index 355ea6b..fbdab17 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "vert", @@ -17,7 +18,8 @@ "clsx": "^2.1.1", "fflate": "^0.8.2", "lucide-svelte": "^0.554.0", - "music-metadata": "^11.10.1", + "mediabunny": "^1.35.1", + "music-metadata": "^11.10.3", "overlayscrollbars": "^2.12.0", "overlayscrollbars-svelte": "^0.5.5", "p-queue": "^9.0.1", @@ -31,7 +33,7 @@ "@inlang/paraglide-js": "^2.5.0", "@poppanator/sveltekit-svg": "^5.0.1", "@sveltejs/adapter-static": "^3.0.10", - "@sveltejs/kit": "^2.48.5", + "@sveltejs/kit": "^2.49.0", "@sveltejs/vite-plugin-svelte": "^4.0.4", "@types/eslint": "^9.6.1", "@types/sanitize-html": "^2.16.0", @@ -44,8 +46,8 @@ "prettier": "^3.6.2", "prettier-plugin-svelte": "^3.4.0", "prettier-plugin-tailwindcss": "^0.6.14", - "sass": "^1.94.1", - "svelte": "^5.43.12", + "sass": "^1.94.2", + "svelte": "^5.43.14", "svelte-check": "^4.3.4", "tailwindcss": "^3.4.18", "typescript": "^5.9.3", @@ -314,6 +316,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=="], @@ -628,6 +634,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/package.json b/package.json index 4698882..46e573e 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "clsx": "^2.1.1", "fflate": "^0.8.2", "lucide-svelte": "^0.554.0", + "mediabunny": "^1.35.1", "music-metadata": "^11.10.3", "overlayscrollbars": "^2.12.0", "overlayscrollbars-svelte": "^0.5.5", 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 index fbd7654..006e093 100644 --- a/src/lib/converters/mediabunny.svelte.ts +++ b/src/lib/converters/mediabunny.svelte.ts @@ -1,16 +1,19 @@ import { VertFile } from "$lib/types"; import { - ALL_FORMATS, + BlobSource, BufferTarget, Conversion, Input, + MATROSKA, MkvOutputFormat, MovOutputFormat, - Mp4InputFormat, + MP4, Mp4OutputFormat, + MPEG_TS, + MpegTsOutputFormat, Output, - ReadableStreamSource, - WebMInputFormat, + QTFF, + WEBM, WebMOutputFormat, } from "mediabunny"; import { Converter, FormatInfo, type WorkerStatus } from "./converter.svelte"; @@ -22,9 +25,16 @@ export class MediabunnyConverter extends Converter { public supportedFormats: FormatInfo[] = [ new FormatInfo("mp4", true, true), - new FormatInfo("mkv", false, true), + new FormatInfo("m4v", true, true), + new FormatInfo("mkv", true, true), new FormatInfo("webm", true, true), - new FormatInfo("mov", false, 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() { @@ -32,10 +42,10 @@ export class MediabunnyConverter extends Converter { } public async convert(file: VertFile, to: string): Promise { - const stream = file.file.stream(); // ReadableStream> const input = new Input({ - formats: [new Mp4InputFormat(), new WebMInputFormat()], - source: new ReadableStreamSource(stream), + // TODO: add settings & special handling for certain formats & codecs + formats: [MP4, QTFF, MATROSKA, WEBM, MPEG_TS], + source: new BlobSource(file.file) }); const toFormat = to.startsWith(".") ? to.slice(1) : to; @@ -81,7 +91,12 @@ export class MediabunnyConverter extends Converter { 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(); @@ -89,6 +104,8 @@ export class MediabunnyConverter extends Converter { return new WebMOutputFormat(); case ".mov": return new MovOutputFormat(); + case ".ts": + return new MpegTsOutputFormat(); default: throw new Error(`Unsupported format: ${ext}`); } From ffb6ef856b3d8303a033cf0c6b4f8bdd3021d20c Mon Sep 17 00:00:00 2001 From: Maya Date: Sat, 28 Feb 2026 00:06:16 +0300 Subject: [PATCH 4/6] feat: mediabunny progress and cancel --- messages/en.json | 2 +- src/lib/converters/mediabunny.svelte.ts | 36 +++++++++++++++++++++---- src/routes/+page.svelte | 20 +++++++++++--- 3 files changed, 48 insertions(+), 10 deletions(-) diff --git a/messages/en.json b/messages/en.json index ff2d406..7e331ff 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/src/lib/converters/mediabunny.svelte.ts b/src/lib/converters/mediabunny.svelte.ts index 006e093..a6fffb0 100644 --- a/src/lib/converters/mediabunny.svelte.ts +++ b/src/lib/converters/mediabunny.svelte.ts @@ -18,10 +18,14 @@ import { } from "mediabunny"; import { Converter, FormatInfo, type WorkerStatus } from "./converter.svelte"; import { ToastManager } from "$lib/util/toast.svelte"; +import { error, log } from "$lib/util/logger"; 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), @@ -45,12 +49,9 @@ export class MediabunnyConverter extends Converter { 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) + source: new BlobSource(file.file), }); - const toFormat = to.startsWith(".") ? to.slice(1) : to; - const originalName = file.file.name.split(".").slice(0, -1).join("."); - const output = new Output({ format: this.format(to), target: new BufferTarget(), @@ -72,12 +73,20 @@ export class MediabunnyConverter extends Converter { 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}`, @@ -111,5 +120,22 @@ export class MediabunnyConverter extends Converter { } } - public async cancel(input: VertFile): Promise {} + 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), + }), + )}

Date: Sun, 1 Mar 2026 12:30:36 +0300 Subject: [PATCH 5/6] feat: additional coders --- bun.lock | 6 ++++++ package.json | 2 ++ src/lib/converters/mediabunny.svelte.ts | 18 +++++++++++++++++- 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index fbdab17..75b8e91 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,8 @@ "@fontsource/lexend": "^5.2.11", "@fontsource/radio-canada-big": "^5.2.7", "@imagemagick/magick-wasm": "^0.0.37", + "@mediabunny/ac3": "^1.35.1", + "@mediabunny/mp3-encoder": "^1.35.1", "@stripe/stripe-js": "^8.5.2", "byte-data": "^19.0.1", "client-zip": "^2.5.0", @@ -176,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=="], diff --git a/package.json b/package.json index 46e573e..3999768 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,8 @@ "@fontsource/lexend": "^5.2.11", "@fontsource/radio-canada-big": "^5.2.7", "@imagemagick/magick-wasm": "^0.0.37", + "@mediabunny/ac3": "^1.35.1", + "@mediabunny/mp3-encoder": "^1.35.1", "@stripe/stripe-js": "^8.5.2", "byte-data": "^19.0.1", "client-zip": "^2.5.0", diff --git a/src/lib/converters/mediabunny.svelte.ts b/src/lib/converters/mediabunny.svelte.ts index a6fffb0..fb2680d 100644 --- a/src/lib/converters/mediabunny.svelte.ts +++ b/src/lib/converters/mediabunny.svelte.ts @@ -2,6 +2,7 @@ import { VertFile } from "$lib/types"; import { BlobSource, BufferTarget, + canEncodeAudio, Conversion, Input, MATROSKA, @@ -16,6 +17,8 @@ import { 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"; @@ -43,6 +46,19 @@ export class MediabunnyConverter extends Converter { 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 { @@ -114,7 +130,7 @@ export class MediabunnyConverter extends Converter { case ".mov": return new MovOutputFormat(); case ".ts": - return new MpegTsOutputFormat(); + return new MpegTsOutputFormat(); // FIXME: audio tracks discarded - prob needs another audio codec default: throw new Error(`Unsupported format: ${ext}`); } From 1aac50a649549b1add289758f90ffdf7bded103e Mon Sep 17 00:00:00 2001 From: Maya Date: Sun, 1 Mar 2026 12:52:57 +0300 Subject: [PATCH 6/6] feat: codec compatibility object will use later --- src/lib/converters/mediabunny.svelte.ts | 27 +++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/lib/converters/mediabunny.svelte.ts b/src/lib/converters/mediabunny.svelte.ts index fb2680d..22c610e 100644 --- a/src/lib/converters/mediabunny.svelte.ts +++ b/src/lib/converters/mediabunny.svelte.ts @@ -23,6 +23,33 @@ 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");