web/encode: refactor, split up into separately callable functions

This commit is contained in:
dumbmoron 2024-08-26 19:22:46 +00:00
parent f36088b48d
commit 64002345b5
No known key found for this signature in database
8 changed files with 415 additions and 111 deletions

View file

@ -112,6 +112,9 @@ importers:
'@vitejs/plugin-basic-ssl':
specifier: ^1.1.0
version: 1.1.0(vite@5.3.5(@types/node@20.14.14))
json-stringify-pretty-compact:
specifier: ^4.0.0
version: 4.0.0
mime:
specifier: ^4.0.4
version: 4.0.4
@ -1533,6 +1536,9 @@ packages:
json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
json-stringify-pretty-compact@4.0.0:
resolution: {integrity: sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==}
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@ -3563,6 +3569,8 @@ snapshots:
json-stable-stringify-without-jsonify@1.0.1: {}
json-stringify-pretty-compact@4.0.0: {}
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1

View file

@ -56,6 +56,7 @@
"@imput/version-info": "workspace:^",
"@tabler/icons-svelte": "3.6.0",
"@vitejs/plugin-basic-ssl": "^1.1.0",
"json-stringify-pretty-compact": "^4.0.0",
"mime": "^4.0.4",
"sveltekit-i18n": "^2.4.2",
"ts-deepmerge": "^7.0.0",

View file

