cobalt/web/src/lib/libav.ts

189 lines
6 KiB
TypeScript
Raw Normal View History

2024-08-12 18:28:38 +02:00
import mime from "mime";
import LibAV, { type LibAV as LibAVInstance } from "@imput/libav.js-remux-cli";
2024-08-13 16:39:24 +02:00
import type { FFmpegProgressCallback, FFmpegProgressEvent, FFmpegProgressStatus, FileInfo, RenderParams } from "./types/libav";
2024-08-13 17:33:30 +02:00
import type { FfprobeData } from "fluent-ffmpeg";
2024-08-31 19:46:10 +02:00
import { browser } from "$app/environment";
2024-08-12 18:28:38 +02:00
export default class LibAVWrapper {
libav: Promise<LibAVInstance> | null;
2024-08-12 18:28:38 +02:00
concurrency: number;
2024-08-13 16:39:24 +02:00
onProgress?: FFmpegProgressCallback;
2024-08-12 18:28:38 +02:00
2024-08-13 16:39:24 +02:00
constructor(onProgress?: FFmpegProgressCallback) {
this.libav = null;
2024-08-31 19:46:10 +02:00
this.concurrency = Math.min(4, browser ? navigator.hardwareConcurrency : 0);
2024-08-13 16:39:24 +02:00
this.onProgress = onProgress;
2024-08-12 18:28:38 +02:00
}
async init() {
2024-08-31 19:46:10 +02:00
if (this.concurrency && !this.libav) {
this.libav = LibAV.LibAV({
2024-08-12 19:06:45 +02:00
yesthreads: true,
base: '/_libav'
});
2024-08-12 18:28:38 +02:00
}
}
2024-08-13 17:33:30 +02:00
async probe(blob: Blob) {
if (!this.libav) throw new Error("LibAV wasn't initialized");
const libav = await this.libav;
await libav.mkreadaheadfile('input', blob);
await libav.ffprobe([
'-v', 'quiet',
'-print_format', 'json',
'-show_format',
'-show_streams',
'input',
2024-09-07 13:41:56 +02:00
'-o', 'output.json'
2024-08-13 17:33:30 +02:00
]);
await libav.unlinkreadaheadfile('input');
2024-09-07 13:41:56 +02:00
const copy = await libav.readFile('output.json');
2024-08-13 17:33:30 +02:00
const text = new TextDecoder().decode(copy);
2024-09-07 13:41:56 +02:00
await libav.unlink('output.json');
2024-08-13 17:33:30 +02:00
return JSON.parse(text) as FfprobeData;
}
static getExtensionFromType(blob: Blob) {
const extensions = mime.getAllExtensions(blob.type);
const overrides = ['mp3', 'mov'];
if (!extensions)
return;
for (const override of overrides)
if (extensions?.has(override))
return override;
return [...extensions][0];
}
2024-08-12 19:06:45 +02:00
async render({ blob, output, args }: RenderParams) {
2024-08-12 18:28:38 +02:00
if (!this.libav) throw new Error("LibAV wasn't initialized");
const libav = await this.libav;
2024-08-12 19:06:45 +02:00
const inputKind = blob.type.split("/")[0];
const inputExtension = LibAVWrapper.getExtensionFromType(blob);
2024-08-12 18:28:38 +02:00
if (inputKind !== "video" && inputKind !== "audio") return;
if (!inputExtension) return;
const input: FileInfo = {
kind: inputKind,
extension: inputExtension,
}
if (!output) output = input;
output.type = mime.getType(output.extension);
if (!output.type) return;
const outputName = `output.${output.extension}`;
await libav.mkreadaheadfile("input", blob);
2024-08-12 18:28:38 +02:00
// https://github.com/Yahweasel/libav.js/blob/7d359f69/docs/IO.md#block-writer-devices
await libav.mkwriterdev(outputName);
await libav.mkwriterdev('progress.txt');
// since we expect the output file to be roughly the same size
// as the original, preallocate its size for the output
let writtenData = new Uint8Array(blob.size), actualSize = 0;
2024-08-12 18:28:38 +02:00
libav.onwrite = (name, pos, data) => {
2024-08-13 16:39:24 +02:00
if (name === 'progress.txt') {
try {
return this.#emitProgress(data);
} catch(e) {
console.error(e);
}
2024-08-13 16:39:24 +02:00
} else if (name !== outputName) return;
2024-08-12 19:06:45 +02:00
actualSize = Math.max(pos + data.length, actualSize);
const newLen = Math.max(pos + data.length, writtenData.length);
2024-08-12 18:28:38 +02:00
if (newLen > writtenData.length) {
const newData = new Uint8Array(newLen);
newData.set(writtenData);
writtenData = newData;
}
writtenData.set(data, pos);
};
await libav.ffmpeg([
2024-08-12 19:06:45 +02:00
'-nostdin', '-y',
2024-08-13 16:39:24 +02:00
'-loglevel', 'error',
'-progress', 'progress.txt',
2024-08-12 18:28:38 +02:00
'-threads', this.concurrency.toString(),
'-i', 'input',
...args,
outputName
]);
await libav.unlink(outputName);
await libav.unlink('progress.txt');
await libav.unlinkreadaheadfile("input");
2024-08-12 18:28:38 +02:00
// if we didn't need as much space as we allocated for some reason,
// shrink the buffer so that we don't inflate the file with zeros
if (writtenData.length > actualSize) {
writtenData = writtenData.slice(0, actualSize);
}
2024-08-12 18:28:38 +02:00
const renderBlob = new Blob(
2024-08-12 19:06:45 +02:00
[ writtenData ],
2024-08-12 18:28:38 +02:00
{ type: output.type }
);
if (renderBlob.size === 0) return;
return renderBlob;
}
2024-08-13 16:39:24 +02:00
#emitProgress(data: Uint8Array | Int8Array) {
if (!this.onProgress) return;
2024-08-13 16:39:24 +02:00
const copy = new Uint8Array(data);
const text = new TextDecoder().decode(copy);
const entries = Object.fromEntries(
text.split('\n')
.filter(a => a)
.map(a => a.split('=', ))
);
const status: FFmpegProgressStatus = (() => {
const { progress } = entries;
if (progress === 'continue' || progress === 'end') {
return progress;
}
return "unknown";
})();
2024-08-13 17:34:38 +02:00
const tryNumber = (str: string, transform?: (n: number) => number) => {
2024-08-13 16:39:24 +02:00
if (str) {
const num = Number(str);
if (!isNaN(num)) {
2024-08-13 17:34:38 +02:00
if (transform)
return transform(num);
else
return num;
2024-08-13 16:39:24 +02:00
}
}
}
const progress: FFmpegProgressEvent = {
status,
frame: tryNumber(entries.frame),
fps: tryNumber(entries.fps),
total_size: tryNumber(entries.total_size),
dup_frames: tryNumber(entries.dup_frames),
drop_frames: tryNumber(entries.drop_frames),
speed: tryNumber(entries.speed?.trim()?.replace('x', '')),
2024-08-13 17:34:38 +02:00
out_time_sec: tryNumber(entries.out_time_us, n => Math.floor(n / 1e6))
2024-08-13 16:39:24 +02:00
};
this.onProgress(progress);
}
2024-08-12 19:06:45 +02:00
}