web/libav: move encoding to separate file, add webcodecs bridge
This commit is contained in:
parent
1072cf1e05
commit
b1281ea286
8 changed files with 397 additions and 244 deletions
|
@ -97,6 +97,9 @@ importers:
|
||||||
'@imput/libav.js-remux-cli':
|
'@imput/libav.js-remux-cli':
|
||||||
specifier: ^5.7.6
|
specifier: ^5.7.6
|
||||||
version: 5.7.6
|
version: 5.7.6
|
||||||
|
'@imput/libavjs-webcodecs-polyfill':
|
||||||
|
specifier: ^0.5.1
|
||||||
|
version: 0.5.1
|
||||||
'@imput/version-info':
|
'@imput/version-info':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../packages/version-info
|
version: link:../packages/version-info
|
||||||
|
@ -109,6 +112,9 @@ importers:
|
||||||
libavjs-webcodecs-bridge:
|
libavjs-webcodecs-bridge:
|
||||||
specifier: ^0.1.0
|
specifier: ^0.1.0
|
||||||
version: 0.1.0
|
version: 0.1.0
|
||||||
|
libavjs-webcodecs-polyfill:
|
||||||
|
specifier: link:/home/j/libavjs-webcodecs-polyfill
|
||||||
|
version: link:../../libavjs-webcodecs-polyfill
|
||||||
mime:
|
mime:
|
||||||
specifier: ^4.0.4
|
specifier: ^4.0.4
|
||||||
version: 4.0.4
|
version: 4.0.4
|
||||||
|
@ -541,6 +547,9 @@ packages:
|
||||||
'@imput/libav.js-remux-cli@5.7.6':
|
'@imput/libav.js-remux-cli@5.7.6':
|
||||||
resolution: {integrity: sha512-ofSSLjRF9RfZ3QMBlb7fhxso8p8xlDqU4qX8eCJCukCB15g7iBShthCyGVnYz+3lLoFu9klbvVal9bEncBj/FQ==}
|
resolution: {integrity: sha512-ofSSLjRF9RfZ3QMBlb7fhxso8p8xlDqU4qX8eCJCukCB15g7iBShthCyGVnYz+3lLoFu9klbvVal9bEncBj/FQ==}
|
||||||
|
|
||||||
|
'@imput/libavjs-webcodecs-polyfill@0.5.1':
|
||||||
|
resolution: {integrity: sha512-uQ5vawG/4ppLKDumkg8BjnvqKWzoKXxt7cj0lvTpB9ph1hiuzjNx8wmwMcXbeTVAzRcts+GtCTPpSF9rFosYBg==}
|
||||||
|
|
||||||
'@isaacs/cliui@8.0.2':
|
'@isaacs/cliui@8.0.2':
|
||||||
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
|
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
@ -795,6 +804,9 @@ packages:
|
||||||
resolution: {integrity: sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==}
|
resolution: {integrity: sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==}
|
||||||
engines: {node: ^18.18.0 || >=20.0.0}
|
engines: {node: ^18.18.0 || >=20.0.0}
|
||||||
|
|
||||||
|
'@ungap/global-this@0.4.4':
|
||||||
|
resolution: {integrity: sha512-mHkm6FvepJECMNthFuIgpAEFmPOk71UyXuIxYfjytvFTnSDBIz7jmViO+LfHI/AjrazWije0PnSP3+/NlwzqtA==}
|
||||||
|
|
||||||
'@ungap/structured-clone@1.2.0':
|
'@ungap/structured-clone@1.2.0':
|
||||||
resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
|
resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
|
||||||
|
|
||||||
|
@ -2513,6 +2525,10 @@ snapshots:
|
||||||
|
|
||||||
'@imput/libav.js-remux-cli@5.7.6': {}
|
'@imput/libav.js-remux-cli@5.7.6': {}
|
||||||
|
|
||||||
|
'@imput/libavjs-webcodecs-polyfill@0.5.1':
|
||||||
|
dependencies:
|
||||||
|
'@ungap/global-this': 0.4.4
|
||||||
|
|
||||||
'@isaacs/cliui@8.0.2':
|
'@isaacs/cliui@8.0.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
string-width: 5.1.2
|
string-width: 5.1.2
|
||||||
|
@ -2772,6 +2788,8 @@ snapshots:
|
||||||
'@typescript-eslint/types': 7.18.0
|
'@typescript-eslint/types': 7.18.0
|
||||||
eslint-visitor-keys: 3.4.3
|
eslint-visitor-keys: 3.4.3
|
||||||
|
|
||||||
|
'@ungap/global-this@0.4.4': {}
|
||||||
|
|
||||||
'@ungap/structured-clone@1.2.0': {}
|
'@ungap/structured-clone@1.2.0': {}
|
||||||
|
|
||||||
'@vitejs/plugin-basic-ssl@1.1.0(vite@5.3.5(@types/node@20.14.14))':
|
'@vitejs/plugin-basic-ssl@1.1.0(vite@5.3.5(@types/node@20.14.14))':
|
||||||
|
|
|
@ -51,6 +51,7 @@
|
||||||
"@fontsource/ibm-plex-mono": "^5.0.13",
|
"@fontsource/ibm-plex-mono": "^5.0.13",
|
||||||
"@imput/libav.js-encode-cli": "^5.7.6",
|
"@imput/libav.js-encode-cli": "^5.7.6",
|
||||||
"@imput/libav.js-remux-cli": "^5.7.6",
|
"@imput/libav.js-remux-cli": "^5.7.6",
|
||||||
|
"@imput/libavjs-webcodecs-polyfill": "^0.5.1",
|
||||||
"@imput/version-info": "workspace:^",
|
"@imput/version-info": "workspace:^",
|
||||||
"@tabler/icons-svelte": "3.6.0",
|
"@tabler/icons-svelte": "3.6.0",
|
||||||
"@vitejs/plugin-basic-ssl": "^1.1.0",
|
"@vitejs/plugin-basic-ssl": "^1.1.0",
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
import mime from "mime";
|
|
||||||
import LibAV, { type LibAV as LibAVInstance, type Packet, type Stream } from "@imput/libav.js-encode-cli";
|
import LibAV, { type LibAV as LibAVInstance, type Packet, type Stream } from "@imput/libav.js-encode-cli";
|
||||||
import type { Chunk, ChunkMetadata, Decoder, FFmpegProgressCallback, FFmpegProgressEvent, FFmpegProgressStatus, FileInfo, OutputStream, RenderingPipeline, RenderParams } from "../types/libav";
|
import type { Chunk, ChunkMetadata, Decoder, FFmpegProgressCallback, OutputStream, Pipeline, RenderingPipeline } from "../types/libav";
|
||||||
import type { FfprobeData } from "fluent-ffmpeg";
|
|
||||||
import * as LibAVWebCodecs from "libavjs-webcodecs-bridge";
|
import * as LibAVWebCodecs from "libavjs-webcodecs-bridge";
|
||||||
import { BufferStream } from "./buffer-stream";
|
import { BufferStream } from "./buffer-stream";
|
||||||
import { BufferStream } from "../buffer-stream";
|
import { BufferStream } from "../buffer-stream";
|
||||||
|
@ -31,7 +29,7 @@ export default class LibAVWrapper {
|
||||||
base: '/_libav'
|
base: '/_libav'
|
||||||
});
|
});
|
||||||
|
|
||||||
this.webcodecs = new WebCodecsWrapper(await this.libav);
|
this.webcodecs = new WebCodecsWrapper(this.libav);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -44,166 +42,18 @@ export default class LibAVWrapper {
|
||||||
|
|
||||||
async #get() {
|
async #get() {
|
||||||
if (!this.libav) throw new Error("LibAV wasn't initialized");
|
if (!this.libav) throw new Error("LibAV wasn't initialized");
|
||||||
if (!this.webcodecs) throw new Error("unreachable");
|
const libav = await this.libav;
|
||||||
|
|
||||||
|
if (!this.webcodecs) {
|
||||||
|
throw new Error("unreachable");
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
libav: await this.libav,
|
libav,
|
||||||
webcodecs: this.webcodecs
|
webcodecs: this.webcodecs
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async probe(blob: Blob) {
|
|
||||||
const { libav } = await this.#get();
|
|
||||||
|
|
||||||
await libav.mkreadaheadfile('input', blob);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await libav.ffprobe([
|
|
||||||
'-v', 'quiet',
|
|
||||||
'-print_format', 'json',
|
|
||||||
'-show_format',
|
|
||||||
'-show_streams',
|
|
||||||
'input',
|
|
||||||
'-o', 'output.json'
|
|
||||||
]);
|
|
||||||
|
|
||||||
const copy = await libav.readFile('output.json');
|
|
||||||
const text = new TextDecoder().decode(copy);
|
|
||||||
await libav.unlink('output.json');
|
|
||||||
|
|
||||||
return JSON.parse(text) as FfprobeData;
|
|
||||||
} finally {
|
|
||||||
await libav.unlinkreadaheadfile('input');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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];
|
|
||||||
}
|
|
||||||
|
|
||||||
async remux({ blob, output, args }: RenderParams) {
|
|
||||||
const { libav } = await this.#get();
|
|
||||||
|
|
||||||
const inputKind = blob.type.split("/")[0];
|
|
||||||
const inputExtension = LibAVWrapper.getExtensionFromType(blob);
|
|
||||||
|
|
||||||
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}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await libav.mkreadaheadfile("input", blob);
|
|
||||||
|
|
||||||
// https://github.com/Yahweasel/libav.js/blob/7d359f69/docs/IO.md#block-writer-devices
|
|
||||||
await libav.mkwriterdev(outputName);
|
|
||||||
await libav.mkwriterdev('progress.txt');
|
|
||||||
|
|
||||||
const MB = 1024 * 1024;
|
|
||||||
const chunks: Uint8Array[] = [];
|
|
||||||
const chunkSize = Math.min(512 * MB, blob.size);
|
|
||||||
|
|
||||||
// since we expect the output file to be roughly the same size
|
|
||||||
// as the original, preallocate its size for the output
|
|
||||||
for (let toAllocate = blob.size; toAllocate > 0; toAllocate -= chunkSize) {
|
|
||||||
chunks.push(new Uint8Array(chunkSize));
|
|
||||||
}
|
|
||||||
|
|
||||||
let actualSize = 0;
|
|
||||||
libav.onwrite = (name, pos, data) => {
|
|
||||||
if (name === 'progress.txt') {
|
|
||||||
try {
|
|
||||||
return this.#emitProgress(data);
|
|
||||||
} catch(e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
} else if (name !== outputName) return;
|
|
||||||
|
|
||||||
const writeEnd = pos + data.length;
|
|
||||||
if (writeEnd > chunkSize * chunks.length) {
|
|
||||||
chunks.push(new Uint8Array(chunkSize));
|
|
||||||
}
|
|
||||||
|
|
||||||
const chunkIndex = pos / chunkSize | 0;
|
|
||||||
const offset = pos - (chunkSize * chunkIndex);
|
|
||||||
|
|
||||||
if (offset + data.length > chunkSize) {
|
|
||||||
chunks[chunkIndex].set(
|
|
||||||
data.subarray(0, chunkSize - offset), offset
|
|
||||||
);
|
|
||||||
chunks[chunkIndex + 1].set(
|
|
||||||
data.subarray(chunkSize - offset), 0
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
chunks[chunkIndex].set(data, offset);
|
|
||||||
}
|
|
||||||
|
|
||||||
actualSize = Math.max(writeEnd, actualSize);
|
|
||||||
};
|
|
||||||
|
|
||||||
await libav.ffmpeg([
|
|
||||||
'-nostdin', '-y',
|
|
||||||
'-loglevel', 'error',
|
|
||||||
'-progress', 'progress.txt',
|
|
||||||
'-threads', this.concurrency.toString(),
|
|
||||||
'-i', 'input',
|
|
||||||
...args,
|
|
||||||
outputName
|
|
||||||
]);
|
|
||||||
|
|
||||||
// if we didn't need as much space as we allocated for some reason,
|
|
||||||
// shrink the buffers so that we don't inflate the file with zeroes
|
|
||||||
const outputView: Uint8Array[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < chunks.length; ++i) {
|
|
||||||
outputView.push(
|
|
||||||
chunks[i].subarray(
|
|
||||||
0, Math.min(chunkSize, actualSize)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
actualSize -= chunkSize;
|
|
||||||
if (actualSize <= 0) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderBlob = new Blob(
|
|
||||||
outputView,
|
|
||||||
{ type: output.type }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (renderBlob.size === 0) return;
|
|
||||||
return renderBlob;
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
await libav.unlink(outputName);
|
|
||||||
await libav.unlink('progress.txt');
|
|
||||||
await libav.unlinkreadaheadfile("input");
|
|
||||||
} catch { /* catch & ignore */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async transcode(blob: Blob) {
|
async transcode(blob: Blob) {
|
||||||
const { libav } = await this.#get();
|
const { libav } = await this.#get();
|
||||||
let fmtctx;
|
let fmtctx;
|
||||||
|
@ -216,6 +66,12 @@ export default class LibAVWrapper {
|
||||||
const pipes: RenderingPipeline[] = [];
|
const pipes: RenderingPipeline[] = [];
|
||||||
const output_streams: OutputStream[] = [];
|
const output_streams: OutputStream[] = [];
|
||||||
for (const stream of streams) {
|
for (const stream of streams) {
|
||||||
|
if (stream.codec_id === 61) {
|
||||||
|
pipes.push(null);
|
||||||
|
output_streams.push(null);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
pipe,
|
pipe,
|
||||||
stream: ostream
|
stream: ostream
|
||||||
|
@ -246,6 +102,8 @@ export default class LibAVWrapper {
|
||||||
|
|
||||||
async #decodeStreams(fmt_ctx: number, pipes: RenderingPipeline[], streams: Stream[]) {
|
async #decodeStreams(fmt_ctx: number, pipes: RenderingPipeline[], streams: Stream[]) {
|
||||||
for await (const { index, packet } of this.#demux(fmt_ctx)) {
|
for await (const { index, packet } of this.#demux(fmt_ctx)) {
|
||||||
|
if (pipes[index] === null) continue;
|
||||||
|
|
||||||
const { decoder } = pipes[index];
|
const { decoder } = pipes[index];
|
||||||
|
|
||||||
this.#decodePacket(decoder.instance, packet, streams[index]);
|
this.#decodePacket(decoder.instance, packet, streams[index]);
|
||||||
|
@ -264,16 +122,18 @@ export default class LibAVWrapper {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const { decoder } of pipes) {
|
for (const pipe of pipes) {
|
||||||
await decoder.instance.flush();
|
if (pipe !== null) {
|
||||||
decoder.instance.close();
|
await pipe.decoder.instance.flush();
|
||||||
decoder.output.push(null);
|
pipe.decoder.instance.close();
|
||||||
|
pipe.decoder.output.push(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async #encodeStream(
|
async #encodeStream(
|
||||||
frames: RenderingPipeline['decoder']['output'],
|
frames: Pipeline['decoder']['output'],
|
||||||
{ instance: encoder, output }: RenderingPipeline['encoder']
|
{ instance: encoder, output }: Pipeline['encoder']
|
||||||
) {
|
) {
|
||||||
const reader = frames.getReader();
|
const reader = frames.getReader();
|
||||||
|
|
||||||
|
@ -312,7 +172,9 @@ export default class LibAVWrapper {
|
||||||
|
|
||||||
async #encodeStreams(pipes: RenderingPipeline[]) {
|
async #encodeStreams(pipes: RenderingPipeline[]) {
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
pipes.map(
|
pipes
|
||||||
|
.filter(p => p !== null)
|
||||||
|
.map(
|
||||||
({ decoder, encoder }) => {
|
({ decoder, encoder }) => {
|
||||||
return this.#encodeStream(decoder.output, encoder);
|
return this.#encodeStream(decoder.output, encoder);
|
||||||
}
|
}
|
||||||
|
@ -321,6 +183,7 @@ export default class LibAVWrapper {
|
||||||
}
|
}
|
||||||
|
|
||||||
async #processChunk({ chunk, metadata }: { chunk: Chunk, metadata: ChunkMetadata }, ostream: OutputStream, index: number) {
|
async #processChunk({ chunk, metadata }: { chunk: Chunk, metadata: ChunkMetadata }, ostream: OutputStream, index: number) {
|
||||||
|
if (ostream === null) return;
|
||||||
const { libav } = await this.#get();
|
const { libav } = await this.#get();
|
||||||
|
|
||||||
let convertToPacket;
|
let convertToPacket;
|
||||||
|
@ -343,7 +206,8 @@ export default class LibAVWrapper {
|
||||||
const starterPackets = [], readers: ReadableStreamDefaultReader[] = [];
|
const starterPackets = [], readers: ReadableStreamDefaultReader[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < ostreams.length; ++i) {
|
for (let i = 0; i < ostreams.length; ++i) {
|
||||||
readers[i] = pipes[i].encoder.output.getReader();
|
if (pipes[i] === null) continue;
|
||||||
|
readers[i] = pipes[i]!.encoder.output.getReader();
|
||||||
|
|
||||||
const { done, value } = await readers[i].read();
|
const { done, value } = await readers[i].read();
|
||||||
if (done) throw "this should not happen";
|
if (done) throw "this should not happen";
|
||||||
|
@ -373,7 +237,7 @@ export default class LibAVWrapper {
|
||||||
device: true,
|
device: true,
|
||||||
open: true,
|
open: true,
|
||||||
codecpars: true
|
codecpars: true
|
||||||
}, ostreams
|
}, ostreams.filter(a => a !== null)
|
||||||
);
|
);
|
||||||
|
|
||||||
await libav.avformat_write_header(output_ctx, 0);
|
await libav.avformat_write_header(output_ctx, 0);
|
||||||
|
@ -453,18 +317,18 @@ export default class LibAVWrapper {
|
||||||
}
|
}
|
||||||
|
|
||||||
async #createEncoder(stream: Stream, codec: string) {
|
async #createEncoder(stream: Stream, codec: string) {
|
||||||
const { libav } = await this.#get();
|
const { libav, webcodecs } = await this.#get();
|
||||||
|
|
||||||
let streamToConfig, configToStream, Encoder;
|
let streamToConfig, configToStream, initEncoder;
|
||||||
|
|
||||||
if (stream.codec_type === libav.AVMEDIA_TYPE_VIDEO) {
|
if (stream.codec_type === libav.AVMEDIA_TYPE_VIDEO) {
|
||||||
streamToConfig = LibAVWebCodecs.videoStreamToConfig;
|
streamToConfig = LibAVWebCodecs.videoStreamToConfig;
|
||||||
configToStream = LibAVWebCodecs.configToVideoStream;
|
configToStream = LibAVWebCodecs.configToVideoStream;
|
||||||
Encoder = VideoEncoder;
|
initEncoder = webcodecs!.initVideoEncoder.bind(webcodecs);
|
||||||
} else if (stream.codec_type === libav.AVMEDIA_TYPE_AUDIO) {
|
} else if (stream.codec_type === libav.AVMEDIA_TYPE_AUDIO) {
|
||||||
streamToConfig = LibAVWebCodecs.audioStreamToConfig;
|
streamToConfig = LibAVWebCodecs.audioStreamToConfig;
|
||||||
configToStream = LibAVWebCodecs.configToAudioStream;
|
configToStream = LibAVWebCodecs.configToAudioStream;
|
||||||
Encoder = AudioEncoder;
|
initEncoder = webcodecs.initAudioEncoder.bind(webcodecs);
|
||||||
codec = 'mp4a.40.29';
|
codec = 'mp4a.40.29';
|
||||||
} else throw "Unknown type: " + stream.codec_type;
|
} else throw "Unknown type: " + stream.codec_type;
|
||||||
|
|
||||||
|
@ -481,48 +345,41 @@ export default class LibAVWrapper {
|
||||||
sampleRate: config.sampleRate
|
sampleRate: config.sampleRate
|
||||||
};
|
};
|
||||||
|
|
||||||
let { supported } = await Encoder.isConfigSupported(encoderConfig);
|
|
||||||
if (!supported) {
|
|
||||||
throw "cannot encode " + codec;
|
|
||||||
}
|
|
||||||
|
|
||||||
const output = new BufferStream<
|
const output = new BufferStream<
|
||||||
{ chunk: Chunk, metadata: ChunkMetadata }
|
{ chunk: Chunk, metadata: ChunkMetadata }
|
||||||
>();
|
>();
|
||||||
|
|
||||||
const encoder = new Encoder({
|
const encoder = await initEncoder(encoderConfig, {
|
||||||
output: (chunk, metadata = {}) => {
|
output: (chunk, metadata = {}) => {
|
||||||
output.push({ chunk, metadata })
|
output.push({ chunk, metadata })
|
||||||
},
|
},
|
||||||
error: console.error
|
error: console.error
|
||||||
});
|
});
|
||||||
|
|
||||||
encoder.configure(encoderConfig);
|
if (!encoder) {
|
||||||
|
throw "cannot encode " + codec;
|
||||||
|
}
|
||||||
|
|
||||||
const c2s = await configToStream(libav, encoderConfig);
|
const encoderStream = await configToStream(libav, encoderConfig);
|
||||||
|
|
||||||
// FIXME: figure out a proper way to handle timescale
|
|
||||||
// (preferrably without killing self)
|
|
||||||
c2s[1] = 1;
|
|
||||||
c2s[2] = 60000;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pipe: { instance: encoder, output },
|
pipe: { instance: encoder, output },
|
||||||
stream: c2s
|
stream: encoderStream
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async #createDecoder(stream: Stream) {
|
async #createDecoder(stream: Stream) {
|
||||||
const { libav } = await this.#get();
|
const { libav, webcodecs } = await this.#get();
|
||||||
|
|
||||||
let streamToConfig, initDecoder;
|
let streamToConfig, initDecoder;
|
||||||
|
|
||||||
if (stream.codec_type === libav.AVMEDIA_TYPE_VIDEO) {
|
if (stream.codec_type === libav.AVMEDIA_TYPE_VIDEO) {
|
||||||
streamToConfig = LibAVWebCodecs.videoStreamToConfig;
|
streamToConfig = LibAVWebCodecs.videoStreamToConfig;
|
||||||
initDecoder = this.webcodecs.initVideoDecoder;
|
initDecoder = webcodecs.initVideoDecoder.bind(webcodecs);
|
||||||
} else if (stream.codec_type === libav.AVMEDIA_TYPE_AUDIO) {
|
} else if (stream.codec_type === libav.AVMEDIA_TYPE_AUDIO) {
|
||||||
streamToConfig = LibAVWebCodecs.audioStreamToConfig;
|
streamToConfig = LibAVWebCodecs.audioStreamToConfig;
|
||||||
initDecoder = this.webcodecs.initAudioDecoder;
|
initDecoder = webcodecs.initAudioDecoder.bind(webcodecs);
|
||||||
} else throw "Unknown type: " + stream.codec_type;
|
} else throw "Unknown type: " + stream.codec_type;
|
||||||
|
|
||||||
const config = await streamToConfig(libav, stream);
|
const config = await streamToConfig(libav, stream);
|
||||||
|
@ -531,65 +388,16 @@ export default class LibAVWrapper {
|
||||||
throw "could not make decoder config";
|
throw "could not make decoder config";
|
||||||
}
|
}
|
||||||
|
|
||||||
let { supported } = await Decoder.isConfigSupported(config);
|
|
||||||
if (!supported) {
|
|
||||||
throw "cannot decode " + config.codec;
|
|
||||||
}
|
|
||||||
|
|
||||||
const output = new BufferStream<VideoFrame | AudioData>();
|
const output = new BufferStream<VideoFrame | AudioData>();
|
||||||
const decoder = new Decoder({
|
const decoder = await initDecoder(config, {
|
||||||
output: frame => output.push(frame),
|
output: frame => output.push(frame),
|
||||||
error: console.error
|
error: console.error
|
||||||
});
|
});
|
||||||
|
|
||||||
decoder.configure(config);
|
if (!decoder) {
|
||||||
return { instance: decoder, output }
|
throw "cannot decode " + config.codec;
|
||||||
}
|
|
||||||
|
|
||||||
#emitProgress(data: Uint8Array | Int8Array) {
|
|
||||||
if (!this.onProgress) return;
|
|
||||||
|
|
||||||
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";
|
|
||||||
})();
|
|
||||||
|
|
||||||
const tryNumber = (str: string, transform?: (n: number) => number) => {
|
|
||||||
if (str) {
|
|
||||||
const num = Number(str);
|
|
||||||
if (!isNaN(num)) {
|
|
||||||
if (transform)
|
|
||||||
return transform(num);
|
|
||||||
else
|
|
||||||
return num;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const progress: FFmpegProgressEvent = {
|
return { instance: decoder, output }
|
||||||
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', '')),
|
|
||||||
out_time_sec: tryNumber(entries.out_time_us, n => Math.floor(n / 1e6))
|
|
||||||
};
|
|
||||||
|
|
||||||
this.onProgress(progress);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
211
web/src/lib/libav/remux.ts
Normal file
211
web/src/lib/libav/remux.ts
Normal file
|
@ -0,0 +1,211 @@
|
||||||
|
import mime from "mime";
|
||||||
|
import LibAV, { type LibAV as LibAVInstance } from "@imput/libav.js-remux-cli";
|
||||||
|
import type { FFmpegProgressCallback, FFmpegProgressEvent, FFmpegProgressStatus, FileInfo, RenderParams } from "../types/libav";
|
||||||
|
import type { FfprobeData } from "fluent-ffmpeg";
|
||||||
|
|
||||||
|
export default class LibAVWrapper {
|
||||||
|
libav: Promise<LibAVInstance> | null;
|
||||||
|
concurrency: number;
|
||||||
|
onProgress?: FFmpegProgressCallback;
|
||||||
|
|
||||||
|
constructor(onProgress?: FFmpegProgressCallback) {
|
||||||
|
this.libav = null;
|
||||||
|
this.concurrency = Math.min(4, navigator.hardwareConcurrency);
|
||||||
|
this.onProgress = onProgress;
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
if (!this.libav) {
|
||||||
|
this.libav = LibAV.LibAV({
|
||||||
|
yesthreads: true,
|
||||||
|
base: '/_libav'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #get() {
|
||||||
|
if (!this.libav) throw new Error("LibAV wasn't initialized");
|
||||||
|
|
||||||
|
return {
|
||||||
|
libav: await this.libav
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async probe(blob: Blob) {
|
||||||
|
const { libav } = await this.#get();
|
||||||
|
|
||||||
|
const OUT_FILE = 'output.json';
|
||||||
|
await libav.mkreadaheadfile('input', blob);
|
||||||
|
await libav.mkwriterdev(OUT_FILE);
|
||||||
|
|
||||||
|
let writtenData = new Uint8Array(0);
|
||||||
|
|
||||||
|
libav.onwrite = (name, pos, data) => {
|
||||||
|
if (name !== OUT_FILE) return;
|
||||||
|
|
||||||
|
const newLen = Math.max(pos + data.length, writtenData.length);
|
||||||
|
if (newLen > writtenData.length) {
|
||||||
|
const newData = new Uint8Array(newLen);
|
||||||
|
newData.set(writtenData);
|
||||||
|
writtenData = newData;
|
||||||
|
}
|
||||||
|
writtenData.set(data, pos);
|
||||||
|
};
|
||||||
|
|
||||||
|
await libav.ffprobe([
|
||||||
|
'-v', 'quiet',
|
||||||
|
'-print_format', 'json',
|
||||||
|
'-show_format',
|
||||||
|
'-show_streams',
|
||||||
|
'input',
|
||||||
|
'-o', OUT_FILE
|
||||||
|
]);
|
||||||
|
|
||||||
|
await libav.unlink(OUT_FILE);
|
||||||
|
await libav.unlinkreadaheadfile('input');
|
||||||
|
|
||||||
|
const copy = new Uint8Array(writtenData);
|
||||||
|
const text = new TextDecoder().decode(copy);
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
|
async remux({ blob, output, args }: RenderParams) {
|
||||||
|
const { libav } = await this.#get();
|
||||||
|
|
||||||
|
const inputKind = blob.type.split("/")[0];
|
||||||
|
const inputExtension = LibAVWrapper.getExtensionFromType(blob);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
libav.onwrite = (name, pos, data) => {
|
||||||
|
if (name === 'progress.txt') {
|
||||||
|
try {
|
||||||
|
return this.#emitProgress(data);
|
||||||
|
} catch(e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
} else if (name !== outputName) return;
|
||||||
|
|
||||||
|
actualSize = Math.max(pos + data.length, actualSize);
|
||||||
|
const newLen = Math.max(pos + data.length, writtenData.length);
|
||||||
|
if (newLen > writtenData.length) {
|
||||||
|
const newData = new Uint8Array(newLen);
|
||||||
|
newData.set(writtenData);
|
||||||
|
writtenData = newData;
|
||||||
|
}
|
||||||
|
writtenData.set(data, pos);
|
||||||
|
};
|
||||||
|
|
||||||
|
await libav.ffmpeg([
|
||||||
|
'-nostdin', '-y',
|
||||||
|
'-loglevel', 'error',
|
||||||
|
'-progress', 'progress.txt',
|
||||||
|
'-threads', this.concurrency.toString(),
|
||||||
|
'-i', 'input',
|
||||||
|
...args,
|
||||||
|
outputName
|
||||||
|
]);
|
||||||
|
|
||||||
|
await libav.unlink(outputName);
|
||||||
|
await libav.unlink('progress.txt');
|
||||||
|
await libav.unlinkreadaheadfile("input");
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderBlob = new Blob(
|
||||||
|
[ writtenData ],
|
||||||
|
{ type: output.type }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (renderBlob.size === 0) return;
|
||||||
|
return renderBlob;
|
||||||
|
}
|
||||||
|
|
||||||
|
#emitProgress(data: Uint8Array | Int8Array) {
|
||||||
|
if (!this.onProgress) return;
|
||||||
|
|
||||||
|
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";
|
||||||
|
})();
|
||||||
|
|
||||||
|
const tryNumber = (str: string, transform?: (n: number) => number) => {
|
||||||
|
if (str) {
|
||||||
|
const num = Number(str);
|
||||||
|
if (!isNaN(num)) {
|
||||||
|
if (transform)
|
||||||
|
return transform(num);
|
||||||
|
else
|
||||||
|
return num;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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', '')),
|
||||||
|
out_time_sec: tryNumber(entries.out_time_us, n => Math.floor(n / 1e6))
|
||||||
|
};
|
||||||
|
|
||||||
|
this.onProgress(progress);
|
||||||
|
}
|
||||||
|
}
|
113
web/src/lib/libav/webcodecs.ts
Normal file
113
web/src/lib/libav/webcodecs.ts
Normal file
|
@ -0,0 +1,113 @@
|
||||||
|
import type { LibAV } from "@imput/libav.js-encode-cli";
|
||||||
|
import * as LibAVPolyfill from "@imput/libavjs-webcodecs-polyfill";
|
||||||
|
|
||||||
|
const has = <T extends object>(obj: T, key: string) => {
|
||||||
|
return key in obj && typeof (obj as Record<string, unknown>)[key] !== 'undefined';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class WebCodecsWrapper {
|
||||||
|
#libav: Promise<LibAV>;
|
||||||
|
#ready: Promise<void> | undefined;
|
||||||
|
|
||||||
|
constructor(libav: Promise<LibAV>) {
|
||||||
|
this.#libav = libav;
|
||||||
|
}
|
||||||
|
|
||||||
|
async #load() {
|
||||||
|
if (typeof this.#ready === 'undefined') {
|
||||||
|
this.#ready = LibAVPolyfill.load({
|
||||||
|
polyfill: false,
|
||||||
|
LibAV: await this.#libav
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.#ready;
|
||||||
|
}
|
||||||
|
async #getDecoder(config: VideoDecoderConfig | AudioDecoderConfig) {
|
||||||
|
if (has(config, 'numberOfChannels') && has(config, 'sampleRate')) {
|
||||||
|
const audioConfig = config as AudioDecoderConfig;
|
||||||
|
|
||||||
|
if ('AudioDecoder' in window && await window.AudioDecoder.isConfigSupported(audioConfig))
|
||||||
|
return window.AudioDecoder;
|
||||||
|
|
||||||
|
await this.#load();
|
||||||
|
if (await LibAVPolyfill.AudioDecoder.isConfigSupported(audioConfig))
|
||||||
|
return LibAVPolyfill.AudioDecoder;
|
||||||
|
} else {
|
||||||
|
const videoConfig = config as VideoDecoderConfig;
|
||||||
|
if ('VideoDecoder' in window && await window.VideoDecoder.isConfigSupported(videoConfig))
|
||||||
|
return window.VideoDecoder;
|
||||||
|
|
||||||
|
await this.#load();
|
||||||
|
if (await LibAVPolyfill.VideoDecoder.isConfigSupported(videoConfig))
|
||||||
|
return LibAVPolyfill.VideoDecoder;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async #getEncoder(config: VideoEncoderConfig | AudioEncoderConfig) {
|
||||||
|
if (has(config, 'numberOfChannels') && has(config, 'sampleRate')) {
|
||||||
|
const audioConfig = config as AudioEncoderConfig;
|
||||||
|
if ('AudioEncoder' in window && await window.AudioEncoder.isConfigSupported(audioConfig))
|
||||||
|
return window.AudioEncoder;
|
||||||
|
|
||||||
|
await this.#load();
|
||||||
|
if (await LibAVPolyfill.AudioEncoder.isConfigSupported(audioConfig))
|
||||||
|
return LibAVPolyfill.AudioEncoder;
|
||||||
|
} else if (has(config, 'width') && has(config, 'height')) {
|
||||||
|
const videoConfig = config as VideoEncoderConfig;
|
||||||
|
if ('VideoEncoder' in window && await window.VideoEncoder.isConfigSupported(videoConfig))
|
||||||
|
return window.VideoEncoder;
|
||||||
|
|
||||||
|
await this.#load();
|
||||||
|
if (await LibAVPolyfill.VideoEncoder.isConfigSupported(videoConfig))
|
||||||
|
return LibAVPolyfill.VideoEncoder;
|
||||||
|
} else throw new Error("unreachable");
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME: this is nasty, but whatever
|
||||||
|
async initAudioEncoder(config: AudioEncoderConfig, init: AudioEncoderInit) {
|
||||||
|
console.log(this);
|
||||||
|
const Encoder = await this.#getEncoder(config) as typeof AudioEncoder | null;
|
||||||
|
if (Encoder === null) return null;
|
||||||
|
|
||||||
|
const encoder = new Encoder(init);
|
||||||
|
encoder.configure(config);
|
||||||
|
|
||||||
|
return encoder;
|
||||||
|
}
|
||||||
|
|
||||||
|
async initAudioDecoder(config: AudioDecoderConfig, init: AudioDecoderInit) {
|
||||||
|
const Decoder = await this.#getDecoder(config) as typeof AudioDecoder | null;
|
||||||
|
if (Decoder === null) return null;
|
||||||
|
|
||||||
|
const decoder = new Decoder(init);
|
||||||
|
decoder.configure(config);
|
||||||
|
|
||||||
|
return decoder;
|
||||||
|
}
|
||||||
|
|
||||||
|
async initVideoEncoder(config: VideoEncoderConfig, init: VideoEncoderInit) {
|
||||||
|
const Encoder = await this.#getEncoder(config) as typeof VideoEncoder | null;
|
||||||
|
if (Encoder === null) return null;
|
||||||
|
|
||||||
|
const encoder = new Encoder(init);
|
||||||
|
encoder.configure(config);
|
||||||
|
|
||||||
|
return encoder;
|
||||||
|
}
|
||||||
|
|
||||||
|
async initVideoDecoder(config: VideoDecoderConfig, init: VideoDecoderInit) {
|
||||||
|
const Decoder = await this.#getDecoder(config) as typeof VideoDecoder | null;
|
||||||
|
if (Decoder === null) return null;
|
||||||
|
|
||||||
|
const decoder = new Decoder(init);
|
||||||
|
decoder.configure(config);
|
||||||
|
|
||||||
|
return decoder;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -62,5 +62,6 @@ export type VideoPipeline = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RenderingPipeline = AudioPipeline | VideoPipeline;
|
export type Pipeline = AudioPipeline | VideoPipeline;
|
||||||
export type OutputStream = [number, number, number];
|
export type RenderingPipeline = Pipeline | null;
|
||||||
|
export type OutputStream = [number, number, number] | null;
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import LibAVWrapper from "$lib/libav";
|
import LibAVWrapper from "$lib/libav/encode";
|
||||||
import { t } from "$lib/i18n/translations";
|
import { t } from "$lib/i18n/translations";
|
||||||
|
|
||||||
import DropReceiver from "$components/misc/DropReceiver.svelte";
|
import DropReceiver from "$components/misc/DropReceiver.svelte";
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import mime from "mime";
|
import mime from "mime";
|
||||||
import LibAVWrapper from "$lib/libav";
|
import LibAVWrapper from "$lib/libav/remux";
|
||||||
|
|
||||||
import { beforeNavigate, goto } from "$app/navigation";
|
import { beforeNavigate, goto } from "$app/navigation";
|
||||||
|
|
||||||
import { t } from "$lib/i18n/translations";
|
import { t } from "$lib/i18n/translations";
|
||||||
|
|
Loading…
Reference in a new issue