Skip to content

Commit bb2d6d9

Browse files
myieyehahn-kev
authored andcommitted
Add ffmpeg wasm
^ Conflicts: ^ frontend/viewer/src/lib/components/audio/wavesurfer/waveform.svelte
1 parent adfaf20 commit bb2d6d9

12 files changed

Lines changed: 209 additions & 94 deletions

File tree

frontend/package.json

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,10 +109,7 @@
109109
"svelte-intl-precompile": "^0.12.3",
110110
"sveltekit-search-params": "^3",
111111
"tus-js-client": "^4.3.1",
112-
"viewer": "workspace:*",
113-
"@ffmpeg/ffmpeg": "0.12.15",
114-
"@ffmpeg/util": "0.12.2",
115-
"@ffmpeg/core": "0.12.10"
112+
"viewer": "workspace:*"
116113
},
117114
"pnpm": {
118115
"onlyBuiltDependencies": [

frontend/pnpm-lock.yaml

Lines changed: 9 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/viewer/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"lexbox-dev": "vite build -m web-component",
2121
"build": "vite build -m web-component",
2222
"build-app": "vite build",
23+
"build-ffmpeg-worker": "vite build --config vite.config.ffmpeg-worker.ts",
2324
"preview": "vite preview",
2425
"pretest:playwright": "playwright install",
2526
"test:playwright": "playwright test",
@@ -98,6 +99,9 @@
9899
"@microsoft/signalr": "^8.0.0",
99100
"autoprefixer": "^10.4.19",
100101
"fast-json-patch": "^3.1.1",
102+
"@ffmpeg/ffmpeg": "0.12.15",
103+
"@ffmpeg/util": "0.12.2",
104+
"@ffmpeg/core": "0.12.10",
101105
"jsdom": "^26.1.0",
102106
"just-throttle": "^4.2.0",
103107
"postcss": "catalog:",

frontend/viewer/src/lib/components/audio/AudioDialog.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import AudioEditor from './audio-editor.svelte';
1111
import Loading from '$lib/components/Loading.svelte';
1212
13-
let open = $state(false);
13+
let open = $state(true);
1414
useBackHandler({addToStack: () => open, onBack: () => open = false, key: 'audio-dialog'});
1515
const dialogsService = useDialogsService();
1616
dialogsService.invokeAudioDialog = getAudio;

frontend/viewer/src/lib/components/audio/audio-editor.svelte

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import {formatDigitalDuration} from '../ui/format/format-duration';
77
import DevContent from '$lib/layout/DevContent.svelte';
88
import {Label} from '../ui/label';
9+
import {convertToWav, loadFFmpeg} from './ffmpeg';
910
1011
type Props = {
1112
audio: Blob;
@@ -21,6 +22,10 @@
2122
const mb = $derived((audio.size / 1024 / 1024).toFixed(2));
2223
const formatedDuration = $derived(duration ? formatDigitalDuration({ seconds: duration }) : 'unknown');
2324
25+
void loadFFmpeg().then(async (ffmpeg) => {
26+
if (!audio) return;
27+
audio = await convertToWav(ffmpeg, audio);
28+
});
2429
</script>
2530

2631
<div class="flex flex-col gap-4 items-center justify-center">
@@ -37,9 +42,11 @@
3742
<span class="col-span-4">{$t`${audio.type}`}</span>
3843
</DevContent>
3944
</span>
40-
<!-- contain-inline-size prevents wavesurfer from freaking out inside a grid -->
45+
<!-- contain-size prevents wavesurfer from freaking out inside a grid
46+
contain-inline-size would improve the height reactivity of the waveform, but
47+
results in the waveform sometimes change its height unexpectedly -->
4148
<!-- pb-8 ensures the timeline is in the bounds of the container -->
42-
<div class="w-full grow max-h-32 pb-3 contain-inline-size border-y">
49+
<div class="w-full grow max-h-32 pb-3 contain-size border-y">
4350
<Waveform {audio} bind:playing bind:audioApi bind:duration showTimeline autoplay class="size-full" />
4451
</div>
4552
<div class="flex gap-2">

frontend/viewer/src/lib/components/audio/ffmpeg-loader.ts

Lines changed: 0 additions & 73 deletions
This file was deleted.
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/* This file generated by Vite / pnpm build-ffmpeg-worker. */
2+
// @ts-nocheck
3+
const f = "0.12.9", R = `https://unpkg.com/@ffmpeg/core@${f}/dist/umd/ffmpeg-core.js`;
4+
var E;
5+
(function(t) {
6+
t.LOAD = "LOAD", t.EXEC = "EXEC", t.FFPROBE = "FFPROBE", t.WRITE_FILE = "WRITE_FILE", t.READ_FILE = "READ_FILE", t.DELETE_FILE = "DELETE_FILE", t.RENAME = "RENAME", t.CREATE_DIR = "CREATE_DIR", t.LIST_DIR = "LIST_DIR", t.DELETE_DIR = "DELETE_DIR", t.ERROR = "ERROR", t.DOWNLOAD = "DOWNLOAD", t.PROGRESS = "PROGRESS", t.LOG = "LOG", t.MOUNT = "MOUNT", t.UNMOUNT = "UNMOUNT";
7+
})(E || (E = {}));
8+
const u = new Error("unknown message type"), O = new Error("ffmpeg is not loaded, call `await ffmpeg.load()` first"), m = new Error("failed to import ffmpeg-core.js");
9+
let r;
10+
const l = async ({ coreURL: t, wasmURL: n, workerURL: e }) => {
11+
const o = !r;
12+
try {
13+
t || (t = R), importScripts(t);
14+
} catch {
15+
if ((!t || t === R) && (t = R.replace("/umd/", "/esm/")), self.createFFmpegCore = (await import(
16+
/* @vite-ignore */
17+
t
18+
)).default, !self.createFFmpegCore)
19+
throw m;
20+
}
21+
const s = t, c = n || t.replace(/.js$/g, ".wasm"), a = e || t.replace(/.js$/g, ".worker.js");
22+
return r = await self.createFFmpegCore({
23+
// Fix `Overload resolution failed.` when using multi-threaded ffmpeg-core.
24+
// Encoded wasmURL and workerURL in the URL as a hack to fix locateFile issue.
25+
mainScriptUrlOrBlob: `${s}#${btoa(JSON.stringify({ wasmURL: c, workerURL: a }))}`
26+
}), r.setLogger((i) => self.postMessage({ type: E.LOG, data: i })), r.setProgress((i) => self.postMessage({
27+
type: E.PROGRESS,
28+
data: i
29+
})), o;
30+
}, D = ({ args: t, timeout: n = -1 }) => {
31+
r.setTimeout(n), r.exec(...t);
32+
const e = r.ret;
33+
return r.reset(), e;
34+
}, S = ({ args: t, timeout: n = -1 }) => {
35+
r.setTimeout(n), r.ffprobe(...t);
36+
const e = r.ret;
37+
return r.reset(), e;
38+
}, I = ({ path: t, data: n }) => (r.FS.writeFile(t, n), !0), L = ({ path: t, encoding: n }) => r.FS.readFile(t, { encoding: n }), N = ({ path: t }) => (r.FS.unlink(t), !0), A = ({ oldPath: t, newPath: n }) => (r.FS.rename(t, n), !0), k = ({ path: t }) => (r.FS.mkdir(t), !0), w = ({ path: t }) => {
39+
const n = r.FS.readdir(t), e = [];
40+
for (const o of n) {
41+
const s = r.FS.stat(`${t}/${o}`), c = r.FS.isDir(s.mode);
42+
e.push({ name: o, isDir: c });
43+
}
44+
return e;
45+
}, b = ({ path: t }) => (r.FS.rmdir(t), !0), p = ({ fsType: t, options: n, mountPoint: e }) => {
46+
const o = t, s = r.FS.filesystems[o];
47+
return s ? (r.FS.mount(s, n, e), !0) : !1;
48+
}, d = ({ mountPoint: t }) => (r.FS.unmount(t), !0);
49+
self.onmessage = async ({ data: { id: t, type: n, data: e } }) => {
50+
const o = [];
51+
let s;
52+
try {
53+
if (n !== E.LOAD && !r)
54+
throw O;
55+
switch (n) {
56+
case E.LOAD:
57+
s = await l(e);
58+
break;
59+
case E.EXEC:
60+
s = D(e);
61+
break;
62+
case E.FFPROBE:
63+
s = S(e);
64+
break;
65+
case E.WRITE_FILE:
66+
s = I(e);
67+
break;
68+
case E.READ_FILE:
69+
s = L(e);
70+
break;
71+
case E.DELETE_FILE:
72+
s = N(e);
73+
break;
74+
case E.RENAME:
75+
s = A(e);
76+
break;
77+
case E.CREATE_DIR:
78+
s = k(e);
79+
break;
80+
case E.LIST_DIR:
81+
s = w(e);
82+
break;
83+
case E.DELETE_DIR:
84+
s = b(e);
85+
break;
86+
case E.MOUNT:
87+
s = p(e);
88+
break;
89+
case E.UNMOUNT:
90+
s = d(e);
91+
break;
92+
default:
93+
throw u;
94+
}
95+
} catch (c) {
96+
self.postMessage({
97+
id: t,
98+
type: E.ERROR,
99+
data: c.toString()
100+
});
101+
return;
102+
}
103+
s instanceof Uint8Array && o.push(s.buffer), self.postMessage({ id: t, type: n, data: s }, o);
104+
};
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import {FFmpeg} from '@ffmpeg/ffmpeg';
2+
import coreURL from '@ffmpeg/core?url';
3+
import {fetchFile} from '@ffmpeg/util';
4+
import inlineDataUrlWorker from './bundled-ffmpeg-worker.js?url&inline';
5+
import wasmUrl from '@ffmpeg/core/wasm?url';
6+
7+
let loadingPromise: Promise<FFmpeg> | null = null;
8+
9+
export async function loadFFmpeg(): Promise<FFmpeg> {
10+
if (loadingPromise) {
11+
return loadingPromise;
12+
}
13+
14+
try {
15+
loadingPromise = loadFFmpegInternal();
16+
} catch (error) {
17+
loadingPromise = null;
18+
throw error;
19+
}
20+
21+
return loadingPromise;
22+
}
23+
24+
async function loadFFmpegInternal(): Promise<FFmpeg> {
25+
const ffmpeg = new FFmpeg();
26+
27+
await ffmpeg.load({
28+
coreURL: coreURL,
29+
wasmURL: wasmUrl,
30+
classWorkerURL: import.meta.env.DEV ? inlineDataUrlWorker : undefined,
31+
});
32+
33+
return ffmpeg;
34+
}
35+
36+
export async function convertToWav(ffmpeg: FFmpeg, blob: Blob): Promise<Blob> {
37+
// Load input into ffmpeg FS
38+
const inputName = 'input.mp3';
39+
const outputName = 'output.wav';
40+
41+
await ffmpeg.writeFile(inputName, await fetchFile(blob));
42+
43+
// Normalize volume and convert to WAV
44+
await ffmpeg.exec([
45+
'-i', inputName,
46+
'-af', 'loudnorm',
47+
'-ar', '44100',
48+
'-ac', '2',
49+
'-c:a', 'pcm_s16le',
50+
outputName
51+
]);
52+
53+
// Read output file
54+
const data = await ffmpeg.readFile(outputName) as Uint8Array;;
55+
return new Blob([data], {type: 'audio/wav'});
56+
}
57+
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './ffmpeg-api';

frontend/viewer/src/lib/sandbox/Sandbox.svelte

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,7 @@
3333
import {formatDate, FormatDate, formatNumber} from '$lib/components/ui/format';
3434
import {SvelteDate} from 'svelte/reactivity';
3535
import {RichTextToggle} from '$lib/dotnet-types/generated-types/MiniLcm/Models/RichTextToggle';
36-
import {loadFFmpeg} from '$lib/components/audio/ffmpeg-loader';
37-
36+
import {loadFFmpeg} from '$lib/components/audio/ffmpeg';
3837
3938
const testingService = tryUseService(DotnetService.TestingService);
4039

0 commit comments

Comments
 (0)