mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-03 22:03:49 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fbd31849b | ||
|
|
d92df801bf | ||
|
|
6832153fef | ||
|
|
82690ef327 | ||
|
|
c0d93f45a5 | ||
|
|
05f8ecd908 | ||
|
|
e0ced9901e | ||
|
|
96d0d9fb22 | ||
|
|
c6c768d93e | ||
|
|
5799cdf630 | ||
|
|
831d395696 | ||
|
|
288372ee71 | ||
|
|
4825818f9b | ||
|
|
b82ccc2c6a | ||
|
|
eb8b5e2534 | ||
|
|
1c12f2b66d | ||
|
|
441eea38b9 | ||
|
|
ef27d73405 | ||
|
|
41a331b0dc | ||
|
|
85a80972a0 |
+1
-1
@@ -14,7 +14,7 @@ import BlogAuthor from './components/BlogAuthor.vue';
|
||||
<template v-for="(post, i) in data">
|
||||
<a :href="post.url" class="flex flex-col sm:flex-row items-start sm:gap-8 !text-inherit !no-underline ![font-weight:inherit] group">
|
||||
<div>
|
||||
<img src="./assets/on-air.png" class="shrink-0 sm:w-40 rounded" />
|
||||
<img :src="post.frontmatter.headerImage" class="shrink-0 sm:w-40 rounded" />
|
||||
<div v-if="false" class="p-1">
|
||||
<BlogAuthor :frontmatter="post.frontmatter" small />
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ author: Vanilagy
|
||||
authorImage: /vani.png
|
||||
authorLink: https://github.com/Vanilagy
|
||||
authorSubtitle: Creator of Mediabunny
|
||||
headerImage: /on-air.png
|
||||
excerpt: Mediabunny v1.42.0 adds read/write support for HTTP Live Streaming (HLS). This has been in the making for the last ~4 months and is, by far, the biggest addition to Mediabunny since its original release.
|
||||
---
|
||||
|
||||
@@ -14,7 +15,7 @@ excerpt: Mediabunny v1.42.0 adds read/write support for HTTP Live Streaming (HLS
|
||||
import BlogAuthor from '../components/BlogAuthor.vue';
|
||||
</script>
|
||||
|
||||
<img src="../assets/on-air.png" class="rounded-2xl mb-2">
|
||||
<img :src="$frontmatter.headerImage" class="rounded-2xl mb-2" />
|
||||
|
||||
<p class="!m-0 opacity-70">{{ $frontmatter.publishedOn }}</p>
|
||||
|
||||
|
||||
@@ -643,6 +643,10 @@ const source = new UrlSource('https://example.com/bigbuckbunny.mp4', {
|
||||
});
|
||||
```
|
||||
|
||||
::: info
|
||||
All `requestInit` fields are respected except for `signal`, which is overridden by Mediabunny. The same applies to the `signal` value of a `Request` passed as the first constructor argument. To cancel ongoing requests, [dispose of the input](#disposing-inputs).
|
||||
:::
|
||||
|
||||
`getRetryDelay` can be used to control the retry logic used should a request fail. When a request fails, `getRetryDelay` should return the time to wait in seconds before the request will be retried. Returning `null` prevents further retries.
|
||||
```ts
|
||||
// UrlSource using retry logic with exponential backoff:
|
||||
@@ -696,18 +700,18 @@ type FilePathSourceOptions = {
|
||||
When using this source, make sure to manually [dispose of the Input](#disposing-inputs) when you are done with it to properly close the internal file handle held by this source.
|
||||
:::
|
||||
|
||||
### `StreamSource`
|
||||
### `CustomSource`
|
||||
|
||||
This is a general-purpose input source you can use to read data from anywhere.
|
||||
|
||||
For example, here we're reading a file from disk using the Node.js file system (although you should use [`FilePathSource`](#filepathsource) for that):
|
||||
For example, here we're reading a file from disk using the Node.js file system (although you should use the existing [`FilePathSource`](#filepathsource) for that):
|
||||
```ts
|
||||
import { StreamSource } from 'mediabunny';
|
||||
import { CustomSource } from 'mediabunny';
|
||||
import { open } from 'node:fs/promises';
|
||||
|
||||
const fileHandle = await open('bigbuckbunny.mp4', 'r');
|
||||
|
||||
const source = new StreamSource({
|
||||
const source = new CustomSource({
|
||||
read: async (start, end) => {
|
||||
const buffer = Buffer.alloc(end - start);
|
||||
await fileHandle.read(buffer, 0, end - start, start);
|
||||
@@ -720,9 +724,9 @@ const source = new StreamSource({
|
||||
});
|
||||
```
|
||||
|
||||
The options of `StreamSource` have the following type:
|
||||
The options of `CustomSource` have the following type:
|
||||
```ts
|
||||
type StreamSourceOptions = {
|
||||
type CustomSourceOptions = {
|
||||
getSize: () => MaybePromise<number>;
|
||||
read: (start: number, end: number) => MaybePromise<Uint8Array | ReadableStream<Uint8Array>>;
|
||||
dispose?: () => unknown;
|
||||
@@ -736,7 +740,7 @@ type MaybePromise<T> = T | Promise<T>;
|
||||
- `getSize`\
|
||||
Called when the size of the entire file is requested. Must return or resolve to the size in bytes. This function is guaranteed to be called before `read`.
|
||||
- `read`\
|
||||
Called when data is requested. Must return or resolve to the bytes from the specified byte range, or a stream that yields these bytes.
|
||||
Called when data is requested. Must return or resolve to the bytes from the specified byte range, or a stream that yields these bytes. You are guaranteed that `0 <= start < end < fileSize`.
|
||||
- `dispose`\
|
||||
Called when the `Input` driven by this source is disposed.
|
||||
- `maxCacheSize`\
|
||||
@@ -747,6 +751,10 @@ type MaybePromise<T> = T | Promise<T>;
|
||||
- `'fileSystem'`: File system-optimized prefetching: a small amount of data is prefetched bidirectionally, aligned with page boundaries.
|
||||
- `'network'`: Network-optimized prefetching, or more generally, prefetching optimized for any high-latency environment: tries to minimize the amount of read calls and aggressively prefetches data when sequential access patterns are detected.
|
||||
|
||||
::: info
|
||||
`CustomSource` was previously known as `StreamSource` and is still available under that alias, but usage of `StreamSource` is deprecated.
|
||||
:::
|
||||
|
||||
### `ReadableStreamSource`
|
||||
|
||||
This is a source backed by a `ReadableStream` of `Uint8Array`, representing an append-only byte stream of unknown length. This is the source to use for incrementally streaming in input files that are still being constructed and whose size we don't yet know. You could also use it to stream in existing files, but other sources (such as [`BlobSource`](#blobsource) or [`FilePathSource`](#filepathsource)) are recommended instead because they offer random access.
|
||||
|
||||
@@ -124,6 +124,8 @@ const sponsors = {
|
||||
{ image: 'https://avatars.githubusercontent.com/u/67356781', name: 'Ahmed Rowaihi', url: 'https://github.com/ahmedrowaihi' },
|
||||
{ image: 'https://avatars.githubusercontent.com/u/4714175', name: 'Phoomparin Mano', url: 'https://github.com/heypoom' },
|
||||
{ image: 'https://avatars.githubusercontent.com/u/504909', name: 'Hirbod', url: 'https://github.com/hirbod' },
|
||||
{ image: 'https://avatars.githubusercontent.com/u/2565549', name: 'MatthewNorton', url: 'https://github.com/MatthewNorton' },
|
||||
{ image: 'https://avatars.githubusercontent.com/u/272247752', name: 'Motionik', url: 'https://github.com/Motionik' },
|
||||
{ image: 'https://avatars.githubusercontent.com/u/2698271', name: 'Matthew Gardner', url: 'https://github.com/spheric' },
|
||||
{ image: 'https://avatars.githubusercontent.com/u/5475819', name: 'AJ Funk', url: 'https://github.com/AJFunk' },
|
||||
{ image: 'https://avatars.githubusercontent.com/u/30229596', name: 'Pablo Bonilla', url: 'https://github.com/devPablo' },
|
||||
|
||||
|
Before Width: | Height: | Size: 304 KiB After Width: | Height: | Size: 304 KiB |
Generated
+7
-7
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mediabunny",
|
||||
"version": "1.45.3",
|
||||
"version": "1.45.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mediabunny",
|
||||
"version": "1.45.3",
|
||||
"version": "1.45.5",
|
||||
"license": "MPL-2.0",
|
||||
"workspaces": [
|
||||
".",
|
||||
@@ -12806,7 +12806,7 @@
|
||||
},
|
||||
"packages/aac-encoder": {
|
||||
"name": "@mediabunny/aac-encoder",
|
||||
"version": "1.45.3",
|
||||
"version": "1.45.5",
|
||||
"license": "MPL-2.0",
|
||||
"devDependencies": {
|
||||
"@types/emscripten": "^1.40.1"
|
||||
@@ -12821,7 +12821,7 @@
|
||||
},
|
||||
"packages/ac3": {
|
||||
"name": "@mediabunny/ac3",
|
||||
"version": "1.45.3",
|
||||
"version": "1.45.5",
|
||||
"license": "MPL-2.0",
|
||||
"devDependencies": {
|
||||
"@types/emscripten": "^1.40.1"
|
||||
@@ -12836,7 +12836,7 @@
|
||||
},
|
||||
"packages/flac-encoder": {
|
||||
"name": "@mediabunny/flac-encoder",
|
||||
"version": "1.45.3",
|
||||
"version": "1.45.5",
|
||||
"license": "MPL-2.0",
|
||||
"devDependencies": {
|
||||
"@types/emscripten": "^1.40.1"
|
||||
@@ -12851,7 +12851,7 @@
|
||||
},
|
||||
"packages/mp3-encoder": {
|
||||
"name": "@mediabunny/mp3-encoder",
|
||||
"version": "1.45.3",
|
||||
"version": "1.45.5",
|
||||
"license": "MPL-2.0",
|
||||
"devDependencies": {
|
||||
"@types/emscripten": "^1.40.1"
|
||||
@@ -12866,7 +12866,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@mediabunny/server",
|
||||
"version": "1.45.3",
|
||||
"version": "1.45.5",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"node-av": "^5.2.4"
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mediabunny",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.45.3",
|
||||
"version": "1.45.5",
|
||||
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@mediabunny/aac-encoder",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.45.3",
|
||||
"version": "1.45.5",
|
||||
"description": "AAC encoder extension for Mediabunny, based on FFmpeg.",
|
||||
"main": "./dist/bundles/mediabunny-aac-encoder.mjs",
|
||||
"module": "./dist/bundles/mediabunny-aac-encoder.mjs",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@mediabunny/ac3",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.45.3",
|
||||
"version": "1.45.5",
|
||||
"description": "AC-3 and E-AC-3 (Dolby Digital) decoder and encoder extension for Mediabunny, based on FFmpeg.",
|
||||
"main": "./dist/bundles/mediabunny-ac3.mjs",
|
||||
"module": "./dist/bundles/mediabunny-ac3.mjs",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@mediabunny/flac-encoder",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.45.3",
|
||||
"version": "1.45.5",
|
||||
"description": "FLAC encoder extension for Mediabunny, based on libFLAC.",
|
||||
"main": "./dist/bundles/mediabunny-flac-encoder.mjs",
|
||||
"module": "./dist/bundles/mediabunny-flac-encoder.mjs",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@mediabunny/mp3-encoder",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.45.3",
|
||||
"version": "1.45.5",
|
||||
"description": "MP3 encoder extension for Mediabunny, based on LAME.",
|
||||
"main": "./dist/bundles/mediabunny-mp3-encoder.mjs",
|
||||
"module": "./dist/bundles/mediabunny-mp3-encoder.mjs",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { CustomAudioEncoder, AudioCodec, AudioSample, EncodedPacket, registerEncoder } from 'mediabunny';
|
||||
import { FRAME_HEADER_SIZE, readMp3FrameHeader, SAMPLING_RATES } from '../../../shared/mp3-misc';
|
||||
import { MP3_FRAME_HEADER_SIZE, readMp3FrameHeader, SAMPLING_RATES } from '../../../shared/mp3-misc';
|
||||
import type { WorkerCommand, WorkerResponse, WorkerResponseData } from './shared';
|
||||
// @ts-expect-error An esbuild plugin handles this, TypeScript doesn't need to understand
|
||||
import createWorker from './encode.worker';
|
||||
@@ -160,7 +160,7 @@ class Mp3Encoder extends CustomAudioEncoder {
|
||||
this.currentBufferOffset = requiredBufferSize;
|
||||
|
||||
let pos = 0;
|
||||
while (pos <= this.currentBufferOffset - FRAME_HEADER_SIZE) {
|
||||
while (pos <= this.currentBufferOffset - MP3_FRAME_HEADER_SIZE) {
|
||||
const word = new DataView(this.buffer.buffer).getUint32(pos, false);
|
||||
const header = readMp3FrameHeader(word, null).header;
|
||||
if (!header) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@mediabunny/server",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.45.3",
|
||||
"version": "1.45.5",
|
||||
"description": "Adds full video and audio decoder and encoder support to Mediabunny for use in server-side environments (Node, Bun, Deno). Based on NodeAV.",
|
||||
"main": "./dist/bundles/mediabunny-server.cjs",
|
||||
"module": "./dist/bundles/mediabunny-server.mjs",
|
||||
|
||||
@@ -15,7 +15,7 @@ import { AvFrameAudioSampleResource } from './audio-sample';
|
||||
export class NodeAvAudioDecoder extends CustomAudioDecoder {
|
||||
frame!: NodeAv.Frame;
|
||||
packet!: NodeAv.Packet;
|
||||
codecContext!: NodeAv.CodecContext;
|
||||
codecContext: NodeAv.CodecContext | null = null;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
static override supports(codec: AudioCodec, config: AudioDecoderConfig): boolean {
|
||||
@@ -61,6 +61,8 @@ export class NodeAvAudioDecoder extends CustomAudioDecoder {
|
||||
}
|
||||
|
||||
async decode(packet: EncodedPacket): Promise<void> {
|
||||
assert(this.codecContext);
|
||||
|
||||
this.packet.isKeyframe = packet.type === 'key';
|
||||
this.packet.data = Buffer.from(packet.data);
|
||||
this.packet.timeBase = { num: 1, den: this.config.sampleRate };
|
||||
@@ -71,6 +73,8 @@ export class NodeAvAudioDecoder extends CustomAudioDecoder {
|
||||
const ret = await this.codecContext.sendPacket(this.packet);
|
||||
NodeAv.FFmpegError.throwIfError(ret, 'Send packet');
|
||||
|
||||
this.packet.unref(); // Don't need the data again, so just unref it
|
||||
|
||||
while (true) {
|
||||
const receiveRet = await this.codecContext.receiveFrame(this.frame);
|
||||
if (receiveRet === NodeAv.AVERROR_EAGAIN || receiveRet === NodeAv.AVERROR_EOF) {
|
||||
@@ -94,6 +98,8 @@ export class NodeAvAudioDecoder extends CustomAudioDecoder {
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
assert(this.codecContext);
|
||||
|
||||
// Send null packet to signal flush
|
||||
const ret = await this.codecContext.sendPacket(null);
|
||||
NodeAv.FFmpegError.throwIfError(ret, 'Flush decoder');
|
||||
@@ -113,7 +119,7 @@ export class NodeAvAudioDecoder extends CustomAudioDecoder {
|
||||
}
|
||||
|
||||
close(): MaybePromise<void> {
|
||||
this.codecContext.freeContext();
|
||||
this.codecContext?.freeContext();
|
||||
this.frame.free();
|
||||
this.packet.free();
|
||||
}
|
||||
|
||||
@@ -35,16 +35,16 @@ const FRAME_SIZE_FALLBACK = 1024; // Just 'cause
|
||||
export class NodeAvAudioEncoder extends CustomAudioEncoder {
|
||||
frame!: NodeAv.Frame;
|
||||
packet!: NodeAv.Packet;
|
||||
avCodec!: NodeAv.Codec;
|
||||
codecContext: NodeAv.CodecContext | null = null;
|
||||
resampler: NodeAv.SoftwareResampleContext | null = null;
|
||||
dstFrame: NodeAv.Frame | null = null;
|
||||
avCodec!: NodeAv.Codec;
|
||||
firstExpectedTimestamp: number | null = null;
|
||||
outputTimestampOffset = 0;
|
||||
|
||||
resampler: NodeAv.SoftwareResampleContext | null = null;
|
||||
inputParametersKey: string | null = null;
|
||||
resamplerInputSampleRate: number | null = null;
|
||||
nextResamplerPts: bigint | null = null;
|
||||
dstFrame: NodeAv.Frame | null = null;
|
||||
packetEmitted = false;
|
||||
adtsHeaderTemplate: AdtsHeaderTemplate | null = null;
|
||||
|
||||
@@ -134,6 +134,10 @@ export class NodeAvAudioEncoder extends CustomAudioEncoder {
|
||||
this.firstExpectedTimestamp ??= audioSample.timestamp;
|
||||
|
||||
if (audioSample._data instanceof AvFrameAudioSampleResource) {
|
||||
// Release any buffers still referenced from the previous encode before reffing the new frame, otherwise
|
||||
// av_frame_ref leaks them
|
||||
// https://github.com/Vanilagy/mediabunny/issues/392
|
||||
this.frame.unref();
|
||||
this.frame.ref(audioSample._data.frame);
|
||||
} else {
|
||||
copyAudioSampleToAvFrame(audioSample, this.frame);
|
||||
|
||||
@@ -78,6 +78,8 @@ export { AvFrameAudioSampleResource } from './audio-sample';
|
||||
export const toAvFrame = async (sample: VideoSample | AudioSample, frame: NodeAv.Frame) => {
|
||||
if (sample instanceof VideoSample) {
|
||||
if (sample._data instanceof AvFrameVideoSampleResource) {
|
||||
// We're overriding the frame, so release whatever it referenced before, otherwise av_frame_ref leaks it
|
||||
frame.unref();
|
||||
frame.ref(sample._data.frame);
|
||||
} else {
|
||||
if (sample.format === null) {
|
||||
@@ -92,6 +94,8 @@ export const toAvFrame = async (sample: VideoSample | AudioSample, frame: NodeAv
|
||||
frame.timeBase = new NodeAv.Rational(1, 1e6);
|
||||
} else {
|
||||
if (sample._data instanceof AvFrameAudioSampleResource) {
|
||||
// We're overriding the frame, so release whatever it referenced before, otherwise av_frame_ref leaks it
|
||||
frame.unref();
|
||||
frame.ref(sample._data.frame);
|
||||
} else {
|
||||
copyAudioSampleToAvFrame(sample, frame);
|
||||
|
||||
@@ -91,9 +91,10 @@ export class NodeAvVideoDecoder extends CustomVideoDecoder {
|
||||
async decode(packet: EncodedPacket) {
|
||||
if (this.codecContext === null) {
|
||||
await this.initCodecContext(packet);
|
||||
assert(this.codecContext);
|
||||
}
|
||||
|
||||
assert(this.codecContext);
|
||||
|
||||
this.packet.isKeyframe = packet.type === 'key';
|
||||
this.packet.data = Buffer.from(packet.data);
|
||||
this.packet.timeBase = { num: 1, den: 1e6 };
|
||||
@@ -144,6 +145,8 @@ export class NodeAvVideoDecoder extends CustomVideoDecoder {
|
||||
const ret = await this.codecContext.sendPacket(this.packet);
|
||||
NodeAv.FFmpegError.throwIfError(ret, 'Send packet');
|
||||
|
||||
this.packet.unref(); // Don't need the data again, so just unref it
|
||||
|
||||
while (true) {
|
||||
const receiveRet = await this.codecContext.receiveFrame(this.frame);
|
||||
if (receiveRet === NodeAv.AVERROR_EAGAIN || receiveRet === NodeAv.AVERROR_EOF) {
|
||||
|
||||
@@ -44,14 +44,13 @@ import { assert, binarySearchLessOrEqual, simplifyRational, toUint8Array } from
|
||||
export class NodeAvVideoEncoder extends CustomVideoEncoder {
|
||||
frame!: NodeAv.Frame;
|
||||
packet!: NodeAv.Packet;
|
||||
avCodec!: NodeAv.Codec;
|
||||
codecContext: NodeAv.CodecContext | null = null;
|
||||
scaler: NodeAv.SoftwareScaleContext | null = null;
|
||||
dstFrame: NodeAv.Frame | null = null;
|
||||
avCodec!: NodeAv.Codec;
|
||||
lastBuffer: Buffer | null = null;
|
||||
packetEmitted = false;
|
||||
|
||||
scaler: NodeAv.SoftwareScaleContext | null = null;
|
||||
lastScalerKey: string | null = null;
|
||||
dstFrame: NodeAv.Frame | null = null;
|
||||
|
||||
// Bookkeeping to restore the original timing information
|
||||
preciseTimings: {
|
||||
@@ -183,10 +182,14 @@ export class NodeAvVideoEncoder extends CustomVideoEncoder {
|
||||
async encode(videoSample: VideoSample, options: VideoEncoderEncodeOptions): Promise<void> {
|
||||
if (this.codecContext === null) {
|
||||
await this.createCodecContext();
|
||||
assert(this.codecContext);
|
||||
}
|
||||
assert(this.codecContext);
|
||||
|
||||
if (videoSample._data instanceof AvFrameVideoSampleResource) {
|
||||
// Release any buffers still referenced from the previous encode before reffing the new frame, otherwise
|
||||
// av_frame_ref leaks them
|
||||
// https://github.com/Vanilagy/mediabunny/issues/392
|
||||
this.frame.unref();
|
||||
this.frame.ref(videoSample._data.frame);
|
||||
} else {
|
||||
if (videoSample.format === null) {
|
||||
@@ -513,12 +516,10 @@ export class NodeAvVideoEncoder extends CustomVideoEncoder {
|
||||
displayAspectHeight: this.config.displayHeight ?? this.codecContext.height,
|
||||
description: decoderConfigDescription ?? undefined,
|
||||
colorSpace: {
|
||||
primaries:
|
||||
unmapColorPrimaries(this.codecContext.colorPrimaries) as VideoColorPrimaries,
|
||||
matrix:
|
||||
unmapMatrixCoefficients(this.codecContext.colorSpace) as VideoMatrixCoefficients,
|
||||
primaries: unmapColorPrimaries(this.codecContext.colorPrimaries) as VideoColorPrimaries,
|
||||
matrix: unmapMatrixCoefficients(this.codecContext.colorSpace) as VideoMatrixCoefficients,
|
||||
transfer:
|
||||
unmapTransferCharacteristics(this.codecContext.colorTrc) as VideoTransferCharacteristics,
|
||||
unmapTransferCharacteristics(this.codecContext.colorTrc) as VideoTransferCharacteristics,
|
||||
fullRange: this.codecContext.colorRange === NodeAv.AVCOL_RANGE_JPEG
|
||||
? true
|
||||
: this.codecContext.colorRange === NodeAv.AVCOL_RANGE_MPEG
|
||||
|
||||
@@ -83,12 +83,12 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false)
|
||||
const declaration = exportSymbol.valueDeclaration || exportSymbol.declarations?.[0];
|
||||
if (!declaration) return;
|
||||
|
||||
// Collect classes, interfaces, types, enums, variables (only if @public and not @deprecated)
|
||||
// Collect classes, interfaces, types, enums, variables (only if @public)
|
||||
if (ts.isClassDeclaration(declaration) || ts.isInterfaceDeclaration(declaration)
|
||||
|| ts.isTypeAliasDeclaration(declaration) || ts.isEnumDeclaration(declaration)
|
||||
|| ts.isVariableDeclaration(declaration)) {
|
||||
const hasPublicTag = ts.getJSDocTags(declaration).some(tag => tag.tagName.text === 'public');
|
||||
if (hasPublicTag && !hasDeprecatedTag(declaration)) {
|
||||
if (hasPublicTag) {
|
||||
exportedTypes.add(exportSymbol.getName());
|
||||
}
|
||||
}
|
||||
@@ -98,9 +98,9 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false)
|
||||
const aliasedSymbol = typeChecker.getAliasedSymbol(exportSymbol);
|
||||
const aliasedDeclaration = aliasedSymbol.valueDeclaration || aliasedSymbol.declarations?.[0];
|
||||
if (aliasedDeclaration) {
|
||||
// Check if the aliased symbol has @public tag and is not deprecated
|
||||
// Check if the aliased symbol has @public tag
|
||||
const hasPublicTag = ts.getJSDocTags(aliasedDeclaration).some(tag => tag.tagName.text === 'public');
|
||||
if (hasPublicTag && !hasDeprecatedTag(aliasedDeclaration)) {
|
||||
if (hasPublicTag) {
|
||||
exportedTypes.add(exportSymbol.getName());
|
||||
}
|
||||
|
||||
@@ -139,10 +139,10 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Otherwise, add any symbol with @public tag and not @deprecated (we'll filter by type later)
|
||||
// Otherwise, add any symbol with @public tag (we'll filter by type later)
|
||||
else {
|
||||
const hasPublicTag = ts.getJSDocTags(declaration).some(tag => tag.tagName.text === 'public');
|
||||
if (hasPublicTag && !hasDeprecatedTag(declaration)) {
|
||||
if (hasPublicTag) {
|
||||
symbols.push(exportSymbol);
|
||||
}
|
||||
}
|
||||
@@ -379,6 +379,34 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false)
|
||||
});
|
||||
};
|
||||
|
||||
// Helper to build a "> **Deprecated.** ..." notice from a node's @deprecated JSDoc tag.
|
||||
// Returns an empty string if the node has no @deprecated tag.
|
||||
const getDeprecationNotice = (node: ts.Node, currentTypeName?: string): string => {
|
||||
const tag = ts.getJSDocTags(node).find(t => t.tagName.text === 'deprecated');
|
||||
if (!tag) {
|
||||
return '';
|
||||
}
|
||||
let text = '';
|
||||
if (tag.comment) {
|
||||
if (typeof tag.comment === 'string') {
|
||||
text = processLinkTags(tag.comment.trim(), currentTypeName);
|
||||
} else {
|
||||
// comment is a NodeArray of JSDocComment elements (text + inline tags)
|
||||
const raw = tag.comment.map((part) => {
|
||||
if (ts.isJSDocLinkLike(part)) {
|
||||
const linkName = part.name?.getText() ?? '';
|
||||
const linkText = part.text?.trim() ?? '';
|
||||
// Reconstruct as {@link Name text}
|
||||
return `{@link ${linkName}${linkText ? ' ' + linkText : ''}}`;
|
||||
}
|
||||
return part.text ?? '';
|
||||
}).join('');
|
||||
text = processLinkTags(raw.trim(), currentTypeName);
|
||||
}
|
||||
}
|
||||
return text ? `> **Deprecated.** ${text}\n\n` : '> **Deprecated.**\n\n';
|
||||
};
|
||||
|
||||
// Helper to extract linked types from {@link} tags in text
|
||||
const extractLinkedTypes = (text: string): string[] => {
|
||||
if (!text) return [];
|
||||
@@ -627,10 +655,11 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false)
|
||||
const nodeKind = ts.SyntaxKind[declaration.kind];
|
||||
const symbolName = (declaration as any).name?.getText() || exportSymbol.getName();
|
||||
|
||||
// Only process symbols with @public tag, and skip deprecated ones entirely
|
||||
// Only process symbols with @public tag
|
||||
const hasPublicTag = ts.getJSDocTags(declaration).some(tag => tag.tagName.text === 'public');
|
||||
if (!hasPublicTag) return;
|
||||
if (hasDeprecatedTag(declaration)) return;
|
||||
if (!hasPublicTag) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for @group tag (handle re-exports by looking at the original declaration)
|
||||
let targetDeclaration = declaration;
|
||||
@@ -714,7 +743,8 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false)
|
||||
? `${variableName}(\n${params.join(',\n')},\n): ${returnType};`
|
||||
: `${variableName}(): ${returnType};`;
|
||||
|
||||
let markdown = `${buildFrontmatter(description)}<script setup>\nimport { VPBadge } from 'vitepress/theme'\n</script>\n\n<VPBadge type="info" text="Function" />\n\n# ${variableName}\n\n\`\`\`ts\n${functionSig}\n\`\`\`${description ? `\n\n${description}` : ''}`;
|
||||
const deprecationNotice = getDeprecationNotice(declaration, variableName);
|
||||
let markdown = `${buildFrontmatter(description)}<script setup>\nimport { VPBadge } from 'vitepress/theme'\n</script>\n\n<VPBadge type="info" text="Function" />\n\n# ${variableName}\n\n${deprecationNotice}\`\`\`ts\n${functionSig}\n\`\`\`${description ? `\n\n${description}` : ''}`;
|
||||
|
||||
// Find referenced types in all parameters and return type
|
||||
const allTypeStrings = params.map(p => p.replace(/\t.*?:\s*/, '')).concat([returnType]);
|
||||
@@ -728,7 +758,8 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false)
|
||||
}
|
||||
} else {
|
||||
// Handle regular variables
|
||||
let markdown = `${buildFrontmatter(description)}<script setup>\nimport { VPBadge } from 'vitepress/theme'\n</script>\n\n<VPBadge type="info" text="Constant" />\n\n# ${variableName}\n\n${description ? `${description}\n\n` : ''}`;
|
||||
const deprecationNotice = getDeprecationNotice(declaration, variableName);
|
||||
let markdown = `${buildFrontmatter(description)}<script setup>\nimport { VPBadge } from 'vitepress/theme'\n</script>\n\n<VPBadge type="info" text="Constant" />\n\n# ${variableName}\n\n${deprecationNotice}${description ? `${description}\n\n` : ''}`;
|
||||
const variableValue = declaration.initializer ? declaration.initializer.getText() : 'undefined';
|
||||
const variableDefinition = `const ${variableName} = ${variableValue};`;
|
||||
markdown += `\`\`\`ts\n${variableDefinition}\n\`\`\``;
|
||||
@@ -971,33 +1002,10 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false)
|
||||
|
||||
const isDeprecatedMember = hasDeprecatedTag(member);
|
||||
|
||||
const getDeprecationNotice = () => {
|
||||
const tag = ts.getJSDocTags(member).find(t => t.tagName.text === 'deprecated');
|
||||
let text = '';
|
||||
if (tag?.comment) {
|
||||
if (typeof tag.comment === 'string') {
|
||||
text = processLinkTags(tag.comment.trim(), className);
|
||||
} else {
|
||||
// comment is a NodeArray of JSDocComment elements (text + inline tags)
|
||||
const raw = tag.comment.map((part) => {
|
||||
if (ts.isJSDocLinkLike(part)) {
|
||||
const linkName = part.name?.getText() ?? '';
|
||||
const linkText = part.text?.trim() ?? '';
|
||||
// Reconstruct as {@link Name text}
|
||||
return `{@link ${linkName}${linkText ? ' ' + linkText : ''}}`;
|
||||
}
|
||||
return part.text ?? '';
|
||||
}).join('');
|
||||
text = processLinkTags(raw.trim(), className);
|
||||
}
|
||||
}
|
||||
return text ? `> **Deprecated.** ${text}\n\n` : '> **Deprecated.**\n\n';
|
||||
};
|
||||
|
||||
const addDeprecationNotice = (content: string) => {
|
||||
// Insert the deprecation notice right after the heading line
|
||||
const headingEnd = content.indexOf('\n');
|
||||
return content.slice(0, headingEnd) + '\n\n' + getDeprecationNotice() + content.slice(headingEnd + 1);
|
||||
return content.slice(0, headingEnd) + '\n\n' + getDeprecationNotice(member, className) + content.slice(headingEnd + 1);
|
||||
};
|
||||
|
||||
const pushProperty = (content: string) => {
|
||||
@@ -1529,7 +1537,8 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false)
|
||||
markdown += `<VPBadge type="info" text="Interface" />\n\n`;
|
||||
}
|
||||
|
||||
markdown += `# ${className}\n\n${description ? `${description}\n` : ''}${extendsClause}${implementsClause}`;
|
||||
const deprecationNotice = getDeprecationNotice(declaration, className);
|
||||
markdown += `# ${className}\n\n${deprecationNotice}${description ? `${description}\n` : ''}${extendsClause}${implementsClause}`;
|
||||
|
||||
// Add subclasses section for classes that have subclasses
|
||||
if (ts.isClassDeclaration(declaration) && classHierarchy.has(className)) {
|
||||
@@ -1900,7 +1909,25 @@ const extractJsDocDescription = (
|
||||
descLines.push(line);
|
||||
}
|
||||
} else {
|
||||
descLines = lines.filter(line => !line.trim().startsWith('@'));
|
||||
// Skip @tag lines and their continuation lines (continuation ends at a blank line
|
||||
// or the next @tag). Without this, multi-line tags like @deprecated bleed into the
|
||||
// description.
|
||||
descLines = [];
|
||||
let inTagContinuation = false;
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('@')) {
|
||||
inTagContinuation = true;
|
||||
continue;
|
||||
}
|
||||
if (inTagContinuation) {
|
||||
if (trimmed === '') {
|
||||
inTagContinuation = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
descLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
const rawDesc = descLines.join('\n').trim();
|
||||
|
||||
+7
-2
@@ -6,7 +6,7 @@
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
export const FRAME_HEADER_SIZE = 4;
|
||||
export const MP3_FRAME_HEADER_SIZE = 4;
|
||||
export const SAMPLING_RATES = [44100, 48000, 32000];
|
||||
export const KILOBIT_RATES = [
|
||||
// lowSamplingFrequency === 0
|
||||
@@ -122,7 +122,8 @@ export const readMp3FrameHeader = (word: number, remainingBytes: number | null):
|
||||
const layer = (secondByte >> 1) & 0x3;
|
||||
|
||||
const bitrateIndex = (thirdByte >> 4) & 0xf;
|
||||
const frequencyIndex = ((thirdByte >> 2) & 0x3) % 3;
|
||||
const frequencyIndex = ((thirdByte >> 2) & 0x3) % 3; // FFmpeg effectively does % 3 (but in a roundabout way)
|
||||
|
||||
const padding = (thirdByte >> 1) & 0x1;
|
||||
|
||||
const channel = (fourthByte >> 6) & 0x3;
|
||||
@@ -212,3 +213,7 @@ export enum XingFlags {
|
||||
FileSize = 1 << 1,
|
||||
Toc = 1 << 2,
|
||||
}
|
||||
|
||||
export const getMp3ChannelCount = (channel: number) => {
|
||||
return channel === 3 ? 1 : 2;
|
||||
};
|
||||
|
||||
+2
-2
@@ -93,7 +93,7 @@ export const parseId3V1Tag = (slice: FileSlice, tags: MetadataTags) => {
|
||||
const yearText = readId3V1String(slice, 4);
|
||||
const year = Number.parseInt(yearText, 10);
|
||||
if (Number.isInteger(year) && year > 0) {
|
||||
tags.date ??= new Date(year, 0, 1);
|
||||
tags.date ??= new Date(String(year)); // String so that it parses as UTC
|
||||
}
|
||||
|
||||
const commentBytes = readBytes(slice, 30);
|
||||
@@ -339,7 +339,7 @@ export const parseId3V2Tag = (slice: FileSlice, header: Id3V2Header, tags: Metad
|
||||
const year = Number.parseInt(yearText, 10);
|
||||
|
||||
if (Number.isInteger(year)) {
|
||||
tags.date ??= new Date(year, 0, 1);
|
||||
tags.date ??= new Date(String(year)); // String so that it parses as UTC
|
||||
}
|
||||
}; break;
|
||||
|
||||
|
||||
@@ -173,10 +173,14 @@ export {
|
||||
type BlobSourceOptions,
|
||||
BufferSource,
|
||||
CustomPathedSource,
|
||||
CustomSource,
|
||||
type CustomSourceOptions,
|
||||
FilePathSource,
|
||||
type FilePathSourceOptions,
|
||||
PathedSource,
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
StreamSource,
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
type StreamSourceOptions,
|
||||
RangedSource,
|
||||
ReadableStreamSource,
|
||||
|
||||
+6
-2
@@ -22,7 +22,7 @@ import {
|
||||
} from './matroska/ebml';
|
||||
import { MatroskaDemuxer } from './matroska/matroska-demuxer';
|
||||
import { Mp3Demuxer } from './mp3/mp3-demuxer';
|
||||
import { FRAME_HEADER_SIZE, getXingOffset, INFO, XING } from '../shared/mp3-misc';
|
||||
import { MP3_FRAME_HEADER_SIZE, getXingOffset, INFO, XING } from '../shared/mp3-misc';
|
||||
import { ID3_V2_HEADER_SIZE, readId3V2Header } from './id3';
|
||||
import { readNextMp3FrameHeader } from './mp3/mp3-reader';
|
||||
import { OggDemuxer } from './ogg/ogg-demuxer';
|
||||
@@ -330,7 +330,11 @@ export class Mp3InputFormat extends InputFormat {
|
||||
|
||||
// Fine, we found one frame header, but we're still not entirely sure this is MP3. Let's check if we can find
|
||||
// another header right after it:
|
||||
const secondResult = await readNextMp3FrameHeader(input._reader, currentPos, currentPos + FRAME_HEADER_SIZE);
|
||||
const secondResult = await readNextMp3FrameHeader(
|
||||
input._reader,
|
||||
currentPos,
|
||||
currentPos + MP3_FRAME_HEADER_SIZE,
|
||||
);
|
||||
if (!secondResult) {
|
||||
return false;
|
||||
}
|
||||
|
||||
+1
-1
@@ -691,7 +691,7 @@ export class InputVideoTrack extends InputTrack {
|
||||
const colorSpace = await this._backing.getColorSpace();
|
||||
|
||||
return (colorSpace.primaries as string) === 'bt2020' || (colorSpace.primaries as string) === 'smpte432'
|
||||
|| (colorSpace.transfer as string) === 'pg' || (colorSpace.transfer as string) === 'hlg'
|
||||
|| (colorSpace.transfer as string) === 'pq' || (colorSpace.transfer as string) === 'hlg'
|
||||
|| (colorSpace.matrix as string) === 'bt2020-ncl';
|
||||
}
|
||||
|
||||
|
||||
@@ -45,17 +45,6 @@ polyfillSymbolDispose();
|
||||
export const DEFAULT_SOURCE_CACHE_GROUP = 1;
|
||||
export const ENCRYPTION_KEY_CACHE_GROUP = 2;
|
||||
|
||||
let inputFinalizationRegistry: FinalizationRegistry<SourceRef[]> | null = null;
|
||||
if (typeof FinalizationRegistry !== 'undefined') {
|
||||
inputFinalizationRegistry = new FinalizationRegistry((refs) => {
|
||||
for (const ref of refs) {
|
||||
if (!ref.freed) {
|
||||
ref.free();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The options for creating an Input object.
|
||||
* @group Input files & tracks
|
||||
@@ -191,7 +180,6 @@ export class Input<S extends Source = Source> extends EventEmitter<InputEvents>
|
||||
}
|
||||
|
||||
this._sourceRefs.push(this._rootRef);
|
||||
inputFinalizationRegistry?.register(this, this._sourceRefs, this);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
@@ -539,8 +527,6 @@ export class Input<S extends Source = Source> extends EventEmitter<InputEvents>
|
||||
}
|
||||
this._sourceRefs.length = 0;
|
||||
|
||||
inputFinalizationRegistry?.unregister(this);
|
||||
|
||||
void this._demuxerPromise
|
||||
?.then(demuxer => demuxer.dispose());
|
||||
}
|
||||
|
||||
@@ -378,12 +378,14 @@ export const free = (size: number): Box => ({ type: 'free', size });
|
||||
* Movie Box: Used to specify the information that defines a movie - that is, the information that allows
|
||||
* an application to interpret the sample data that is stored elsewhere.
|
||||
*/
|
||||
export const moov = (muxer: IsobmffMuxer) => box('moov', undefined, [
|
||||
mvhd(muxer.creationTime, muxer.trackDatas),
|
||||
...muxer.trackDatas.map(x => trak(x, muxer.creationTime)),
|
||||
muxer.isFragmented ? mvex(muxer.trackDatas) : null,
|
||||
udta(muxer),
|
||||
]);
|
||||
export const moov = (muxer: IsobmffMuxer) => {
|
||||
return box('moov', undefined, [
|
||||
mvhd(muxer.creationTime, muxer.trackDatas),
|
||||
...muxer.trackDatas.map(x => trak(x, muxer.creationTime)),
|
||||
muxer.isFragmented ? mvex(muxer.trackDatas) : null,
|
||||
udta(muxer),
|
||||
]);
|
||||
};
|
||||
|
||||
/** Movie Header Box: Used to specify the characteristics of the entire movie, such as timescale and duration. */
|
||||
export const mvhd = (
|
||||
@@ -739,11 +741,13 @@ export const pasp = (trackData: IsobmffVideoTrackData) => {
|
||||
|
||||
/** Colour Information Box: Specifies the color space of the video. */
|
||||
export const colr = (trackData: IsobmffVideoTrackData) => box('colr', [
|
||||
ascii('nclx'), // Colour type
|
||||
ascii(trackData.muxer.isQuickTime ? 'nclc' : 'nclx'), // Colour type
|
||||
u16(COLOR_PRIMARIES_MAP[trackData.info.decoderConfig.colorSpace!.primaries!]), // Colour primaries
|
||||
u16(TRANSFER_CHARACTERISTICS_MAP[trackData.info.decoderConfig.colorSpace!.transfer!]), // Transfer characteristics
|
||||
u16(MATRIX_COEFFICIENTS_MAP[trackData.info.decoderConfig.colorSpace!.matrix!]), // Matrix coefficients
|
||||
u8((trackData.info.decoderConfig.colorSpace!.fullRange ? 1 : 0) << 7), // Full range flag
|
||||
trackData.muxer.isQuickTime
|
||||
? [] // Doesn't have it
|
||||
: u8((trackData.info.decoderConfig.colorSpace!.fullRange ? 1 : 0) << 7), // Full range flag
|
||||
]);
|
||||
|
||||
/** AVC Configuration Box: Provides additional information to the decoder. */
|
||||
|
||||
@@ -1448,20 +1448,24 @@ export class IsobmffDemuxer extends Demuxer {
|
||||
assert(track.info?.type === 'video');
|
||||
|
||||
const colourType = readAscii(slice, 4);
|
||||
if (colourType !== 'nclx') {
|
||||
if (colourType !== 'nclx' && colourType !== 'nclc') {
|
||||
break;
|
||||
}
|
||||
|
||||
const colourPrimaries = readU16Be(slice);
|
||||
const transferCharacteristics = readU16Be(slice);
|
||||
const matrixCoefficients = readU16Be(slice);
|
||||
const fullRangeFlag = Boolean(readU8(slice) & 0x80);
|
||||
|
||||
let fullRange: boolean | undefined = undefined;
|
||||
if (colourType === 'nclx') {
|
||||
fullRange = Boolean(readU8(slice) & 0x80);
|
||||
}
|
||||
|
||||
track.info.colorSpace = {
|
||||
primaries: COLOR_PRIMARIES_MAP_INVERSE[colourPrimaries],
|
||||
transfer: TRANSFER_CHARACTERISTICS_MAP_INVERSE[transferCharacteristics],
|
||||
matrix: MATRIX_COEFFICIENTS_MAP_INVERSE[matrixCoefficients],
|
||||
fullRange: fullRangeFlag,
|
||||
fullRange,
|
||||
} as VideoColorSpaceInit;
|
||||
}; break;
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
vtte,
|
||||
} from './isobmff-boxes';
|
||||
import { Muxer } from '../muxer';
|
||||
import { Output, OutputAudioTrack, OutputSubtitleTrack, OutputTrack, OutputVideoTrack } from '../output';
|
||||
import { Output, OutputAudioTrack, OutputSubtitleTrack, OutputTrack, OutputVideoTrack, TrackType } from '../output';
|
||||
import { Writer } from '../writer';
|
||||
import { BufferTarget } from '../target';
|
||||
import { assert, computeRationalApproximation, last, promiseWithResolvers, Rational, simplifyRational } from '../misc';
|
||||
@@ -1183,6 +1183,8 @@ export class IsobmffMuxer extends Muxer {
|
||||
boxWriter.writer.startTrackingWrites();
|
||||
}
|
||||
|
||||
this.ensureOneEnabledTrack();
|
||||
|
||||
// Write the moov box now that we have all decoder configs
|
||||
const movieBox = moov(this);
|
||||
boxWriter.writeBox(movieBox);
|
||||
@@ -1299,6 +1301,8 @@ export class IsobmffMuxer extends Muxer {
|
||||
|
||||
if (this.allTracksAreKnown()) {
|
||||
if (!this.mdat) {
|
||||
this.ensureOneEnabledTrack();
|
||||
|
||||
// We finally know all tracks, let's reserve space for the moov box
|
||||
const moovBox = moov(this);
|
||||
const moovSize = this.boxWriter.measureBox(moovBox);
|
||||
@@ -1389,11 +1393,34 @@ export class IsobmffMuxer extends Muxer {
|
||||
release();
|
||||
}
|
||||
|
||||
ensureOneEnabledTrack() {
|
||||
// If no track of a given type is enabled, force the first one to be enabled. Otherwise the video won't play in
|
||||
// players like QuickTime.
|
||||
// https://github.com/Vanilagy/mediabunny/pull/391
|
||||
for (const type of ['video', 'audio', 'subtitle'] as TrackType[]) {
|
||||
const tracks = this.trackDatas.filter(t => t.type === type);
|
||||
if (tracks.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasEnabled = tracks.some(t => t.track.metadata.disposition?.default !== false);
|
||||
if (!hasEnabled) {
|
||||
const firstTrack = tracks[0]!;
|
||||
|
||||
firstTrack.track.metadata.disposition = {
|
||||
...firstTrack.track.metadata.disposition,
|
||||
default: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Finalizes the file, making it ready for use. Must be called after all video and audio chunks have been added. */
|
||||
async finalize() {
|
||||
const release = await this.mutex.acquire();
|
||||
|
||||
this.allTracksKnown.resolve();
|
||||
this.ensureOneEnabledTrack();
|
||||
|
||||
for (const trackData of this.trackDatas) {
|
||||
trackData.closed = true;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import { parsePcmCodec, PCM_AUDIO_CODECS, PcmAudioCodec, VideoCodec, AudioCodec } from './codec';
|
||||
import {
|
||||
AvcNalUnitType,
|
||||
concatAvcNalUnits,
|
||||
deserializeAvcDecoderConfigurationRecord,
|
||||
determineVideoPacketType,
|
||||
@@ -997,6 +998,14 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
|
||||
|
||||
for (const loc of iterateAvcNalUnits(packet.data, this.decoderConfig)) {
|
||||
const type = extractNalUnitTypeForAvc(packet.data[loc.offset]!);
|
||||
|
||||
if (type === AvcNalUnitType.AUD) {
|
||||
// If packets contain an AUD and have NALUs before it, this trips up Chromium's key frame
|
||||
// detector. Clear the NALUs if an AUD is encountered.
|
||||
// https://github.com/Vanilagy/mediabunny/issues/396
|
||||
filteredNalUnits.length = 0;
|
||||
}
|
||||
|
||||
// These trip up Chromium's key frame detection, so let's strip them
|
||||
if (!(type >= 20 && type <= 31)) {
|
||||
filteredNalUnits.push(packet.data.subarray(loc.offset, loc.offset + loc.length));
|
||||
|
||||
+1
-1
@@ -524,7 +524,7 @@ export const mergeRequestInit = (init1: RequestInit, init2: RequestInit): Reques
|
||||
};
|
||||
|
||||
/** Normalizes HeadersInit to a Record<string, string> format. */
|
||||
const normalizeHeaders = (headers: HeadersInit): Record<string, string> => {
|
||||
export const normalizeHeaders = (headers: HeadersInit): Record<string, string> => {
|
||||
if (headers instanceof Headers) {
|
||||
const result: Record<string, string> = {};
|
||||
headers.forEach((value, key) => {
|
||||
|
||||
+9
-10
@@ -28,6 +28,7 @@ import {
|
||||
XING,
|
||||
XingFlags,
|
||||
computeAverageMp3FrameSize,
|
||||
getMp3ChannelCount,
|
||||
} from '../../shared/mp3-misc';
|
||||
import {
|
||||
ID3_V1_TAG_SIZE,
|
||||
@@ -108,7 +109,12 @@ export class Mp3Demuxer extends Demuxer {
|
||||
}
|
||||
}
|
||||
|
||||
const result = await readNextMp3FrameHeader(this.reader, this.lastLoadedPos, this.reader.fileSize);
|
||||
const result = await readNextMp3FrameHeader(
|
||||
this.reader,
|
||||
this.lastLoadedPos,
|
||||
this.reader.fileSize,
|
||||
this.firstFrameHeader,
|
||||
);
|
||||
if (!result) {
|
||||
this.lastSampleLoaded = true;
|
||||
return;
|
||||
@@ -157,13 +163,6 @@ export class Mp3Demuxer extends Demuxer {
|
||||
this.firstFrameHeaderPos = result.startPos;
|
||||
}
|
||||
|
||||
if (header.sampleRate !== this.firstFrameHeader.sampleRate) {
|
||||
console.warn(
|
||||
`MP3 changed sample rate mid-file: ${this.firstFrameHeader.sampleRate} Hz to ${header.sampleRate} Hz.`
|
||||
+ ` Might be a bug, so please report this file.`,
|
||||
);
|
||||
}
|
||||
|
||||
const sampleDuration = header.audioSamplesInFrame / this.firstFrameHeader.sampleRate;
|
||||
const sample: Sample = {
|
||||
timestamp: this.nextTimestampInSamples / this.firstFrameHeader.sampleRate,
|
||||
@@ -332,7 +331,7 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking {
|
||||
|
||||
getNumberOfChannels() {
|
||||
assert(this.demuxer.firstFrameHeader);
|
||||
return this.demuxer.firstFrameHeader.channel === 3 ? 1 : 2;
|
||||
return getMp3ChannelCount(this.demuxer.firstFrameHeader.channel);
|
||||
}
|
||||
|
||||
getSampleRate() {
|
||||
@@ -351,7 +350,7 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking {
|
||||
|
||||
return {
|
||||
codec: 'mp3',
|
||||
numberOfChannels: this.demuxer.firstFrameHeader.channel === 3 ? 1 : 2,
|
||||
numberOfChannels: getMp3ChannelCount(this.demuxer.firstFrameHeader.channel),
|
||||
sampleRate: this.demuxer.firstFrameHeader.sampleRate,
|
||||
};
|
||||
}
|
||||
|
||||
+21
-6
@@ -6,10 +6,15 @@
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { FRAME_HEADER_SIZE, Mp3FrameHeader, readMp3FrameHeader } from '../../shared/mp3-misc';
|
||||
import { MP3_FRAME_HEADER_SIZE, getMp3ChannelCount, Mp3FrameHeader, readMp3FrameHeader } from '../../shared/mp3-misc';
|
||||
import { Reader, readU32Be } from '../reader';
|
||||
|
||||
export const readNextMp3FrameHeader = async (reader: Reader, startPos: number, until: number | null): Promise<{
|
||||
export const readNextMp3FrameHeader = async (
|
||||
reader: Reader,
|
||||
startPos: number,
|
||||
until: number | null,
|
||||
ref: Mp3FrameHeader | null = null,
|
||||
): Promise<{
|
||||
header: Mp3FrameHeader;
|
||||
startPos: number;
|
||||
} | null> => {
|
||||
@@ -21,11 +26,11 @@ export const readNextMp3FrameHeader = async (reader: Reader, startPos: number, u
|
||||
? Math.min(CHUNK_SIZE, until - currentPos)
|
||||
: CHUNK_SIZE;
|
||||
|
||||
let slice = reader.requestSliceRange(currentPos, FRAME_HEADER_SIZE, maxLength);
|
||||
let slice = reader.requestSliceRange(currentPos, MP3_FRAME_HEADER_SIZE, maxLength);
|
||||
if (slice instanceof Promise) slice = await slice;
|
||||
if (!slice || slice.length < FRAME_HEADER_SIZE) break;
|
||||
if (!slice || slice.length < MP3_FRAME_HEADER_SIZE) break;
|
||||
|
||||
while (slice.remainingLength >= FRAME_HEADER_SIZE) {
|
||||
while (slice.remainingLength >= MP3_FRAME_HEADER_SIZE) {
|
||||
const posBeforeRead = slice.filePos;
|
||||
const word = readU32Be(slice);
|
||||
const remainingBytes = reader.fileSize !== null
|
||||
@@ -33,7 +38,17 @@ export const readNextMp3FrameHeader = async (reader: Reader, startPos: number, u
|
||||
: null;
|
||||
|
||||
const result = readMp3FrameHeader(word, remainingBytes);
|
||||
if (result.header) {
|
||||
if (
|
||||
result.header
|
||||
&& (!ref || (
|
||||
// This condition helps us recover malformed streams
|
||||
// https://stackoverflow.com/a/20884944
|
||||
result.header.sampleRate === ref.sampleRate
|
||||
&& result.header.mpegVersionId === ref.mpegVersionId
|
||||
&& result.header.layer === ref.layer
|
||||
&& getMp3ChannelCount(result.header.channel) === getMp3ChannelCount(ref.channel)
|
||||
))
|
||||
) {
|
||||
return { header: result.header, startPos: currentPos };
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,11 @@ import {
|
||||
TRANSFER_CHARACTERISTICS_MAP_INVERSE,
|
||||
UNDETERMINED_LANGUAGE,
|
||||
} from '../misc';
|
||||
import { FRAME_HEADER_SIZE as MP3_FRAME_HEADER_SIZE, readMp3FrameHeader } from '../../shared/mp3-misc';
|
||||
import {
|
||||
MP3_FRAME_HEADER_SIZE,
|
||||
getMp3ChannelCount,
|
||||
readMp3FrameHeader,
|
||||
} from '../../shared/mp3-misc';
|
||||
import { EncodedPacket, PacketType, PLACEHOLDER_DATA } from '../packet';
|
||||
import { FileSlice, readBytes, Reader, readU16Be, readU32Be, readU8 } from '../reader';
|
||||
import { buildMpegTsMimeType, MpegTsStreamType, TIMESCALE, TS_PACKET_SIZE } from './mpeg-ts-misc';
|
||||
@@ -629,7 +633,7 @@ export class MpegTsDemuxer extends Demuxer {
|
||||
);
|
||||
}
|
||||
|
||||
elementaryStream.info.numberOfChannels = result.header.channel === 3 ? 1 : 2;
|
||||
elementaryStream.info.numberOfChannels = getMp3ChannelCount(result.header.channel);
|
||||
elementaryStream.info.sampleRate = result.header.sampleRate;
|
||||
} else if (elementaryStream.info.codec === 'ac3') {
|
||||
const frameInfo = parseAc3SyncFrame(context.suppliedPacket.data);
|
||||
|
||||
+5
-4
@@ -120,15 +120,16 @@ export class Reader {
|
||||
return this.requestSlice(0, this.fileSizeNonStrict);
|
||||
}
|
||||
|
||||
const startOffset = chunks.length * CHUNK_SIZE;
|
||||
let slice = this.requestSliceRange(startOffset, 0, CHUNK_SIZE);
|
||||
let slice = this.requestSliceRange(currentSize, 0, CHUNK_SIZE);
|
||||
if (slice instanceof Promise) slice = await slice;
|
||||
|
||||
if (!slice) {
|
||||
if (!slice || slice.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
chunks.push(readBytes(slice, slice.length));
|
||||
const chunk = readBytes(slice, slice.length);
|
||||
chunks.push(chunk);
|
||||
|
||||
currentSize += slice.length;
|
||||
}
|
||||
|
||||
|
||||
+205
-44
@@ -17,6 +17,7 @@ import {
|
||||
isWebKit,
|
||||
MaybePromise,
|
||||
mergeRequestInit,
|
||||
normalizeHeaders,
|
||||
polyfillSymbolDispose,
|
||||
promiseWithResolvers,
|
||||
retriedFetch,
|
||||
@@ -59,6 +60,13 @@ export type SourceEvents = {
|
||||
};
|
||||
};
|
||||
|
||||
let sourceFinalizationRegistry: FinalizationRegistry<() => unknown> | null = null;
|
||||
if (typeof FinalizationRegistry !== 'undefined') {
|
||||
sourceFinalizationRegistry = new FinalizationRegistry((cleanup) => {
|
||||
cleanup();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The source base class, representing a resource from which bytes can be read.
|
||||
* @group Input sources
|
||||
@@ -85,10 +93,27 @@ export abstract class Source extends EventEmitter<SourceEvents> {
|
||||
* @internal
|
||||
*/
|
||||
_usedForHls = false;
|
||||
/**
|
||||
* FinalizationRegistry for rogue refs to this source that didn't get freed. It lives on the Source itself so that
|
||||
* in case the Source transitively points back to itself and forms a cycle (for example through a custom
|
||||
* CustomSource callback) that we're not leaking memory.
|
||||
* @internal
|
||||
*/
|
||||
_refFinalizationRegistry: FinalizationRegistry<Source> | null = null;
|
||||
|
||||
/** @internal */
|
||||
private _sizePromise: Promise<number | null> | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
if (typeof FinalizationRegistry !== 'undefined') {
|
||||
this._refFinalizationRegistry = new FinalizationRegistry((source) => {
|
||||
source._decrementRefCount();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves with the total size of the file in bytes. This function is memoized, meaning only the first call
|
||||
* will retrieve the size.
|
||||
@@ -171,6 +196,21 @@ export abstract class Source extends EventEmitter<SourceEvents> {
|
||||
ref() {
|
||||
return new SourceRef(this);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
_incrementRefCount() {
|
||||
this._refCount++;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
_decrementRefCount() {
|
||||
this._refCount--;
|
||||
|
||||
if (this._refCount === 0) {
|
||||
this._dispose();
|
||||
this._disposed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -193,7 +233,9 @@ export class SourceRef<S extends Source = Source> implements Disposable {
|
||||
throw new Error('Cannot ref a disposed source.');
|
||||
}
|
||||
|
||||
source._refCount++;
|
||||
source._incrementRefCount();
|
||||
source._refFinalizationRegistry?.register(this, source, this);
|
||||
|
||||
this._source = source;
|
||||
}
|
||||
|
||||
@@ -223,12 +265,8 @@ export class SourceRef<S extends Source = Source> implements Disposable {
|
||||
const source = this.source;
|
||||
assert(source._refCount > 0);
|
||||
|
||||
source._refCount--;
|
||||
|
||||
if (source._refCount === 0) {
|
||||
source._dispose();
|
||||
source._disposed = true;
|
||||
}
|
||||
source._decrementRefCount();
|
||||
source._refFinalizationRegistry?.unregister(this);
|
||||
|
||||
this._freed = true;
|
||||
this._source = null;
|
||||
@@ -638,10 +676,10 @@ export type UrlSourceOptions = {
|
||||
* The [`RequestInit`](https://developer.mozilla.org/en-US/docs/Web/API/RequestInit) used by the Fetch API. Can be
|
||||
* used to further control the requests, such as setting custom headers.
|
||||
*
|
||||
* All fields will work except for `signal` and `headers.Range`; these will be overridden by Mediabunny. If you want
|
||||
* to cancel ongoing requests, use {@link Input.dispose}.
|
||||
* The `signal` field is not available, as Mediabunny controls request cancellation internally. If you want to
|
||||
* cancel ongoing requests, use {@link Input.dispose}.
|
||||
*/
|
||||
requestInit?: RequestInit;
|
||||
requestInit?: Omit<RequestInit, 'signal'>;
|
||||
|
||||
/**
|
||||
* A function that returns the delay (in seconds) before retrying a failed request. The function is called
|
||||
@@ -680,6 +718,12 @@ export class UrlSource extends PathedSource {
|
||||
/** @internal */
|
||||
_options: UrlSourceOptions;
|
||||
/** @internal */
|
||||
_requestInit: RequestInit;
|
||||
/** @internal */
|
||||
_offset = 0;
|
||||
/** @internal */
|
||||
_length: number | null = null;
|
||||
/** @internal */
|
||||
_orchestrator: ReadOrchestrator;
|
||||
/**
|
||||
* Note that this value being true does NOT mean the file size can't change anymore; it just signals that we have at
|
||||
@@ -691,8 +735,8 @@ export class UrlSource extends PathedSource {
|
||||
/**
|
||||
* Creates a new {@link UrlSource} backed by the resource at the specified URL.
|
||||
*
|
||||
* When passing a `Request` instance, note that the `signal` and `headers.Range` options will be overridden by
|
||||
* Mediabunny. If you want to cancel ongoing requests, use {@link Input.dispose}.
|
||||
* When passing a `Request` instance, note that its `signal` will be overridden by Mediabunny; if you want to cancel
|
||||
* ongoing requests, use {@link Input.dispose}.
|
||||
*/
|
||||
constructor(
|
||||
url: string | URL | Request,
|
||||
@@ -740,6 +784,42 @@ export class UrlSource extends PathedSource {
|
||||
this._options = options;
|
||||
this._getRetryDelay = options.getRetryDelay ?? DEFAULT_RETRY_DELAY;
|
||||
|
||||
// A user-supplied Range header is interpreted as a byte offset (and optional length) into the resource. We
|
||||
// pull it out of the request and remember it for subsequent requests.
|
||||
this._requestInit = { ...options.requestInit };
|
||||
|
||||
let rangeHeaderValue: string | null = null;
|
||||
|
||||
if (options.requestInit?.headers) {
|
||||
const headers = { ...normalizeHeaders(options.requestInit.headers) };
|
||||
const rangeKey = Object.keys(headers).find(key => key.toLowerCase() === 'range');
|
||||
if (rangeKey !== undefined) {
|
||||
rangeHeaderValue = headers[rangeKey]!;
|
||||
delete headers[rangeKey];
|
||||
this._requestInit.headers = headers;
|
||||
}
|
||||
}
|
||||
|
||||
if (url instanceof Request) {
|
||||
const requestRange = url.headers.get('Range');
|
||||
if (requestRange !== null) {
|
||||
rangeHeaderValue ??= requestRange;
|
||||
|
||||
// Clone the request so we don't mutate the user's object, then strip the Range header
|
||||
const strippedRequest = new Request(url);
|
||||
strippedRequest.headers.delete('Range');
|
||||
this._url = strippedRequest;
|
||||
}
|
||||
}
|
||||
|
||||
if (rangeHeaderValue !== null) {
|
||||
const parsed = parseByteRangeHeader(rangeHeaderValue);
|
||||
if (parsed) {
|
||||
this._offset = parsed.offset;
|
||||
this._length = parsed.length;
|
||||
}
|
||||
}
|
||||
|
||||
// Most files in the real-world have a single sequential access pattern, but having two in parallel can
|
||||
// also happen
|
||||
const DEFAULT_PARALLELISM = 2;
|
||||
@@ -754,9 +834,16 @@ export class UrlSource extends PathedSource {
|
||||
|
||||
/** @internal */
|
||||
_getFileSize(): number | null | undefined {
|
||||
return this._fileSizeDetermined
|
||||
? this._orchestrator.fileSize
|
||||
: undefined;
|
||||
if (!this._fileSizeDetermined) {
|
||||
return this._length !== null ? this._length : undefined;
|
||||
}
|
||||
|
||||
const baseSize = this._orchestrator.fileSize;
|
||||
if (baseSize === null) {
|
||||
return this._length !== null ? this._length : null;
|
||||
}
|
||||
|
||||
return clamp(baseSize - this._offset, 0, this._length ?? Infinity);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
@@ -766,7 +853,32 @@ export class UrlSource extends PathedSource {
|
||||
minReadPosition: number,
|
||||
maxReadPosition: number,
|
||||
): MaybePromise<ReadResult | null> {
|
||||
return this._orchestrator.read(start, end, minReadPosition, maxReadPosition);
|
||||
if (this._length !== null && end > this._length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const offset = this._offset;
|
||||
const result = this._orchestrator.read(
|
||||
offset + start,
|
||||
offset + end,
|
||||
Math.max(offset + minReadPosition, offset),
|
||||
offset + Math.min(maxReadPosition, this._length ?? Infinity),
|
||||
);
|
||||
|
||||
const processResult = (result: ReadResult | null) => {
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
result.offset -= this._offset;
|
||||
return result;
|
||||
};
|
||||
|
||||
if (result instanceof Promise) {
|
||||
return result.then(processResult);
|
||||
} else {
|
||||
return processResult(result);
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
@@ -777,7 +889,7 @@ export class UrlSource extends PathedSource {
|
||||
const response = await retriedFetch(
|
||||
this._options.fetchFn ?? fetch,
|
||||
this._url,
|
||||
mergeRequestInit(this._options.requestInit ?? {}, {
|
||||
mergeRequestInit(this._requestInit, {
|
||||
headers: {
|
||||
// Always sending a range request is a good way to probe if the server supports them
|
||||
Range: `bytes=${worker.currentPos}-`,
|
||||
@@ -935,6 +1047,26 @@ export class UrlSource extends PathedSource {
|
||||
}
|
||||
}
|
||||
|
||||
const BYTE_RANGE_REGEX = /^bytes=(\d+)-(\d*)$/;
|
||||
const parseByteRangeHeader = (value: string) => {
|
||||
const match = BYTE_RANGE_REGEX.exec(value.trim());
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const offset = Number(match[1]);
|
||||
const end = match[2] === '' ? null : Number(match[2]);
|
||||
|
||||
if (end !== null && end < offset) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
offset,
|
||||
length: end !== null ? end - offset + 1 : null,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Options for {@link FilePathSource}.
|
||||
* @group Input sources
|
||||
@@ -955,7 +1087,7 @@ export type FilePathSourceOptions = {
|
||||
*/
|
||||
export class FilePathSource extends PathedSource {
|
||||
/** @internal */
|
||||
_streamSource: StreamSource;
|
||||
_customSource: CustomSource;
|
||||
/** @internal */
|
||||
_fileHandle: FileHandle | null = null;
|
||||
|
||||
@@ -982,12 +1114,18 @@ export class FilePathSource extends PathedSource {
|
||||
|
||||
super(filePath, request => new FilePathSource(request.path, options));
|
||||
|
||||
// Let's back this source with a StreamSource, makes the implementation very simple
|
||||
this._streamSource = new StreamSource({
|
||||
// Let's back this source with a CustomSource, makes the implementation very simple
|
||||
this._customSource = new CustomSource({
|
||||
getSize: async () => {
|
||||
this._fileHandle = await node.fs.open(filePath, 'r');
|
||||
const fileHandle = await node.fs.open(filePath, 'r');
|
||||
this._fileHandle = fileHandle;
|
||||
|
||||
const stats = await this._fileHandle.stat();
|
||||
sourceFinalizationRegistry?.register(this, () => {
|
||||
// If it's not closed, Node prints annoying warnings
|
||||
void fileHandle.close();
|
||||
}, this);
|
||||
|
||||
const stats = await fileHandle.stat();
|
||||
return stats.size;
|
||||
},
|
||||
read: async (start, end) => {
|
||||
@@ -1010,28 +1148,32 @@ export class FilePathSource extends PathedSource {
|
||||
minReadPosition: number,
|
||||
maxReadPosition: number,
|
||||
): MaybePromise<ReadResult | null> {
|
||||
return this._streamSource._read(start, end, minReadPosition, maxReadPosition);
|
||||
return this._customSource._read(start, end, minReadPosition, maxReadPosition);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
_getFileSize(): number | null | undefined {
|
||||
return this._streamSource._getFileSize();
|
||||
return this._customSource._getFileSize();
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
_dispose() {
|
||||
this._streamSource._dispose();
|
||||
void this._fileHandle?.close();
|
||||
this._fileHandle = null;
|
||||
this._customSource._dispose();
|
||||
|
||||
if (this._fileHandle) {
|
||||
void this._fileHandle.close();
|
||||
this._fileHandle = null;
|
||||
sourceFinalizationRegistry?.unregister(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for defining a {@link StreamSource}.
|
||||
* Options for defining a {@link CustomSource}.
|
||||
* @group Input sources
|
||||
* @public
|
||||
*/
|
||||
export type StreamSourceOptions = {
|
||||
export type CustomSourceOptions = {
|
||||
/**
|
||||
* Called when the size of the entire file is requested. Must return or resolve to the size in bytes. This function
|
||||
* is guaranteed to be called before `read`.
|
||||
@@ -1041,6 +1183,8 @@ export type StreamSourceOptions = {
|
||||
/**
|
||||
* Called when data is requested. Must return or resolve to the bytes from the specified byte range, or a stream
|
||||
* that yields these bytes.
|
||||
*
|
||||
* You are guaranteed that `0 <= start < end < fileSize`.
|
||||
*/
|
||||
read: (start: number, end: number) => MaybePromise<Uint8Array | ReadableStream<Uint8Array>>;
|
||||
|
||||
@@ -1067,18 +1211,19 @@ export type StreamSourceOptions = {
|
||||
};
|
||||
|
||||
/**
|
||||
* A general-purpose, callback-driven source that can get its data from anywhere.
|
||||
* A general-purpose, callback-driven source that can get its data from anywhere. Use this source to implement your own
|
||||
* custom source if the other sources don't cover your case.
|
||||
* @group Input sources
|
||||
* @public
|
||||
*/
|
||||
export class StreamSource extends Source {
|
||||
export class CustomSource extends Source {
|
||||
/** @internal */
|
||||
_options: StreamSourceOptions;
|
||||
_options: CustomSourceOptions;
|
||||
/** @internal */
|
||||
_orchestrator: ReadOrchestrator;
|
||||
|
||||
/** Creates a new {@link StreamSource} whose behavior is specified by `options`. */
|
||||
constructor(options: StreamSourceOptions) {
|
||||
/** Creates a new {@link CustomSource} whose behavior is specified by `options`. */
|
||||
constructor(options: CustomSourceOptions) {
|
||||
if (!options || typeof options !== 'object') {
|
||||
throw new TypeError('options must be an object.');
|
||||
}
|
||||
@@ -1226,6 +1371,25 @@ export class StreamSource extends Source {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An alias for {@link CustomSource}.
|
||||
* @deprecated This name is misleading and will be removed in a future release. Please use {@link CustomSource} instead.
|
||||
*
|
||||
* @group Input sources
|
||||
* @public
|
||||
*/
|
||||
export const StreamSource = CustomSource;
|
||||
|
||||
/**
|
||||
* An alias for {@link CustomSourceOptions}.
|
||||
* @deprecated This name is misleading and will be removed in a future release. Please use
|
||||
* {@link CustomSourceOptions} instead.
|
||||
*
|
||||
* @group Input sources
|
||||
* @public
|
||||
*/
|
||||
export type StreamSourceOptions = CustomSourceOptions;
|
||||
|
||||
type ReadableStreamSourcePendingSlice = {
|
||||
start: number;
|
||||
end: number;
|
||||
@@ -2353,22 +2517,19 @@ export class RangedSource extends Source {
|
||||
this._offset + maxReadPosition,
|
||||
);
|
||||
|
||||
if (result instanceof Promise) {
|
||||
return result.then((result) => {
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
result.offset -= this._offset;
|
||||
return result;
|
||||
});
|
||||
} else {
|
||||
const processResult = (result: ReadResult | null) => {
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
result.offset -= this._offset;
|
||||
return result;
|
||||
};
|
||||
|
||||
if (result instanceof Promise) {
|
||||
return result.then(processResult);
|
||||
} else {
|
||||
return processResult(result);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { test } from 'vitest';
|
||||
import { Input } from '../../src/input.js';
|
||||
import { UrlSource } from '../../src/source.js';
|
||||
import { ALL_FORMATS } from '../../src/input-format.js';
|
||||
import { assert } from '../../src/misc.js';
|
||||
import { AudioSampleSink } from '../../src/media-sink.js';
|
||||
|
||||
// "joined" in the sense that it was two separate MP3s that were spliced together (I think)
|
||||
test('Can decode malformed joined MP3', async () => {
|
||||
using input = new Input({
|
||||
source: new UrlSource('/malformed-join.mp3'),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const audioTrack = await input.getPrimaryAudioTrack();
|
||||
assert(audioTrack);
|
||||
|
||||
const sink = new AudioSampleSink(audioTrack);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
for await (using sample of sink.samples());
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { UrlSource } from '../../src/source.js';
|
||||
import { ALL_FORMATS } from '../../src/input-format.js';
|
||||
import { Input } from '../../src/input.js';
|
||||
|
||||
test('Should be able to load a very small video file via URL (<512 kB)', async () => {
|
||||
const source = new UrlSource('/frames.webm');
|
||||
using input = new Input({
|
||||
source,
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
const primaryVideoTrack = await input.getPrimaryVideoTrack();
|
||||
if (!primaryVideoTrack) {
|
||||
throw new Error('No video track found');
|
||||
};
|
||||
|
||||
const duration = await primaryVideoTrack.computeDuration();
|
||||
expect(duration).toBeCloseTo(3.33333);
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { UrlSource } from '../../src/source.js';
|
||||
import { ALL_FORMATS } from '../../src/input-format.js';
|
||||
import { Input } from '../../src/input.js';
|
||||
import { Reader, readBytes } from '../../src/reader.js';
|
||||
|
||||
test('Should be able to load a very small video file via URL (<512 kB)', async () => {
|
||||
const source = new UrlSource('/frames.webm');
|
||||
using input = new Input({
|
||||
source,
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
const primaryVideoTrack = await input.getPrimaryVideoTrack();
|
||||
if (!primaryVideoTrack) {
|
||||
throw new Error('No video track found');
|
||||
};
|
||||
|
||||
const duration = await primaryVideoTrack.computeDuration();
|
||||
expect(duration).toBeCloseTo(3.33333);
|
||||
});
|
||||
|
||||
test('requestInit with Range', async () => {
|
||||
const url = makeRampedUrl();
|
||||
|
||||
const source = new UrlSource(url, {
|
||||
requestInit: {
|
||||
headers: { Range: 'bytes=10-' },
|
||||
},
|
||||
});
|
||||
const reader = new Reader(source);
|
||||
|
||||
const slice = await reader.requestSlice(0, 5);
|
||||
expect(slice).not.toBeNull();
|
||||
|
||||
expect([...readBytes(slice!, 5)]).toEqual([10, 11, 12, 13, 14]);
|
||||
|
||||
expect(reader.fileSize).toBe(256 - 10);
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
test('Request with Range', async () => {
|
||||
const url = makeRampedUrl();
|
||||
|
||||
const request = new Request(url, {
|
||||
headers: { Range: 'bytes=20-29' },
|
||||
});
|
||||
|
||||
const source = new UrlSource(request);
|
||||
const reader = new Reader(source);
|
||||
|
||||
const slice = await reader.requestSlice(2, 4);
|
||||
expect(slice).not.toBeNull();
|
||||
|
||||
expect([...readBytes(slice!, 4)]).toEqual([22, 23, 24, 25]);
|
||||
|
||||
expect(reader.fileSize).toBe(10);
|
||||
|
||||
expect(await reader.requestSlice(8, 4)).toBeNull();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
const makeRampedUrl = () => {
|
||||
const data = new Uint8Array(256);
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
data[i] = i;
|
||||
}
|
||||
|
||||
const blob = new Blob([data.buffer]);
|
||||
return URL.createObjectURL(blob);
|
||||
};
|
||||
@@ -352,3 +352,38 @@ test('PCM audio, no silence padding with approximate timestamps', async () => {
|
||||
|
||||
expect(frameCount).toBe(expectedFrameCount);
|
||||
});
|
||||
|
||||
// https://github.com/Vanilagy/mediabunny/pull/391
|
||||
test('At least one track is enabled even if all are added disabled', async () => {
|
||||
const output = new Output({
|
||||
format: new Mp4OutputFormat(),
|
||||
target: new BufferTarget(),
|
||||
});
|
||||
|
||||
const meta = { decoderConfig: { codec: 'vp8', codedWidth: 1280, codedHeight: 720 } };
|
||||
|
||||
const source1 = new EncodedVideoPacketSource('vp8');
|
||||
output.addVideoTrack(source1, { disposition: { default: false } });
|
||||
|
||||
const source2 = new EncodedVideoPacketSource('vp8');
|
||||
output.addVideoTrack(source2, { disposition: { default: false } });
|
||||
|
||||
await output.start();
|
||||
|
||||
await source1.add(new EncodedPacket(new Uint8Array(1024), 'key', 0, 0.1), meta);
|
||||
await source2.add(new EncodedPacket(new Uint8Array(1024), 'key', 0, 0.1), meta);
|
||||
|
||||
await output.finalize();
|
||||
|
||||
using input = new Input({
|
||||
source: new BufferSource(output.target.buffer!),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const tracks = await input.getVideoTracks();
|
||||
expect(tracks.length).toBe(2);
|
||||
|
||||
// Even though both tracks were added disabled, the muxer forces the first one to be enabled
|
||||
expect((await tracks[0]!.getDisposition()).default).toBe(true);
|
||||
expect((await tracks[1]!.getDisposition()).default).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { Input } from '../../src/input.js';
|
||||
import { FilePathSource, ReadableStreamSource, StreamSource } from '../../src/source.js';
|
||||
import { FilePathSource, ReadableStreamSource, CustomSource } from '../../src/source.js';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { Readable } from 'node:stream';
|
||||
@@ -759,7 +759,7 @@ test('MPEG-TS partial reading', async () => {
|
||||
let maxEnd = 0;
|
||||
|
||||
using input = new Input({
|
||||
source: new StreamSource({
|
||||
source: new CustomSource({
|
||||
getSize: () => {
|
||||
return buffer.byteLength;
|
||||
},
|
||||
|
||||
+108
-1
@@ -1,6 +1,19 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import path from 'node:path';
|
||||
import { ALL_FORMATS, MP4, EncodedPacketSink, Input, FilePathSource } from '../../src/index.js';
|
||||
import {
|
||||
ALL_FORMATS,
|
||||
BufferSource,
|
||||
BufferTarget,
|
||||
EncodedPacket,
|
||||
EncodedPacketSink,
|
||||
EncodedVideoPacketSource,
|
||||
FilePathSource,
|
||||
Input,
|
||||
MP4,
|
||||
MovOutputFormat,
|
||||
Mp4OutputFormat,
|
||||
Output,
|
||||
} from '../../src/index.js';
|
||||
|
||||
const __dirname = new URL('.', import.meta.url).pathname;
|
||||
|
||||
@@ -33,3 +46,97 @@ test('Should be able to get packets from a .MP4 file', async () => {
|
||||
0, 0.16, 0.08, 0.04, 0.12, 0.32, 0.24, 0.2, 0.28, 0.48,
|
||||
]);
|
||||
});
|
||||
|
||||
test('MP4 nclx color information', async () => {
|
||||
const output = new Output({
|
||||
format: new Mp4OutputFormat(),
|
||||
target: new BufferTarget(),
|
||||
});
|
||||
const source = new EncodedVideoPacketSource('vp8');
|
||||
output.addVideoTrack(source);
|
||||
|
||||
await output.start();
|
||||
await source.add(
|
||||
new EncodedPacket(new Uint8Array(1024), 'key', 0, 0.1),
|
||||
{
|
||||
decoderConfig: {
|
||||
codec: 'vp8',
|
||||
codedWidth: 1280,
|
||||
codedHeight: 720,
|
||||
colorSpace: {
|
||||
primaries: 'bt2020' as VideoColorPrimaries,
|
||||
transfer: 'pq' as VideoTransferCharacteristics,
|
||||
matrix: 'bt2020-ncl' as VideoMatrixCoefficients,
|
||||
fullRange: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
await output.finalize();
|
||||
|
||||
const str = String.fromCharCode(...new Uint8Array(output.target.buffer!));
|
||||
expect(str.includes('nclc')).toBe(false);
|
||||
expect(str.includes('nclx')).toBe(true);
|
||||
|
||||
using input = new Input({
|
||||
source: new BufferSource(output.target.buffer!),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
const track = await input.getPrimaryVideoTrack();
|
||||
if (!track) throw new Error('No video track found');
|
||||
|
||||
expect(await track.getColorSpace()).toEqual({
|
||||
primaries: 'bt2020',
|
||||
transfer: 'pq',
|
||||
matrix: 'bt2020-ncl',
|
||||
fullRange: false,
|
||||
});
|
||||
expect(await track.hasHighDynamicRange()).toBe(true);
|
||||
});
|
||||
|
||||
test('QuickTime nclc color information', async () => {
|
||||
const output = new Output({
|
||||
format: new MovOutputFormat(),
|
||||
target: new BufferTarget(),
|
||||
});
|
||||
const source = new EncodedVideoPacketSource('vp8');
|
||||
output.addVideoTrack(source);
|
||||
|
||||
await output.start();
|
||||
await source.add(
|
||||
new EncodedPacket(new Uint8Array(1024), 'key', 0, 0.1),
|
||||
{
|
||||
decoderConfig: {
|
||||
codec: 'vp8',
|
||||
codedWidth: 1280,
|
||||
codedHeight: 720,
|
||||
colorSpace: {
|
||||
primaries: 'bt2020' as VideoColorPrimaries,
|
||||
transfer: 'pq' as VideoTransferCharacteristics,
|
||||
matrix: 'bt2020-ncl' as VideoMatrixCoefficients,
|
||||
fullRange: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
await output.finalize();
|
||||
|
||||
const str = String.fromCharCode(...new Uint8Array(output.target.buffer!));
|
||||
expect(str.includes('nclc')).toBe(true);
|
||||
expect(str.includes('nclx')).toBe(false);
|
||||
|
||||
using input = new Input({
|
||||
source: new BufferSource(output.target.buffer!),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
const track = await input.getPrimaryVideoTrack();
|
||||
if (!track) throw new Error('No video track found');
|
||||
|
||||
expect(await track.getColorSpace()).toEqual({
|
||||
primaries: 'bt2020',
|
||||
transfer: 'pq',
|
||||
matrix: 'bt2020-ncl',
|
||||
fullRange: undefined,
|
||||
});
|
||||
expect(await track.hasHighDynamicRange()).toBe(true);
|
||||
});
|
||||
|
||||
@@ -871,6 +871,88 @@ describe('Video', async () => {
|
||||
expect(plane[3]).toBe(0xff);
|
||||
});
|
||||
|
||||
// https://github.com/Vanilagy/mediabunny/issues/392
|
||||
test('Memory doesn\'t leak', async () => {
|
||||
const encoder = new NodeAvVideoEncoder();
|
||||
// @ts-expect-error Readonly
|
||||
encoder.codec = 'avc';
|
||||
// @ts-expect-error Readonly
|
||||
encoder.config = {
|
||||
codec: buildVideoCodecString('avc', 1920, 1080, 1e6),
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
bitrate: 1e6,
|
||||
} satisfies VideoEncoderConfig;
|
||||
|
||||
const pendingPackets: EncodedPacket[] = [];
|
||||
const decodedFrames: VideoSample[] = [];
|
||||
let decoderConfig: VideoDecoderConfig | null = null;
|
||||
|
||||
// @ts-expect-error Readonly
|
||||
encoder.onPacket = (packet: EncodedPacket, meta: EncodedVideoChunkMetadata) => {
|
||||
if (meta.decoderConfig) {
|
||||
decoderConfig = meta.decoderConfig;
|
||||
}
|
||||
pendingPackets.push(packet);
|
||||
};
|
||||
|
||||
await encoder.init();
|
||||
|
||||
// Encode a single white frame to get one packet (and the decoder config) to drive the loop with
|
||||
const data = new Uint8Array(1920 * 1080 * 4).fill(0xff); // White
|
||||
using bootstrapSample = new VideoSample(data, {
|
||||
format: 'RGBX',
|
||||
codedWidth: 1920,
|
||||
codedHeight: 1080,
|
||||
timestamp: 0,
|
||||
duration: 1 / 30,
|
||||
});
|
||||
await encoder.encode(bootstrapSample, {});
|
||||
await encoder.flush();
|
||||
assert(decoderConfig);
|
||||
assert(pendingPackets.length > 0);
|
||||
|
||||
const sourcePacket = pendingPackets[0]!;
|
||||
pendingPackets.length = 0;
|
||||
|
||||
const decoder = new NodeAvVideoDecoder();
|
||||
// @ts-expect-error Readonly
|
||||
decoder.codec = 'avc';
|
||||
// @ts-expect-error Readonly
|
||||
decoder.config = decoderConfig;
|
||||
// @ts-expect-error Readonly
|
||||
decoder.onSample = (sample: VideoSample) => {
|
||||
decodedFrames.push(sample);
|
||||
};
|
||||
await decoder.init();
|
||||
|
||||
const iterations = 300;
|
||||
let baselineRss = 0;
|
||||
|
||||
for (let i = 1; i <= iterations; i++) {
|
||||
await decoder.decode(sourcePacket.clone({ timestamp: i / 30 }));
|
||||
|
||||
for (const frame of decodedFrames) {
|
||||
await encoder.encode(frame, {});
|
||||
frame.close();
|
||||
}
|
||||
decodedFrames.length = 0;
|
||||
pendingPackets.length = 0;
|
||||
|
||||
if (i === 100) { // Should have stabilized by then
|
||||
baselineRss = process.memoryUsage().rss;
|
||||
}
|
||||
}
|
||||
|
||||
const finalRss = process.memoryUsage().rss;
|
||||
const growth = finalRss - baselineRss;
|
||||
|
||||
expect(growth).toBeLessThan(10 * 1024 * 1024);
|
||||
|
||||
await encoder.close();
|
||||
await decoder.close();
|
||||
});
|
||||
|
||||
describe('VideoSample transformation', () => {
|
||||
// 400x400 image: red everywhere, with a 200x200 blue square filling the bottom-left quadrant.
|
||||
const TEST_IMAGE = (() => {
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user