@ -1,5 +1,5 @@
import LibAV, { type Packet, type Stream } from "@imput/libav.js-encode-cli";
import type { Chunk, ChunkMetadata, Decoder, OutputStream, Pipeline, RenderingPipeline } from "../types/libav";
import type { Chunk, ChunkMetadata, Decoder, DecoderPipeline, EncoderPipeline, OutputStream, Pipeline, RenderingPipeline, StreamInfo } from "../types/libav";
import * as LibAVWebCodecs from "@imput/libavjs-webcodecs-bridge";
import { BufferStream } from "../buffer-stream";
import WebCodecsWrapper from "./webcodecs";
@ -8,12 +8,22 @@ import {
EncodedAudioChunk as PolyfilledEncodedAudioChunk,
EncodedVideoChunk as PolyfilledEncodedVideoChunk
} from "@imput/libavjs-webcodecs-polyfill";
import type { AudioEncoderConfig, VideoEncoderConfig } from "@imput/libavjs-webcodecs-polyfill";
import { probeAudio, probeVideo, type ProbeResult } from "./probe";
const QUEUE_THRESHOLD_MIN = 16;
const QUEUE_THRESHOLD_MAX = 128;
export default class EncodeLibAV extends LibAVWrapper {
#webcodecs: WebCodecsWrapper | null = null;
#has_file = false;
#fmt_ctx?: number;
#istreams?: Stream[];
#decoders?: DecoderPipeline[];
#encoders?: EncoderPipeline[];
#ostreams?: OutputStream[];
constructor() {
super(LibAV);
@ -25,6 +35,8 @@ export default class EncodeLibAV extends LibAVWrapper {
this.#webcodecs = new WebCodecsWrapper(
super.get().then(({ libav }) => libav)
);
await this.#webcodecs.load();
}
}
@ -35,53 +47,207 @@ export default class EncodeLibAV extends LibAVWrapper {
};
}
async transcode(blob: Blob) {
async cleanup() {
const { libav } = await this.#get();
if (this.#has_file) {
await libav.unlinkreadaheadfile('input');
this.#has_file = false;
}
if (this.#fmt_ctx) {
await libav.avformat_close_input_js(this.#fmt_ctx);
this.#fmt_ctx = undefined;
this.#istreams = undefined;
}
if (this.#encoders) {
for (const encoder of this.#encoders) {
try {
encoder?.instance.close();
} catch {}
}
this.#encoders = undefined;
}
if (this.#decoders) {
for (const decoder of this.#decoders) {
try {
decoder?.instance.close();
} catch {}
}
this.#decoders = undefined;
}
}
async feed(blob: Blob) {
if (this.#has_file) {
throw "readahead file already exists";
}
const { libav } = await this.#get();
let fmtctx;
await libav.mkreadaheadfile('input', blob);
this.#has_file = true;
try {
const [ fmt_ctx, streams ] = await libav.ff_init_demuxer_file('input');
fmtctx = fmt_ctx;
const pipes: RenderingPipeline[] = [];
const output_streams: OutputStream[] = [];
for (const stream of streams) {
const {
pipe,
stream: ostream
} = await this.#createEncoder(stream, 'mp4a.40.02');
pipes.push({
decoder: await this.#createDecoder(stream),
encoder: pipe
} as RenderingPipeline);
output_streams.push(ostream);
}
await Promise.all([
this.#decodeStreams(fmt_ctx, pipes, streams),
this.#encodeStreams(pipes),
this.#mux(pipes, output_streams)
])
this.#fmt_ctx = fmt_ctx;
this.#istreams = streams;
} catch(e) {
console.error(e);
} finally {
await libav.unlinkreadaheadfile('input');
await this.cleanup();
}
}
if (fmtctx) {
await libav.avformat_close_input_js(fmtctx);
async prep() {
if (!this.#istreams || !this.#fmt_ctx) {
throw "streams are not set up";
} else if (this.#decoders) {
throw "decoders are already set up";
}
this.#decoders = Array(this.#istreams.length).fill(null);
this.#encoders = Array(this.#istreams.length).fill(null);
this.#ostreams = Array(this.#istreams.length).fill(null);
for (const idx in this.#istreams) {
try {
this.#decoders[idx] = await this.#createDecoder(
this.#istreams[idx]
)
} catch(e) {
console.error('could not make decoder', e);
}
}
}
async #decodeStreams(fmt_ctx: number, pipes: RenderingPipeline[], streams: Stream[]) {
for await (const { index, packet } of this.#demux(fmt_ctx)) {
async getStreamInfo(): Promise<StreamInfo[]> {
if (!this.#istreams) {
throw "input not configured";
} else if (!this.#decoders) {
throw "decoders not prepped";
}
const { libav } = await this.#get();
return Promise.all(this.#istreams.map(
async (stream, index) => {
const codec = await libav.avcodec_get_name(stream.codec_id);
let type = 'unsupported';
if (stream.codec_type === libav.AVMEDIA_TYPE_VIDEO) {
type = 'video'
} else if (stream.codec_type === libav.AVMEDIA_TYPE_AUDIO) {
type = 'audio'
}
const decoderConfig: VideoDecoderConfig | AudioDecoderConfig = await this.#streamToConfig(stream);
const config = {
...decoderConfig,
width: 'codedWidth' in decoderConfig ? decoderConfig.codedWidth : undefined,
height: 'codedHeight' in decoderConfig ? decoderConfig.codedHeight : undefined,
};
let output: ProbeResult = {};
if (type === 'video') {
output = await probeVideo(config as globalThis.VideoEncoderConfig);
} else if (type === 'audio') {
output = await probeAudio(config as globalThis.AudioEncoderConfig)
}
return {
codec,
type,
supported: !!this.#decoders?.[index],
output
}
}
));
}
async configureEncoder(index: number, config: AudioEncoderConfig | VideoEncoderConfig | null) {
if (!this.#istreams || !this.#ostreams || !this.#istreams[index])
throw "stream does not exist or streams are not configured"
else if (!this.#encoders)
throw "decoders have not been set up yet";
const { libav, webcodecs } = await this.#get();
const stream = this.#istreams[index];
let configToStream, initEncoder;
if (this.#encoders[index]) {
await this.#encoders[index].instance.flush();
this.#encoders[index].instance.close();
this.#encoders[index] = null;
}
if (config === null) {
return true;
}
if (stream.codec_type === libav.AVMEDIA_TYPE_VIDEO) {
configToStream = LibAVWebCodecs.configToVideoStream;
initEncoder = webcodecs.initVideoEncoder.bind(webcodecs);
} else if (stream.codec_type === libav.AVMEDIA_TYPE_AUDIO) {
configToStream = LibAVWebCodecs.configToAudioStream;
initEncoder = webcodecs.initAudioEncoder.bind(webcodecs);
} else throw "Unknown type: " + stream.codec_type;
const output = new BufferStream<
{ chunk: Chunk, metadata: ChunkMetadata }
>();
const encoder = await initEncoder(config as any /* fixme */, {
output: (chunk, metadata = {}) => {
output.push({ chunk, metadata })
},
error: console.error
});
if (!encoder) {
return false;
}
this.#ostreams[index] = await configToStream(libav, config);
this.#encoders[index] = { instance: encoder, output } as EncoderPipeline;
return true;
}
async work(formatName: string) {
if (!this.#encoders || !this.#decoders)
throw "not configured";
const pipes: RenderingPipeline[] = Array(this.#decoders.length).fill(null);
for (let i = 0; i < this.#encoders.length; ++i) {
if (this.#encoders[i] === null) continue;
if (this.#decoders[i] === null) continue;
pipes[i] = {
encoder: this.#encoders[i],
decoder: this.#decoders[i]
} as RenderingPipeline;
}
const [,, blob] = await Promise.all([
this.#decodeStreams(pipes),
this.#encodeStreams(pipes),
this.#mux(pipes, formatName)
]);
return blob;
}
async #decodeStreams(pipes: RenderingPipeline[]) {
if (!this.#istreams) throw "istreams are not set up";
for await (const { index, packet } of this.#demux()) {
if (pipes[index] === null) continue;
const { decoder } = pipes[index];
this.#decodePacket(decoder.instance, packet, streams[index]);
this.#decodePacket(decoder.instance, packet, this.#istreams[index]);
let currentSize = decoder.instance.decodeQueueSize + decoder.output.queue.size;
if (currentSize >= QUEUE_THRESHOLD_MAX) {
@ -137,7 +303,6 @@ export default class EncodeLibAV extends LibAVWrapper {
encoder as VideoEncoder
);
} else {
console.log(value);
WebCodecsWrapper.encodeAudio(
value as AudioData,
encoder as AudioEncoder
@ -178,7 +343,10 @@ export default class EncodeLibAV extends LibAVWrapper {
return await convertToPacket(libav, chunk, metadata, ostream, index);
}
async #mux(pipes: RenderingPipeline[], ostreams: OutputStream[]) {
async #mux(pipes: RenderingPipeline[], format_name: string) {
if (!this.#ostreams) throw "ostreams not configured";
const ostreams = this.#ostreams;
const { libav } = await this.#get();
const write_pkt = await libav.av_packet_alloc();
@ -211,15 +379,15 @@ export default class EncodeLibAV extends LibAVWrapper {
writtenData.set(data, pos);
};
await libav.mkwriterdev("output.mp4");
await libav.mkwriterdev("output");
[ output_ctx,, writer_ctx ] = await libav.ff_init_muxer(
{
format_name: 'mp4',
filename: 'output.mp4',
format_name,
filename: 'output',
device: true,
open: true,
codecpars: true
}, ostreams.filter(a => a !== null)
}, this.#ostreams.filter(a => a !== null)
);
await libav.avformat_write_header(output_ctx, 0);
@ -245,25 +413,18 @@ export default class EncodeLibAV extends LibAVWrapper {
await writePromise;
await libav.av_write_trailer(output_ctx);
const renderBlob = new Blob(
[ writtenData ],
{ type: "video/mp4" }
);
const url = URL.createObjectURL(renderBlob);
const a = document.createElement('a');
a.href = url;
a.download = 'output.mp4';
a.click();
return new Blob([ writtenData ]);
} finally {
try {
await libav.unlink('output.mp4');
await libav.unlink('output');
} catch {}
await libav.av_packet_free(write_pkt);
if (output_ctx && writer_ctx) {
await libav.ff_free_muxer(output_ctx, writer_ctx);
}
await this.cleanup();
}
}
@ -289,13 +450,14 @@ export default class EncodeLibAV extends LibAVWrapper {
}
}
async* #demux(fmt_ctx: number) {
async* #demux() {
if (!this.#fmt_ctx) throw "fmt_ctx is missing";
const { libav } = await this.#get();
const read_pkt = await libav.av_packet_alloc();
try {
while (true) {
const [ ret, packets ] = await libav.ff_read_frame_multi(fmt_ctx, read_pkt, { limit: 1 });
const [ ret, packets ] = await libav.ff_read_frame_multi(this.#fmt_ctx, read_pkt, { limit: 1 });
if (ret !== -libav.EAGAIN &&
ret !== 0 &&
@ -317,80 +479,45 @@ export default class EncodeLibAV extends LibAVWrapper {
}
}
async #createEncoder(stream: Stream, codec: string) {
const { libav, webcodecs } = await this.#get();
#streamToConfig(stream: Stream) {
let _streamToConfig;
let streamToConfig, configToStream, initEncoder;
if (stream.codec_type === libav.AVMEDIA_TYPE_VIDEO) {
streamToConfig = LibAVWebCodecs.videoStreamToConfig;
configToStream = LibAVWebCodecs.configToVideoStream;
initEncoder = webcodecs.initVideoEncoder.bind(webcodecs);
codec = 'hvc1.1.6.L123.B0'
} else if (stream.codec_type === libav.AVMEDIA_TYPE_AUDIO) {
streamToConfig = LibAVWebCodecs.audioStreamToConfig;
configToStream = LibAVWebCodecs.configToAudioStream;
initEncoder = webcodecs.initAudioEncoder.bind(webcodecs);
if (stream.codec_type === LibAV.AVMEDIA_TYPE_VIDEO) {
_streamToConfig = LibAVWebCodecs.videoStreamToConfig;
} else if (stream.codec_type === LibAV.AVMEDIA_TYPE_AUDIO) {
_streamToConfig = LibAVWebCodecs.audioStreamToConfig;
} else throw "Unknown type: " + stream.codec_type;
const config = await streamToConfig(libav, stream, true);
if (config === null) {
throw "could not make encoder config";
}
return this.#get().then(
({ libav }) => _streamToConfig(libav, stream, true)
) as Promise<AudioDecoderConfig | VideoDecoderConfig>;
}
const encoderConfig = {
codec,
width: config.codedWidth,
height: config.codedHeight,
numberOfChannels: config.numberOfChannels,
sampleRate: config.sampleRate
};
const output = new BufferStream<
{ chunk: Chunk, metadata: ChunkMetadata }
>();
const encoder = await initEncoder(encoderConfig, {
output: (chunk, metadata = {}) => {
output.push({ chunk, metadata })
},
error: console.error
});
if (!encoder) {
throw "cannot encode " + codec;
}
const encoderStream = await configToStream(libav, encoderConfig);
return {
pipe: { instance: encoder, output },
stream: encoderStream
};
streamIndexToConfig(index: number) {
if (!this.#istreams || !this.#istreams[index])
throw "invalid stream";
return this.#streamToConfig(this.#istreams[index]);
}
async #createDecoder(stream: Stream) {
const { libav, webcodecs } = await this.#get();
let streamToConfig, initDecoder;
let initDecoder;
if (stream.codec_type === libav.AVMEDIA_TYPE_VIDEO) {
streamToConfig = LibAVWebCodecs.videoStreamToConfig;
initDecoder = webcodecs.initVideoDecoder.bind(webcodecs);
} else if (stream.codec_type === libav.AVMEDIA_TYPE_AUDIO) {
streamToConfig = LibAVWebCodecs.audioStreamToConfig;
initDecoder = webcodecs.initAudioDecoder.bind(webcodecs);
} else throw "Unknown type: " + stream.codec_type;
const config = await streamToConfig(libav, stream, true);
const config = await this.#streamToConfig(stream);
if (config === null) {
throw "could not make decoder config";
}
const output = new BufferStream<VideoFrame | AudioData>();
const decoder = await initDecoder(config, {
const decoder = await initDecoder(config as any /* fixme */, {
output: frame => output.push(frame),
error: console.error
});
@ -399,6 +526,6 @@ export default class EncodeLibAV extends LibAVWrapper {
throw "cannot decode " + config.codec;
}
return { instance: decoder, output }
return { instance: decoder, output } as DecoderPipeline;
}
}

View file

@ -28,6 +28,7 @@ export default class LibAVWrapper {
if (this.#libav) {
const libav = await this.#libav;
libav.terminate();
this.#libav = null;
}
}
@ -41,7 +42,7 @@ export default class LibAVWrapper {
static getExtensionFromType(blob: Blob) {
const extensions = mime.getAllExtensions(blob.type);
const overrides = ['mp3', 'mov'];
const overrides = ['mp3', 'mov', 'opus'];
if (!extensions)
return;

101
web/src/lib/libav/probe.ts Normal file
View file

@ -0,0 +1,101 @@
import * as LibAVPolyfill from "@imput/libavjs-webcodecs-polyfill";
const AUDIO_CODECS = {
aac: ['mp4a.40.02', 'mp4a.40.05', 'mp4a.40.29'],
opus: ['opus'],
mp3: ['mp3', 'mp4a.69', 'mp4a.6B'],
flac: ['flac'],
ogg: ['vorbis'],
ulaw: ['ulaw'],
alaw: ['alaw']
};
const VIDEO_CODECS = {
h264: ['avc1.42E01E', 'avc1.42E01F', 'avc1.4D401F', 'avc1.4D4028', 'avc1.640028', 'avc1.640029', 'avc1.64002A'],
av1: ['av01.0.08M.08', 'av01.0.12M.08', 'av01.0.16M.08'],
vp9: ['vp09.01.10.08', 'vp09.02.10.08'],
hevc: ['hvc1.1.6.L93.B0', 'hvc1.1.6.L123.00', 'hvc1.1.6.L153.00', 'hvc1.2.4.L93.00', 'hvc1.2.4.L123.00']
};
async function probeSingleAudio(config: AudioEncoderConfig) {
try {
if ('AudioEncoder' in window && window.AudioEncoder !== LibAVPolyfill.AudioEncoder) {
const { supported } = await window.AudioEncoder.isConfigSupported(config);
if (supported) {
return { supported };
}
}
} catch(e) { console.warn('audio native probe fail', e) }
try {
const { supported } = await LibAVPolyfill.AudioEncoder.isConfigSupported(config);
if (supported) {
return { supported, slow: true }
}
} catch(e) { console.warn('audio polyfill probe fail', e) }
return { supported: false }
}
async function probeSingleVideo(config: VideoEncoderConfig) {
try {
if ('VideoEncoder' in window && window.VideoEncoder !== LibAVPolyfill.VideoEncoder) {
const { supported } = await window.VideoEncoder.isConfigSupported(config);
if (supported) {
return { supported };
}
}
} catch(e) { console.warn('video native probe fail', e) }
try {
const { supported } = await LibAVPolyfill.VideoEncoder.isConfigSupported(config);
if (supported) {
return { supported, slow: true }
}
} catch(e) { console.warn('video polyfill probe fail', e) }
return { supported: false }
}
export type ProbeResult = {
[name: string]: { supported: false } | {
supported: true,
codec: string,
slow?: boolean
}
}
export async function probeAudio(partial: Omit<AudioEncoderConfig, "codec">) {
const result: ProbeResult = {};
for (const [ name, codecs ] of Object.entries(AUDIO_CODECS)) {
result[name] = { supported: false };
for (const codec of codecs) {
const config = { ...partial, codec };
const { supported, slow } = await probeSingleAudio(config);
if (supported) {
result[name] = { supported, slow, codec };
break;
}
}
}
return result;
}
export async function probeVideo(partial: Omit<VideoEncoderConfig, "codec">) {
const result: ProbeResult = {};
for (const [ name, codecs ] of Object.entries(VIDEO_CODECS)) {
result[name] = { supported: false };
for (const codec of codecs) {
const config = { ...partial, codec };
const { supported, slow } = await probeSingleVideo(config);
if (supported) {
result[name] = { supported, slow, codec };
break;
}
}
}
return result;
}

View file

@ -13,7 +13,7 @@ export default class WebCodecsWrapper {
this.#libav = libav;
}
async #load() {
async load() {
if (typeof this.#ready === 'undefined') {
this.#ready = LibAVPolyfill.load({
polyfill: true,
@ -30,7 +30,7 @@ export default class WebCodecsWrapper {
const audioConfig = config as AudioDecoderConfig;
for (const source of [ window, LibAVPolyfill ]) {
if (source === LibAVPolyfill) {
await this.#load();
await this.load();
}
try {
@ -45,7 +45,7 @@ export default class WebCodecsWrapper {
const videoConfig = config as VideoDecoderConfig;
for (const source of [ window, LibAVPolyfill ]) {
if (source === LibAVPolyfill) {
await this.#load();
await this.load();
}
try {
@ -66,7 +66,7 @@ export default class WebCodecsWrapper {
const audioConfig = config as AudioEncoderConfig;
for (const source of [ window, LibAVPolyfill ]) {
if (source === LibAVPolyfill) {
await this.#load();
await this.load();
}
try {
@ -81,7 +81,7 @@ export default class WebCodecsWrapper {
const videoConfig = config as VideoEncoderConfig;
for (const source of [ window, LibAVPolyfill ]) {
if (source === LibAVPolyfill) {
await this.#load();
await this.load();
}
try {

View file

@ -1,4 +1,5 @@
import { BufferStream } from "$lib/buffer-stream";
import type { ProbeResult } from "$lib/libav/probe";
export type InputFileKind = "video" | "audio";
@ -69,5 +70,17 @@ export type VideoPipeline = {
}
export type Pipeline = AudioPipeline | VideoPipeline;
export type DecoderPipeline = AudioDecoderPipeline | VideoDecoderPipeline | null;
export type EncoderPipeline = AudioEncoderPipeline | VideoEncoderPipeline | null;
export type RenderingPipeline = Pipeline | null;
export type OutputStream = [number, number, number] | null;
export type ContainerConfiguration = {
formatName: string
};
export type StreamInfo = {
codec: string,
type: string,
supported: boolean,
output: ProbeResult
};

View file

@ -1,12 +1,15 @@
<script lang="ts">
import LibAVWrapper from "$lib/libav/encode";
import { t } from "$lib/i18n/translations";
import stringify from "json-stringify-pretty-compact";
import DropReceiver from "$components/misc/DropReceiver.svelte";
import FileReceiver from "$components/misc/FileReceiver.svelte";
import type { StreamInfo } from "$lib/types/libav";
import { onDestroy } from "svelte";
let file: File | undefined;
let streamInfo: StreamInfo[] | undefined;
const ff = new LibAVWrapper();
ff.init();
@ -14,6 +17,44 @@
const render = async () => {
if (!file) return;
await ff.init();
await ff.cleanup();
await ff.feed(file);
await ff.prep();
streamInfo = await ff.getStreamInfo();
console.log(streamInfo);
for (const stream_index in streamInfo) {
const stream = streamInfo[stream_index];
if (!stream.supported) continue;
const maybe_codec = Object.values(stream.output).find(a => a.supported && !a.slow);
if (!maybe_codec || !maybe_codec.supported)
throw "could not find valid codec";
const decoderConfig = await ff.streamIndexToConfig(+stream_index);
const config = {
...decoderConfig,
width: 'codedWidth' in decoderConfig ? decoderConfig.codedWidth : undefined,
height: 'codedHeight' in decoderConfig ? decoderConfig.codedHeight : undefined,
};
await ff.configureEncoder(+stream_index, {
...config,
codec: maybe_codec.codec
});
}
const blob = new Blob(
[ await ff.work('mp4') ],
{ type: 'video/mp4' }
);
console.log('she=onika ate=burgers blob=', blob);
const pseudolink = document.createElement("a");
pseudolink.href = URL.createObjectURL(blob);
pseudolink.download = "video.mp4";
pseudolink.click();
};
onDestroy(async () => {
await ff.cleanup();
@ -44,6 +85,12 @@
{$t("remux.description")}
</div>
</div>
{#if streamInfo}
<div class="codec-info">
i am (hopefully) working. check console
{ stringify(streamInfo) }
</div>
{/if}
</DropReceiver>
<style>
@ -69,4 +116,10 @@
font-size: 14px;
line-height: 1.5;
}
.codec-info {
white-space: pre;
font-family: 'Comic Sans MS', cursive;
color: red;
}
</style>