mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-09 08:43:49 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49af8d5435 | ||
|
|
a09cb4aebf | ||
|
|
0500beaf34 | ||
|
|
f424f6e840 | ||
|
|
d949d1eadb | ||
|
|
11970675c6 | ||
|
|
d6c223b0f6 | ||
|
|
5b703a1c78 | ||
|
|
3dd7a9cb58 | ||
|
|
7b3f5dad2a | ||
|
|
dceb67a065 | ||
|
|
dd39b8b2ca | ||
|
|
71963cbd92 | ||
|
|
816bb020fb | ||
|
|
e5aeff89e3 | ||
|
|
3620b04caf | ||
|
|
c0c353dbe8 | ||
|
|
75096edb23 | ||
|
|
f33dd4f2cd | ||
|
|
cc3d48451e | ||
|
|
34b3ef0d44 | ||
|
|
896242a5a5 | ||
|
|
ef7a40944e | ||
|
|
f62ecb0eb6 | ||
|
|
da159bb063 | ||
|
|
c5d1efc18d | ||
|
|
9464adf22f | ||
|
|
17dfd2c68a | ||
|
|
abe6185ddd | ||
|
|
217b383f12 | ||
|
|
2c96ec0f1b |
@@ -5,6 +5,7 @@ on:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
id-token: write # Required for OIDC
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
@@ -14,7 +15,7 @@ jobs:
|
||||
permissions: write-all
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -27,12 +28,15 @@ jobs:
|
||||
git push origin release
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Update npm
|
||||
run: npm install -g npm@latest
|
||||
|
||||
- name: Get package.json version
|
||||
id: package-json-version
|
||||
run: echo "version=v$(cat package.json | jq '.version' --raw-output)" >> $GITHUB_OUTPUT
|
||||
@@ -58,11 +62,7 @@ jobs:
|
||||
run: gh release upload ${{ github.event.release.tag_name }} dist/bundles/mediabunny.cjs dist/bundles/mediabunny.min.cjs dist/bundles/mediabunny.mjs dist/bundles/mediabunny.min.mjs dist/mediabunny.d.ts packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.js packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.min.js packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.mjs packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.min.mjs packages/mp3-encoder/dist/mediabunny-mp3-encoder.d.ts
|
||||
|
||||
- name: Publish Mediabunny to npm
|
||||
run: npm publish --provenance --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: npm publish --access public
|
||||
|
||||
- name: Publish workspace packages to npm
|
||||
run: npm publish --provenance --access public --workspaces
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: npm publish --access public --workspaces
|
||||
|
||||
@@ -14,12 +14,33 @@
|
||||
source: new Mediabunny.BlobSource(file),
|
||||
});
|
||||
|
||||
const videoTrack = await input.getPrimaryVideoTrack();
|
||||
const sink = new Mediabunny.EncodedPacketSink(videoTrack);
|
||||
|
||||
for await (const packet of sink.packets()) {
|
||||
console.log(packet);
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
const videoTrack = await input.getPrimaryVideoTrack();
|
||||
const sink = new Mediabunny.EncodedPacketSink(videoTrack);
|
||||
|
||||
for await (const packet of sink.packets()) {
|
||||
console.log(packet);
|
||||
}
|
||||
*/
|
||||
|
||||
/*
|
||||
return;
|
||||
|
||||
const audioTrack = await input.getPrimaryAudioTrack();
|
||||
const sink = new Mediabunny.EncodedPacketSink(audioTrack);
|
||||
|
||||
for await (const packet of sink.packets()) {
|
||||
console.log(packet.timestamp, packet.duration, packet.type)
|
||||
}
|
||||
*/
|
||||
|
||||
/*
|
||||
const audioTrack = await input.getPrimaryAudioTrack();
|
||||
|
||||
+4
-1
@@ -1,6 +1,7 @@
|
||||
<button>Go</button>
|
||||
|
||||
<script src="../dist/bundles/mediabunny.cjs"></script>
|
||||
<script src="../packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.js"></script>
|
||||
|
||||
<script type="module">
|
||||
function download(blob, filename) {
|
||||
@@ -12,6 +13,8 @@
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
MediabunnyMp3Encoder.registerMp3Encoder();
|
||||
|
||||
const button = document.querySelector('button');
|
||||
button.addEventListener('click', async () => {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
|
||||
@@ -34,7 +37,7 @@
|
||||
}
|
||||
if (audioTrack) {
|
||||
const source = new Mediabunny.MediaStreamAudioTrackSource(audioTrack, {
|
||||
codec: 'aac',
|
||||
codec: 'mp3',
|
||||
bitrate: Mediabunny.QUALITY_MEDIUM
|
||||
});
|
||||
|
||||
|
||||
@@ -14,6 +14,9 @@ export default withMermaid({
|
||||
title: 'Mediabunny',
|
||||
description: DESCRIPTION,
|
||||
cleanUrls: true,
|
||||
sitemap: {
|
||||
hostname: 'https://mediabunny.dev',
|
||||
},
|
||||
head: [
|
||||
['link', { rel: 'icon', type: 'image/png', href: '/mediabunny-logo.png' }],
|
||||
['link', { rel: 'icon', type: 'image/svg+xml', href: '/mediabunny-logo.svg' }],
|
||||
@@ -34,7 +37,7 @@ export default withMermaid({
|
||||
// https://vitepress.dev/reference/default-theme-config
|
||||
nav: [
|
||||
{ text: 'Guide', link: '/guide/introduction', activeMatch: '/guide' },
|
||||
{ text: 'API', link: '/api', activeMatch: '/api' },
|
||||
{ text: 'API', link: '/api/', activeMatch: '/api' },
|
||||
{ text: 'LLMs', link: '/llms', activeMatch: '/llms' },
|
||||
{ text: 'Examples', link: '/examples', activeMatch: '/examples' },
|
||||
{ text: 'Sponsors', link: '/#sponsors', activeMatch: '/#sponsors' },
|
||||
|
||||
+6
-6
@@ -9,37 +9,37 @@ hero:
|
||||
features:
|
||||
- title: Metadata extraction
|
||||
details: Extract various metadata from an input media file.
|
||||
link: /examples/metadata-extraction
|
||||
link: /examples/metadata-extraction/
|
||||
target: _self
|
||||
icon:
|
||||
src: /mingcute--file-info-line.svg
|
||||
- title: Thumbnail generation
|
||||
details: Generate multiple small thumbnails for a video track.
|
||||
link: /examples/thumbnail-generation
|
||||
link: /examples/thumbnail-generation/
|
||||
target: _self
|
||||
icon:
|
||||
src: /mingcute--photo-album-line.svg
|
||||
- title: Media player (advanced)
|
||||
details: "A full video & audio media player, implemented from scratch with Mediabunny, with microsecond playback accuracy."
|
||||
link: /examples/media-player
|
||||
link: /examples/media-player/
|
||||
target: _self
|
||||
icon:
|
||||
src: /mingcute--video-line.svg
|
||||
- title: File compression
|
||||
details: Convert an input file to a highly-compressed MP4 file.
|
||||
link: /examples/file-compression
|
||||
link: /examples/file-compression/
|
||||
target: _self
|
||||
icon:
|
||||
src: /mingcute--file-zip-line.svg
|
||||
- title: Procedural video generation
|
||||
details: Generate a video file as fast as the hardware allows.
|
||||
link: /examples/procedural-generation
|
||||
link: /examples/procedural-generation/
|
||||
target: _self
|
||||
icon:
|
||||
src: /mingcute--magic-3-line.svg
|
||||
- title: Live recording & streaming
|
||||
details: Record a video from live sources and stream it to a video element.
|
||||
link: /examples/live-recording
|
||||
link: /examples/live-recording/
|
||||
target: _self
|
||||
icon:
|
||||
src: /mingcute--microphone-line.svg
|
||||
|
||||
@@ -433,4 +433,12 @@ await textSource.add('00:00:00.000 --> 00:00:02.000\nHello there!\n\n');
|
||||
await textSource.add('00:00:02.500 --> 00:00:04.000\nChunky chunks.\n\n');
|
||||
```
|
||||
|
||||
The chunks have certain constraints: A cue must be fully contained within a chunk and cannot be split across multiple smaller chunks (although a chunk can contain multiple cues). Also, the WebVTT preamble must be added first and all at once.
|
||||
The chunks have certain constraints: A cue must be fully contained within a chunk and cannot be split across multiple smaller chunks (although a chunk can contain multiple cues). Also, the WebVTT preamble must be added first and all at once.
|
||||
|
||||
::: info
|
||||
For QuickTime to display WebVTT subtitles, it typically expects alignment information to be specified:
|
||||
```
|
||||
00:00:00.000 --> 00:00:02.000 align:center
|
||||
This is your last chance.
|
||||
```
|
||||
:::
|
||||
@@ -106,6 +106,10 @@ track.languageCode; // => string
|
||||
|
||||
// A user-defined name for this track.
|
||||
track.name; // => string
|
||||
|
||||
// Information about the intended usage of the track
|
||||
// (default, commentary, hearing-impaired, visually-impaired, etc.)
|
||||
track.disposition; // TrackDisposition
|
||||
```
|
||||
|
||||
#### Codec information
|
||||
|
||||
@@ -56,6 +56,7 @@ output.addVideoTrack(videoSource, {
|
||||
output.addAudioTrack(audioSourceEng, {
|
||||
language: 'eng', // ISO 639-2/T language code
|
||||
name: 'Developer Commentary', // Sets a user-defined track name
|
||||
disposition: { commentary: true }, // Sets additional flags in the file
|
||||
});
|
||||
output.addAudioTrack(audioSourceGer, {
|
||||
language: 'ger',
|
||||
|
||||
@@ -114,6 +114,7 @@ const sponsors = {
|
||||
{ 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' },
|
||||
{ image: 'https://avatars.githubusercontent.com/u/56988069', name: 'SyhabouthAlex', url: 'https://github.com/SyhabouthAlex' },
|
||||
{ image: 'https://avatars.githubusercontent.com/u/38181164', name: 'wcw', url: 'https://github.com/asd55667' },
|
||||
{ image: 'https://avatars.githubusercontent.com/u/1836701', name: 'Bean Deng', url: 'https://github.com/HADB' },
|
||||
{ image: 'https://avatars.githubusercontent.com/u/63088713', name: 'taf2000', url: 'https://github.com/taf2000' },
|
||||
|
||||
@@ -21,7 +21,7 @@ const context = canvas.getContext('2d', { alpha: false, desynchronized: true })!
|
||||
|
||||
const frameRate = 30;
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
const chunks: Uint8Array<ArrayBuffer>[] = [];
|
||||
let recording = false;
|
||||
let output: Output;
|
||||
let videoSource: CanvasSource;
|
||||
|
||||
@@ -97,7 +97,7 @@ const extractMetadata = (resource: File | string) => {
|
||||
'Lyrics': tags.lyrics,
|
||||
'Comment': tags.comment,
|
||||
'Images': tags.images?.map((image) => {
|
||||
const blob = new Blob([image.data], { type: image.mimeType });
|
||||
const blob = new Blob([image.data.slice()], { type: image.mimeType });
|
||||
const element = new Image();
|
||||
element.src = URL.createObjectURL(blob);
|
||||
|
||||
|
||||
Generated
+285
-462
File diff suppressed because it is too large
Load Diff
+4
-4
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mediabunny",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.24.7",
|
||||
"version": "1.25.8",
|
||||
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
@@ -70,7 +70,7 @@
|
||||
"@eslint/js": "^9.22.0",
|
||||
"@fontsource-variable/rubik": "^5.2.6",
|
||||
"@fontsource/dm-mono": "^5.2.6",
|
||||
"@microsoft/api-extractor": "^7.52.1",
|
||||
"@microsoft/api-extractor": "^7.55.1",
|
||||
"@stylistic/eslint-plugin": "^4.2.0",
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"@types/markdown-it-footnote": "^3.0.4",
|
||||
@@ -84,8 +84,8 @@
|
||||
"mermaid": "^11.6.0",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.8.2",
|
||||
"typescript-eslint": "^8.26.1",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^6.3.5",
|
||||
"vitepress": "^1.6.3",
|
||||
"vitepress-plugin-llms": "^1.5.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@mediabunny/mp3-encoder",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.24.7",
|
||||
"version": "1.25.8",
|
||||
"description": "MP3 encoder extension for Mediabunny, based on LAME.",
|
||||
"main": "./dist/bundles/mediabunny-mp3-encoder.mjs",
|
||||
"module": "./dist/bundles/mediabunny-mp3-encoder.mjs",
|
||||
|
||||
@@ -22,7 +22,7 @@ class Mp3Encoder extends CustomAudioEncoder {
|
||||
|
||||
private buffer = new Uint8Array(2 ** 16);
|
||||
private currentBufferOffset = 0;
|
||||
private currentTimestamp = 0;
|
||||
private currentTimestamp: number | null = null;
|
||||
private chunkMetadata: EncodedAudioChunkMetadata = {};
|
||||
|
||||
static override supports(codec: AudioCodec, config: AudioDecoderConfig): boolean {
|
||||
@@ -79,6 +79,11 @@ class Mp3Encoder extends CustomAudioEncoder {
|
||||
}
|
||||
|
||||
async encode(audioSample: AudioSample) {
|
||||
if (this.currentTimestamp === null) {
|
||||
// The first sample's timestamp determines where we start
|
||||
this.currentTimestamp = audioSample.timestamp;
|
||||
}
|
||||
|
||||
const sizePerChannel = audioSample.allocationSize({
|
||||
format: 's16-planar',
|
||||
planeIndex: 0,
|
||||
@@ -123,6 +128,8 @@ class Mp3Encoder extends CustomAudioEncoder {
|
||||
* these chunks and extract the MP3 frames only when they're complete.
|
||||
*/
|
||||
private digestOutput(bytes: Uint8Array) {
|
||||
assert(this.currentTimestamp !== null);
|
||||
|
||||
const requiredBufferSize = this.currentBufferOffset + bytes.length;
|
||||
if (requiredBufferSize > this.buffer.length) {
|
||||
// Grow the buffer to the required size
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from '../misc';
|
||||
import { EncodedPacket, PLACEHOLDER_DATA } from '../packet';
|
||||
import { readBytes, Reader } from '../reader';
|
||||
import { DEFAULT_TRACK_DISPOSITION } from '../metadata';
|
||||
import { FrameHeader, MAX_FRAME_HEADER_SIZE, MIN_FRAME_HEADER_SIZE, readFrameHeader } from './adts-reader';
|
||||
|
||||
const SAMPLES_PER_AAC_FRAME = 1024;
|
||||
@@ -188,6 +189,12 @@ class AdtsAudioTrackBacking implements InputAudioTrackBacking {
|
||||
return sampleRate;
|
||||
}
|
||||
|
||||
getDisposition() {
|
||||
return {
|
||||
...DEFAULT_TRACK_DISPOSITION,
|
||||
};
|
||||
}
|
||||
|
||||
async getDecoderConfig(): Promise<AudioDecoderConfig> {
|
||||
assert(this.demuxer.firstFrameHeader);
|
||||
|
||||
|
||||
+130
-5
@@ -22,9 +22,11 @@ import {
|
||||
textEncoder,
|
||||
toDataView,
|
||||
toUint8Array,
|
||||
getChromiumVersion,
|
||||
isChromium,
|
||||
} from './misc';
|
||||
import { PacketType } from './packet';
|
||||
import { MetadataTags } from './tags';
|
||||
import { MetadataTags } from './metadata';
|
||||
|
||||
// References for AVC/HEVC code:
|
||||
// ISO 14496-15
|
||||
@@ -34,6 +36,7 @@ import { MetadataTags } from './tags';
|
||||
|
||||
export enum AvcNalUnitType {
|
||||
IDR = 5,
|
||||
SEI = 6,
|
||||
SPS = 7,
|
||||
PPS = 8,
|
||||
SPS_EXT = 13,
|
||||
@@ -590,9 +593,7 @@ export const extractNalUnitTypeForHevc = (data: Uint8Array) => {
|
||||
};
|
||||
|
||||
/** Builds a HevcDecoderConfigurationRecord from an HEVC packet in Annex B format. */
|
||||
export const extractHevcDecoderConfigurationRecord = (
|
||||
packetData: Uint8Array,
|
||||
) => {
|
||||
export const extractHevcDecoderConfigurationRecord = (packetData: Uint8Array) => {
|
||||
try {
|
||||
const nalUnits = findNalUnitsInAnnexB(packetData);
|
||||
|
||||
@@ -1456,6 +1457,69 @@ export const extractAv1CodecInfoFromPacket = (
|
||||
}
|
||||
}
|
||||
|
||||
// Frame size
|
||||
const frameWidthBitsMinus1 = bitstream.readBits(4);
|
||||
const frameHeightBitsMinus1 = bitstream.readBits(4);
|
||||
const n1 = frameWidthBitsMinus1 + 1;
|
||||
bitstream.skipBits(n1); // max_frame_width_minus_1
|
||||
const n2 = frameHeightBitsMinus1 + 1;
|
||||
bitstream.skipBits(n2); // max_frame_height_minus_1
|
||||
|
||||
// Frame IDs
|
||||
let frameIdNumbersPresentFlag = 0;
|
||||
if (reducedStillPictureHeader) {
|
||||
frameIdNumbersPresentFlag = 0;
|
||||
} else {
|
||||
frameIdNumbersPresentFlag = bitstream.readBits(1);
|
||||
}
|
||||
|
||||
if (frameIdNumbersPresentFlag) {
|
||||
bitstream.skipBits(4); // delta_frame_id_length_minus_2
|
||||
bitstream.skipBits(3); // additional_frame_id_length_minus_1
|
||||
}
|
||||
|
||||
bitstream.skipBits(1); // use_128x128_superblock
|
||||
bitstream.skipBits(1); // enable_filter_intra
|
||||
bitstream.skipBits(1); // enable_intra_edge_filter
|
||||
|
||||
if (!reducedStillPictureHeader) {
|
||||
bitstream.skipBits(1); // enable_interintra_compound
|
||||
bitstream.skipBits(1); // enable_masked_compound
|
||||
bitstream.skipBits(1); // enable_warped_motion
|
||||
bitstream.skipBits(1); // enable_dual_filter
|
||||
const enableOrderHint = bitstream.readBits(1);
|
||||
|
||||
if (enableOrderHint) {
|
||||
bitstream.skipBits(1); // enable_jnt_comp
|
||||
bitstream.skipBits(1); // enable_ref_frame_mvs
|
||||
}
|
||||
|
||||
const seqChooseScreenContentTools = bitstream.readBits(1);
|
||||
let seqForceScreenContentTools = 0;
|
||||
|
||||
if (seqChooseScreenContentTools) {
|
||||
seqForceScreenContentTools = 2; // SELECT_SCREEN_CONTENT_TOOLS
|
||||
} else {
|
||||
seqForceScreenContentTools = bitstream.readBits(1);
|
||||
}
|
||||
|
||||
if (seqForceScreenContentTools > 0) {
|
||||
const seqChooseIntegerMv = bitstream.readBits(1);
|
||||
if (!seqChooseIntegerMv) {
|
||||
bitstream.skipBits(1); // seq_force_integer_mv
|
||||
}
|
||||
}
|
||||
|
||||
if (enableOrderHint) {
|
||||
bitstream.skipBits(3); // order_hint_bits_minus_1
|
||||
}
|
||||
}
|
||||
|
||||
bitstream.skipBits(1); // enable_superres
|
||||
bitstream.skipBits(1); // enable_cdef
|
||||
bitstream.skipBits(1); // enable_restoration
|
||||
|
||||
// color_config()
|
||||
const highBitdepth = bitstream.readBits(1);
|
||||
|
||||
let bitDepth = 8;
|
||||
@@ -1653,7 +1717,68 @@ export const determineVideoPacketType = (
|
||||
switch (codec) {
|
||||
case 'avc': {
|
||||
const nalUnits = extractAvcNalUnits(packetData, decoderConfig);
|
||||
const isKeyframe = nalUnits.some(x => extractNalUnitTypeForAvc(x) === AvcNalUnitType.IDR);
|
||||
let isKeyframe = nalUnits.some(x => extractNalUnitTypeForAvc(x) === AvcNalUnitType.IDR);
|
||||
|
||||
if (!isKeyframe && (!isChromium() || getChromiumVersion()! >= 144)) {
|
||||
// In addition to IDR, Recovery Point SEI also counts as a valid H.264 keyframe by current consensus.
|
||||
// See https://github.com/w3c/webcodecs/issues/650 for the relevant discussion. WebKit and Firefox have
|
||||
// always supported them, but Chromium hasn't, therefore the (admittedly dirty) version check.
|
||||
|
||||
for (const nalUnit of nalUnits) {
|
||||
const type = extractNalUnitTypeForAvc(nalUnit);
|
||||
if (type !== AvcNalUnitType.SEI) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const bytes = removeEmulationPreventionBytes(nalUnit);
|
||||
let pos = 1; // Skip NALU header
|
||||
|
||||
// sei_rbsp()
|
||||
do {
|
||||
// sei_message()
|
||||
let payloadType = 0;
|
||||
while (true) {
|
||||
const nextByte = bytes[pos++];
|
||||
if (nextByte === undefined) break;
|
||||
payloadType += nextByte;
|
||||
|
||||
if (nextByte < 255) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let payloadSize = 0;
|
||||
while (true) {
|
||||
const nextByte = bytes[pos++];
|
||||
if (nextByte === undefined) break;
|
||||
payloadSize += nextByte;
|
||||
|
||||
if (nextByte < 255) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// sei_payload()
|
||||
const PAYLOAD_TYPE_RECOVERY_POINT = 6;
|
||||
if (payloadType === PAYLOAD_TYPE_RECOVERY_POINT) {
|
||||
const bitstream = new Bitstream(bytes);
|
||||
bitstream.pos = 8 * pos;
|
||||
|
||||
const recoveryFrameCount = readExpGolomb(bitstream);
|
||||
const exactMatchFlag = bitstream.readBits(1);
|
||||
|
||||
if (recoveryFrameCount === 0 && exactMatchFlag === 1) {
|
||||
// https://github.com/w3c/webcodecs/pull/910
|
||||
// "recovery_frame_cnt == 0 and exact_match_flag=1 in the SEI recovery payload"
|
||||
isKeyframe = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
pos += payloadSize;
|
||||
} while (pos < bytes.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
return isKeyframe ? 'key' : 'delta';
|
||||
};
|
||||
|
||||
+20
-8
@@ -48,7 +48,7 @@ import {
|
||||
import { Output, TrackType } from './output';
|
||||
import { Mp4OutputFormat } from './output-format';
|
||||
import { AudioSample, clampCropRectangle, validateCropRectangle, VideoSample } from './sample';
|
||||
import { MetadataTags, validateMetadataTags } from './tags';
|
||||
import { MetadataTags, validateMetadataTags } from './metadata';
|
||||
import { NullTarget } from './target';
|
||||
|
||||
/**
|
||||
@@ -82,10 +82,16 @@ export type ConversionOptions = {
|
||||
|
||||
/** Options to trim the input file. */
|
||||
trim?: {
|
||||
/** The time in the input file in seconds at which the output file should start. Must be less than `end`. */
|
||||
start: number;
|
||||
/** The time in the input file in seconds at which the output file should end. Must be greater than `start`. */
|
||||
end: number;
|
||||
/**
|
||||
* The time in the input file in seconds at which the output file should start. Must be less than `end`.
|
||||
* Defaults to 0 when omitted.
|
||||
*/
|
||||
start?: number;
|
||||
/**
|
||||
* The time in the input file in seconds at which the output file should end. Must be greater than `start`.
|
||||
* Defaults to the duration of the input when omitted.
|
||||
*/
|
||||
end?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1184,9 +1190,10 @@ export class Conversion {
|
||||
|
||||
this.output.addVideoTrack(videoSource, {
|
||||
frameRate: trackOptions.frameRate,
|
||||
// TEMP: This condition can be removed when all demuxers properly homogenize to BCP47 in v2
|
||||
// TODO: This condition can be removed when all demuxers properly homogenize to BCP47 in v2
|
||||
languageCode: isIso639Dash2LanguageCode(track.languageCode) ? track.languageCode : undefined,
|
||||
name: track.name ?? undefined,
|
||||
disposition: track.disposition,
|
||||
rotation: needsRerender ? 0 : totalRotation, // Rerendering will bake the rotation into the output
|
||||
});
|
||||
this._addedCounts.video++;
|
||||
@@ -1423,9 +1430,10 @@ export class Conversion {
|
||||
}
|
||||
|
||||
this.output.addAudioTrack(audioSource, {
|
||||
// TEMP: This condition can be removed when all demuxers properly homogenize to BCP47 in v2
|
||||
// TODO: This condition can be removed when all demuxers properly homogenize to BCP47 in v2
|
||||
languageCode: isIso639Dash2LanguageCode(track.languageCode) ? track.languageCode : undefined,
|
||||
name: track.name ?? undefined,
|
||||
disposition: track.disposition,
|
||||
});
|
||||
this._addedCounts.audio++;
|
||||
this._totalTrackCount++;
|
||||
@@ -1507,7 +1515,10 @@ export class Conversion {
|
||||
targetSampleRate,
|
||||
startTime: this._startTimestamp,
|
||||
endTime: this._endTimestamp,
|
||||
onSample: sample => this._registerAudioSample(track, trackOptions, source, sample),
|
||||
onSample: async (sample) => {
|
||||
await this._registerAudioSample(track, trackOptions, source, sample);
|
||||
sample.close();
|
||||
},
|
||||
});
|
||||
|
||||
const sink = new AudioSampleSink(track);
|
||||
@@ -1519,6 +1530,7 @@ export class Conversion {
|
||||
}
|
||||
|
||||
await resampler.add(sample);
|
||||
sample.close();
|
||||
}
|
||||
|
||||
await resampler.finalize();
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
|
||||
import { Input } from './input';
|
||||
import { InputTrack } from './input-track';
|
||||
import { MetadataTags } from './tags';
|
||||
import { MetadataTags } from './metadata';
|
||||
|
||||
export abstract class Demuxer {
|
||||
input: Input;
|
||||
|
||||
+40
-1
@@ -22,6 +22,7 @@ import {
|
||||
VideoCodec,
|
||||
} from './codec';
|
||||
import { customAudioEncoders, customVideoEncoders } from './custom-coder';
|
||||
import { isFirefox } from './misc';
|
||||
import { EncodedPacket } from './packet';
|
||||
|
||||
/**
|
||||
@@ -529,7 +530,45 @@ export const canEncodeVideo = async (
|
||||
});
|
||||
|
||||
const support = await VideoEncoder.isConfigSupported(encoderConfig);
|
||||
return support.supported === true;
|
||||
if (!support.supported) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isFirefox()) {
|
||||
// isConfigSupported on Firefox appears to unreliably indicate if encoding will actually succeed. Therefore, we
|
||||
// just try encoding a frame to see if it actually works.
|
||||
// https://github.com/Vanilagy/mediabunny/issues/222
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises, no-async-promise-executor
|
||||
return new Promise<boolean>(async (resolve) => {
|
||||
try {
|
||||
const encoder = new VideoEncoder({
|
||||
output: () => {},
|
||||
error: () => resolve(false),
|
||||
});
|
||||
encoder.configure(encoderConfig);
|
||||
|
||||
const frameData = new Uint8Array(width * height * 4);
|
||||
const frame = new VideoFrame(frameData, {
|
||||
format: 'RGBA',
|
||||
codedWidth: width,
|
||||
codedHeight: height,
|
||||
timestamp: 0,
|
||||
});
|
||||
|
||||
encoder.encode(frame);
|
||||
frame.close();
|
||||
|
||||
await encoder.flush();
|
||||
|
||||
resolve(true);
|
||||
} catch {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
readU32Be,
|
||||
readU8,
|
||||
} from '../reader';
|
||||
import { MetadataTags } from '../tags';
|
||||
import { DEFAULT_TRACK_DISPOSITION, MetadataTags } from '../metadata';
|
||||
import {
|
||||
calculateCrc8,
|
||||
readBlockSize,
|
||||
@@ -565,6 +565,12 @@ class FlacAudioTrackBacking implements InputAudioTrackBacking {
|
||||
return this.demuxer.audioInfo.sampleRate;
|
||||
}
|
||||
|
||||
getDisposition() {
|
||||
return {
|
||||
...DEFAULT_TRACK_DISPOSITION,
|
||||
};
|
||||
}
|
||||
|
||||
async getFirstTimestamp() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import { Output, OutputAudioTrack } from '../output';
|
||||
import { FlacOutputFormat } from '../output-format';
|
||||
import { EncodedPacket } from '../packet';
|
||||
import { FileSlice, readBytes } from '../reader';
|
||||
import { AttachedImage, metadataTagsAreEmpty } from '../tags';
|
||||
import { AttachedImage, metadataTagsAreEmpty } from '../metadata';
|
||||
import { Writer } from '../writer';
|
||||
import {
|
||||
readBlockSize,
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { decodeSynchsafe, encodeSynchsafe } from '../shared/mp3-misc';
|
||||
import { MetadataTags } from './tags';
|
||||
import { MetadataTags } from './metadata';
|
||||
import {
|
||||
coalesceIndex,
|
||||
textDecoder,
|
||||
|
||||
+2
-1
@@ -202,6 +202,7 @@ export {
|
||||
AttachedImage,
|
||||
RichImageData,
|
||||
AttachedFile,
|
||||
} from './tags';
|
||||
TrackDisposition,
|
||||
} from './metadata';
|
||||
|
||||
// 🐡🦔
|
||||
|
||||
@@ -14,6 +14,7 @@ import { EncodedPacketSink, PacketRetrievalOptions } from './media-sink';
|
||||
import { assert, Rotation } from './misc';
|
||||
import { TrackType } from './output';
|
||||
import { EncodedPacket, PacketType } from './packet';
|
||||
import { TrackDisposition } from './metadata';
|
||||
|
||||
/**
|
||||
* Contains aggregate statistics about the encoded packets of a track.
|
||||
@@ -36,6 +37,7 @@ export interface InputTrackBacking {
|
||||
getName(): string | null;
|
||||
getLanguageCode(): string;
|
||||
getTimeResolution(): number;
|
||||
getDisposition(): TrackDisposition;
|
||||
getFirstTimestamp(): Promise<number>;
|
||||
computeDuration(): Promise<number>;
|
||||
|
||||
@@ -128,6 +130,11 @@ export abstract class InputTrack {
|
||||
return this._backing.getTimeResolution();
|
||||
}
|
||||
|
||||
/** The track's disposition, i.e. information about its intended usage. */
|
||||
get disposition() {
|
||||
return this._backing.getDisposition();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the start timestamp of the first packet of this track, in seconds. While often near zero, this value
|
||||
* may be positive or even negative. A negative starting timestamp means the track's timing has been offset. Samples
|
||||
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
Sample,
|
||||
} from './isobmff-muxer';
|
||||
import { parseOpusIdentificationHeader } from '../codec-data';
|
||||
import { MetadataTags, RichImageData } from '../tags';
|
||||
import { MetadataTags, RichImageData } from '../metadata';
|
||||
|
||||
export class IsobmffBoxWriter {
|
||||
private helper = new Uint8Array(8);
|
||||
@@ -421,7 +421,12 @@ export const tkhd = (
|
||||
matrix = IDENTITY_MATRIX;
|
||||
}
|
||||
|
||||
return fullBox('tkhd', +needsU64, 3, [
|
||||
let flags = 0x2; // Track in movie
|
||||
if (trackData.track.metadata.disposition?.default !== false) {
|
||||
flags |= 0x1; // Track enabled
|
||||
}
|
||||
|
||||
return fullBox('tkhd', +needsU64, flags, [
|
||||
u32OrU64(creationTime), // Creation time
|
||||
u32OrU64(creationTime), // Modification time
|
||||
u32(trackData.track.id), // Track ID
|
||||
@@ -845,7 +850,7 @@ export const dOps = (trackData: IsobmffAudioTrackData) => {
|
||||
let inputSampleRate = trackData.info.sampleRate;
|
||||
let outputGain = 0;
|
||||
let channelMappingFamily = 0;
|
||||
let channelMappingTable = new Uint8Array(0);
|
||||
let channelMappingTable: Uint8Array<ArrayBufferLike> = new Uint8Array(0);
|
||||
|
||||
// Read preskip and from codec private data from the encoder
|
||||
// https://www.rfc-editor.org/rfc/rfc7845#section-5
|
||||
|
||||
@@ -86,12 +86,13 @@ import {
|
||||
readU8,
|
||||
readAscii,
|
||||
} from '../reader';
|
||||
import { MetadataTags, RichImageData } from '../tags';
|
||||
import { DEFAULT_TRACK_DISPOSITION, MetadataTags, RichImageData, TrackDisposition } from '../metadata';
|
||||
|
||||
type InternalTrack = {
|
||||
id: number;
|
||||
demuxer: IsobmffDemuxer;
|
||||
inputTrack: InputTrack | null;
|
||||
disposition: TrackDisposition;
|
||||
timescale: number;
|
||||
durationInMovieTimescale: number;
|
||||
durationInMediaTimescale: number;
|
||||
@@ -315,6 +316,9 @@ export class IsobmffDemuxer extends Demuxer {
|
||||
this.moovSlice = moovSlice;
|
||||
this.readContiguousBoxes(this.moovSlice);
|
||||
|
||||
// Put default tracks first
|
||||
this.tracks.sort((a, b) => Number(b.disposition.default) - Number(a.disposition.default));
|
||||
|
||||
for (const track of this.tracks) {
|
||||
// Modify the edit list offset based on the previous segment durations. They are in different
|
||||
// timescales, so we first convert to seconds and then into the track timescale.
|
||||
@@ -651,6 +655,9 @@ export class IsobmffDemuxer extends Demuxer {
|
||||
id: -1,
|
||||
demuxer: this,
|
||||
inputTrack: null,
|
||||
disposition: {
|
||||
...DEFAULT_TRACK_DISPOSITION,
|
||||
},
|
||||
info: null,
|
||||
timescale: -1,
|
||||
durationInMovieTimescale: -1,
|
||||
@@ -695,10 +702,10 @@ export class IsobmffDemuxer extends Demuxer {
|
||||
const version = readU8(slice);
|
||||
const flags = readU24Be(slice);
|
||||
|
||||
const trackEnabled = (flags & 0x1) !== 0;
|
||||
if (!trackEnabled) {
|
||||
break;
|
||||
}
|
||||
// Spec says disabled tracks are to be treated like they don't exist, but in practice, they are treated
|
||||
// more like non-default tracks.
|
||||
const trackEnabled = !!(flags & 0x1);
|
||||
track.disposition.default = trackEnabled;
|
||||
|
||||
// Skip over creation & modification time to reach the track ID
|
||||
if (version === 0) {
|
||||
@@ -2345,6 +2352,10 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
|
||||
return this.internalTrack.timescale;
|
||||
}
|
||||
|
||||
getDisposition() {
|
||||
return this.internalTrack.disposition;
|
||||
}
|
||||
|
||||
async computeDuration() {
|
||||
const lastPacket = await this.getPacket(Infinity, { metadataOnly: true });
|
||||
return (lastPacket?.timestamp ?? 0) + (lastPacket?.duration ?? 0);
|
||||
@@ -2478,11 +2489,8 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
|
||||
const timestampInTimescale = this.mapTimestampIntoTimescale(timestamp);
|
||||
|
||||
const sampleTable = this.internalTrack.demuxer.getSampleTableForTrack(this.internalTrack);
|
||||
const sampleIndex = getSampleIndexForTimestamp(sampleTable, timestampInTimescale);
|
||||
const keyFrameSampleIndex = sampleIndex === -1
|
||||
? -1
|
||||
: getRelevantKeyframeIndexForSample(sampleTable, sampleIndex);
|
||||
const regularPacket = await this.fetchPacketForSampleIndex(keyFrameSampleIndex, options);
|
||||
const sampleIndex = getKeyframeSampleIndexForTimestamp(sampleTable, timestampInTimescale);
|
||||
const regularPacket = await this.fetchPacketForSampleIndex(sampleIndex, options);
|
||||
|
||||
if (!sampleTableIsEmpty(sampleTable) || !this.internalTrack.demuxer.isFragmented) {
|
||||
// Prefer the non-fragmented packet
|
||||
@@ -2902,7 +2910,45 @@ const getSampleIndexForTimestamp = (sampleTable: SampleTable, timescaleUnits: nu
|
||||
|
||||
const entry = sampleTable.sampleTimingEntries[index]!;
|
||||
return entry.startIndex
|
||||
+ Math.min(Math.floor((timescaleUnits - entry.startDecodeTimestamp) / entry.delta), entry.count - 1);
|
||||
+ Math.min(
|
||||
Math.floor((timescaleUnits - entry.startDecodeTimestamp) / entry.delta),
|
||||
entry.count - 1,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getKeyframeSampleIndexForTimestamp = (sampleTable: SampleTable, timescaleUnits: number) => {
|
||||
if (!sampleTable.keySampleIndices) {
|
||||
// Every sample is a keyframe
|
||||
return getSampleIndexForTimestamp(sampleTable, timescaleUnits);
|
||||
}
|
||||
|
||||
if (sampleTable.presentationTimestamps) {
|
||||
const index = binarySearchLessOrEqual(
|
||||
sampleTable.presentationTimestamps,
|
||||
timescaleUnits,
|
||||
x => x.presentationTimestamp,
|
||||
);
|
||||
if (index === -1) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Walk the samples in presentation order until we find one that's a keyframe
|
||||
for (let i = index; i >= 0; i--) {
|
||||
const sampleIndex = sampleTable.presentationTimestamps[i]!.sampleIndex;
|
||||
const isKeyFrame = binarySearchExact(sampleTable.keySampleIndices, sampleIndex, x => x) !== -1;
|
||||
|
||||
if (isKeyFrame) {
|
||||
return sampleIndex;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
} else {
|
||||
const sampleIndex = getSampleIndexForTimestamp(sampleTable, timescaleUnits);
|
||||
|
||||
const index = binarySearchLessOrEqual(sampleTable.keySampleIndices, sampleIndex, x => x);
|
||||
return sampleTable.keySampleIndices[index] ?? -1;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2991,15 +3037,6 @@ const getSampleInfo = (sampleTable: SampleTable, sampleIndex: number): SampleInf
|
||||
};
|
||||
};
|
||||
|
||||
const getRelevantKeyframeIndexForSample = (sampleTable: SampleTable, sampleIndex: number) => {
|
||||
if (!sampleTable.keySampleIndices) {
|
||||
return sampleIndex;
|
||||
}
|
||||
|
||||
const index = binarySearchLessOrEqual(sampleTable.keySampleIndices, sampleIndex, x => x);
|
||||
return sampleTable.keySampleIndices[index] ?? -1;
|
||||
};
|
||||
|
||||
const getNextKeyframeIndexForSample = (sampleTable: SampleTable, sampleIndex: number) => {
|
||||
if (!sampleTable.keySampleIndices) {
|
||||
return sampleIndex + 1;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { RichImageData } from '../tags';
|
||||
import { RichImageData } from '../metadata';
|
||||
import { textDecoder } from '../misc';
|
||||
import { FileSlice, readAscii, readBytes, readI32Be, readU16Be, readU32Be, readU64Be, readU8 } from '../reader';
|
||||
|
||||
|
||||
@@ -88,6 +88,10 @@ export enum EBMLId {
|
||||
FlagEnabled = 0xb9,
|
||||
FlagDefault = 0x88,
|
||||
FlagForced = 0x55aa,
|
||||
FlagOriginal = 0x55ae,
|
||||
FlagHearingImpaired = 0x55ab,
|
||||
FlagVisualImpaired = 0x55ac,
|
||||
FlagCommentary = 0x55af,
|
||||
FlagLacing = 0x9c,
|
||||
Name = 0x536e,
|
||||
Language = 0x22b59c,
|
||||
@@ -463,7 +467,7 @@ export class EBMLWriter {
|
||||
|
||||
export const MAX_VAR_INT_SIZE = 8;
|
||||
export const MIN_HEADER_SIZE = 2; // 1-byte ID and 1-byte size
|
||||
export const MAX_HEADER_SIZE = /* #__PURE__ */ 2 * MAX_VAR_INT_SIZE; // 8-byte ID and 8-byte size
|
||||
export const MAX_HEADER_SIZE = 2 * MAX_VAR_INT_SIZE; // 8-byte ID and 8-byte size
|
||||
|
||||
export const readVarIntSize = (slice: FileSlice) => {
|
||||
const firstByte = readU8(slice);
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
InputVideoTrack,
|
||||
InputVideoTrackBacking,
|
||||
} from '../input-track';
|
||||
import { AttachedFile, MetadataTags } from '../tags';
|
||||
import { AttachedFile, DEFAULT_TRACK_DISPOSITION, MetadataTags, TrackDisposition } from '../metadata';
|
||||
import { PacketRetrievalOptions } from '../media-sink';
|
||||
import {
|
||||
assert,
|
||||
@@ -188,7 +188,7 @@ type InternalTrack = {
|
||||
}[];
|
||||
cuePoints: CuePoint[];
|
||||
|
||||
isDefault: boolean;
|
||||
disposition: TrackDisposition;
|
||||
inputTrack: InputTrack | null;
|
||||
codecId: string | null;
|
||||
codecPrivate: Uint8Array | null;
|
||||
@@ -228,7 +228,7 @@ const METADATA_ELEMENTS = [
|
||||
{ id: EBMLId.Tracks, flag: 'tracksSeen' },
|
||||
{ id: EBMLId.Cues, flag: 'cuesSeen' },
|
||||
] as const;
|
||||
const MAX_RESYNC_LENGTH = /* #__PURE__ */ 10 * 2 ** 20; // 10 MiB
|
||||
const MAX_RESYNC_LENGTH = 10 * 2 ** 20; // 10 MiB
|
||||
|
||||
export class MatroskaDemuxer extends Demuxer {
|
||||
reader: Reader;
|
||||
@@ -537,7 +537,7 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
}
|
||||
|
||||
// Put default tracks first
|
||||
this.currentSegment.tracks.sort((a, b) => Number(b.isDefault) - Number(a.isDefault));
|
||||
this.currentSegment.tracks.sort((a, b) => Number(b.disposition.default) - Number(a.disposition.default));
|
||||
|
||||
// Now, let's distribute the cue points to the tracks
|
||||
const idToTrack = new Map(this.currentSegment.tracks.map(x => [x.id, x]));
|
||||
@@ -989,7 +989,9 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
clusterPositionCache: [],
|
||||
cuePoints: [],
|
||||
|
||||
isDefault: false,
|
||||
disposition: {
|
||||
...DEFAULT_TRACK_DISPOSITION,
|
||||
},
|
||||
inputTrack: null,
|
||||
codecId: null,
|
||||
codecPrivate: null,
|
||||
@@ -1154,7 +1156,37 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
case EBMLId.FlagDefault: {
|
||||
if (!this.currentTrack) break;
|
||||
|
||||
this.currentTrack.isDefault = !!readUnsignedInt(slice, size);
|
||||
this.currentTrack.disposition.default = !!readUnsignedInt(slice, size);
|
||||
}; break;
|
||||
|
||||
case EBMLId.FlagForced: {
|
||||
if (!this.currentTrack) break;
|
||||
|
||||
this.currentTrack.disposition.forced = !!readUnsignedInt(slice, size);
|
||||
}; break;
|
||||
|
||||
case EBMLId.FlagOriginal: {
|
||||
if (!this.currentTrack) break;
|
||||
|
||||
this.currentTrack.disposition.original = !!readUnsignedInt(slice, size);
|
||||
}; break;
|
||||
|
||||
case EBMLId.FlagHearingImpaired: {
|
||||
if (!this.currentTrack) break;
|
||||
|
||||
this.currentTrack.disposition.hearingImpaired = !!readUnsignedInt(slice, size);
|
||||
}; break;
|
||||
|
||||
case EBMLId.FlagVisualImpaired: {
|
||||
if (!this.currentTrack) break;
|
||||
|
||||
this.currentTrack.disposition.visuallyImpaired = !!readUnsignedInt(slice, size);
|
||||
}; break;
|
||||
|
||||
case EBMLId.FlagCommentary: {
|
||||
if (!this.currentTrack) break;
|
||||
|
||||
this.currentTrack.disposition.commentary = !!readUnsignedInt(slice, size);
|
||||
}; break;
|
||||
|
||||
case EBMLId.CodecID: {
|
||||
@@ -1847,6 +1879,10 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
|
||||
return this.internalTrack.segment.timestampFactor;
|
||||
}
|
||||
|
||||
getDisposition() {
|
||||
return this.internalTrack.disposition;
|
||||
}
|
||||
|
||||
async getFirstPacket(options: PacketRetrievalOptions) {
|
||||
return this.performClusterLookup(
|
||||
null,
|
||||
|
||||
@@ -63,10 +63,10 @@ import { Muxer } from '../muxer';
|
||||
import { Writer } from '../writer';
|
||||
import { EncodedPacket } from '../packet';
|
||||
import { parseOpusIdentificationHeader } from '../codec-data';
|
||||
import { AttachedFile } from '../tags';
|
||||
import { AttachedFile } from '../metadata';
|
||||
|
||||
const MIN_CLUSTER_TIMESTAMP_MS = /* #__PURE__ */ -(2 ** 15);
|
||||
const MAX_CLUSTER_TIMESTAMP_MS = /* #__PURE__ */ 2 ** 15 - 1;
|
||||
const MIN_CLUSTER_TIMESTAMP_MS = -(2 ** 15);
|
||||
const MAX_CLUSTER_TIMESTAMP_MS = 2 ** 15 - 1;
|
||||
const APP_NAME = 'Mediabunny';
|
||||
const SEGMENT_SIZE_BYTES = 6;
|
||||
const CLUSTER_SIZE_BYTES = 5;
|
||||
@@ -304,6 +304,24 @@ export class MatroskaMuxer extends Muxer {
|
||||
{ id: EBMLId.TrackNumber, data: trackData.track.id },
|
||||
{ id: EBMLId.TrackUID, data: trackData.track.id },
|
||||
{ id: EBMLId.TrackType, data: TRACK_TYPE_MAP[trackData.type] },
|
||||
trackData.track.metadata.disposition?.default === false
|
||||
? { id: EBMLId.FlagDefault, data: 0 }
|
||||
: null,
|
||||
trackData.track.metadata.disposition?.forced
|
||||
? { id: EBMLId.FlagForced, data: 1 }
|
||||
: null,
|
||||
trackData.track.metadata.disposition?.hearingImpaired
|
||||
? { id: EBMLId.FlagHearingImpaired, data: 1 }
|
||||
: null,
|
||||
trackData.track.metadata.disposition?.visuallyImpaired
|
||||
? { id: EBMLId.FlagVisualImpaired, data: 1 }
|
||||
: null,
|
||||
trackData.track.metadata.disposition?.original
|
||||
? { id: EBMLId.FlagOriginal, data: 1 }
|
||||
: null,
|
||||
trackData.track.metadata.disposition?.commentary
|
||||
? { id: EBMLId.FlagCommentary, data: 1 }
|
||||
: null,
|
||||
{ id: EBMLId.FlagLacing, data: 0 },
|
||||
{ id: EBMLId.Language, data: trackData.track.metadata.languageCode ?? UNDETERMINED_LANGUAGE },
|
||||
{ id: EBMLId.CodecID, data: codecId },
|
||||
|
||||
+9
-7
@@ -201,9 +201,10 @@ export class EncodedPacketSink {
|
||||
}
|
||||
|
||||
const packet = await this._track._backing.getKeyPacket(timestamp, options);
|
||||
if (!packet || packet.type === 'delta') {
|
||||
if (!packet) {
|
||||
return packet;
|
||||
}
|
||||
assert(packet.type === 'key');
|
||||
|
||||
const determinedType = await this._track.determinePacketType(packet);
|
||||
if (determinedType === 'delta') {
|
||||
@@ -235,9 +236,10 @@ export class EncodedPacketSink {
|
||||
}
|
||||
|
||||
const nextPacket = await this._track._backing.getNextKeyPacket(packet, options);
|
||||
if (!nextPacket || nextPacket.type === 'delta') {
|
||||
if (!nextPacket) {
|
||||
return nextPacket;
|
||||
}
|
||||
assert(nextPacket.type === 'key');
|
||||
|
||||
const determinedType = await this._track.determinePacketType(nextPacket);
|
||||
if (determinedType === 'delta') {
|
||||
@@ -474,9 +476,6 @@ export abstract class BaseMediaSampleSink<
|
||||
const packetSink = this._createPacketSink();
|
||||
const keyPacket = await packetSink.getKeyPacket(startTimestamp, { verifyKeyPackets: true })
|
||||
?? await packetSink.getFirstPacket();
|
||||
if (!keyPacket) {
|
||||
return;
|
||||
}
|
||||
|
||||
let currentPacket: EncodedPacket | null = keyPacket;
|
||||
|
||||
@@ -498,7 +497,7 @@ export abstract class BaseMediaSampleSink<
|
||||
}
|
||||
}
|
||||
|
||||
const packets = packetSink.packets(keyPacket, endPacket);
|
||||
const packets = packetSink.packets(keyPacket ?? undefined, endPacket);
|
||||
await packets.next(); // Skip the start packet as we already have it
|
||||
|
||||
while (currentPacket && !ended && !this._track.input._disposed) {
|
||||
@@ -2127,11 +2126,14 @@ export class AudioBufferSink {
|
||||
|
||||
/** @internal */
|
||||
_audioSampleToWrappedArrayBuffer(sample: AudioSample): WrappedAudioBuffer {
|
||||
return {
|
||||
const result: WrappedAudioBuffer = {
|
||||
buffer: sample.toAudioBuffer(),
|
||||
timestamp: sample.timestamp,
|
||||
duration: sample.duration,
|
||||
};
|
||||
|
||||
sample.close();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+19
-13
@@ -1356,21 +1356,27 @@ class AudioEncoderWrapper {
|
||||
(audioSample.timestamp + audioSample.duration) * audioSample.sampleRate,
|
||||
);
|
||||
|
||||
if (this.lastEndSampleIndex !== null && startSampleIndex > this.lastEndSampleIndex) {
|
||||
const sampleCount = startSampleIndex - this.lastEndSampleIndex;
|
||||
const fillSample = new AudioSample({
|
||||
data: new Float32Array(sampleCount * audioSample.numberOfChannels),
|
||||
format: 'f32-planar',
|
||||
sampleRate: audioSample.sampleRate,
|
||||
numberOfChannels: audioSample.numberOfChannels,
|
||||
numberOfFrames: sampleCount,
|
||||
timestamp: this.lastEndSampleIndex / audioSample.sampleRate,
|
||||
});
|
||||
if (this.lastEndSampleIndex === null) {
|
||||
this.lastEndSampleIndex = endSampleIndex;
|
||||
} else {
|
||||
const sampleDiff = startSampleIndex - this.lastEndSampleIndex;
|
||||
|
||||
await this.add(fillSample, true); // Recursive call
|
||||
if (sampleDiff >= 64) {
|
||||
// The gap is big enough, let's add a correction sample
|
||||
const fillSample = new AudioSample({
|
||||
data: new Float32Array(sampleDiff * audioSample.numberOfChannels),
|
||||
format: 'f32-planar',
|
||||
sampleRate: audioSample.sampleRate,
|
||||
numberOfChannels: audioSample.numberOfChannels,
|
||||
numberOfFrames: sampleDiff,
|
||||
timestamp: this.lastEndSampleIndex / audioSample.sampleRate,
|
||||
});
|
||||
|
||||
await this.add(fillSample, true); // Recursive call
|
||||
}
|
||||
|
||||
this.lastEndSampleIndex += audioSample.numberOfFrames;
|
||||
}
|
||||
|
||||
this.lastEndSampleIndex = endSampleIndex;
|
||||
}
|
||||
|
||||
if (this.customEncoder) {
|
||||
|
||||
@@ -259,3 +259,62 @@ export const metadataTagsAreEmpty = (tags: MetadataTags) => {
|
||||
&& tags.comment === undefined
|
||||
&& (tags.raw === undefined || Object.keys(tags.raw).length === 0);
|
||||
};
|
||||
|
||||
/**
|
||||
* Specifies a track's disposition, i.e. information about its intended usage.
|
||||
* @public
|
||||
* @group Miscellaneous
|
||||
*/
|
||||
export type TrackDisposition = {
|
||||
/**
|
||||
* Indicates that this track is eligible for automatic selection by a player; that it is the main track among other,
|
||||
* non-default tracks of the same type.
|
||||
*/
|
||||
default: boolean;
|
||||
/**
|
||||
* Indicates that players should always display this track by default, even if it goes against the user's default
|
||||
* preferences. For example, a subtitle track only containing translations of foreign-language audio.
|
||||
*/
|
||||
forced: boolean;
|
||||
/** Indicates that this track is in the content's original language. */
|
||||
original: boolean;
|
||||
/** Indicates that this track contains commentary. */
|
||||
commentary: boolean;
|
||||
/** Indicates that this track is intended for hearing-impaired users. */
|
||||
hearingImpaired: boolean;
|
||||
/** Indicates that this track is intended for visually-impaired users. */
|
||||
visuallyImpaired: boolean;
|
||||
};
|
||||
|
||||
export const DEFAULT_TRACK_DISPOSITION: TrackDisposition = {
|
||||
default: true,
|
||||
forced: false,
|
||||
original: false,
|
||||
commentary: false,
|
||||
hearingImpaired: false,
|
||||
visuallyImpaired: false,
|
||||
};
|
||||
|
||||
export const validateTrackDisposition = (disposition: Partial<TrackDisposition>) => {
|
||||
if (!disposition || typeof disposition !== 'object') {
|
||||
throw new TypeError('disposition must be an object.');
|
||||
}
|
||||
if (disposition.default !== undefined && typeof disposition.default !== 'boolean') {
|
||||
throw new TypeError('disposition.default must be a boolean.');
|
||||
}
|
||||
if (disposition.forced !== undefined && typeof disposition.forced !== 'boolean') {
|
||||
throw new TypeError('disposition.forced must be a boolean.');
|
||||
}
|
||||
if (disposition.original !== undefined && typeof disposition.original !== 'boolean') {
|
||||
throw new TypeError('disposition.original must be a boolean.');
|
||||
}
|
||||
if (disposition.commentary !== undefined && typeof disposition.commentary !== 'boolean') {
|
||||
throw new TypeError('disposition.commentary must be a boolean.');
|
||||
}
|
||||
if (disposition.hearingImpaired !== undefined && typeof disposition.hearingImpaired !== 'boolean') {
|
||||
throw new TypeError('disposition.hearingImpaired must be a boolean.');
|
||||
}
|
||||
if (disposition.visuallyImpaired !== undefined && typeof disposition.visuallyImpaired !== 'boolean') {
|
||||
throw new TypeError('disposition.visuallyImpaired must be a boolean.');
|
||||
}
|
||||
};
|
||||
+48
-10
@@ -157,20 +157,20 @@ export const writeBits = (bytes: Uint8Array, start: number, end: number, value:
|
||||
export const toUint8Array = (source: AllowSharedBufferSource): Uint8Array => {
|
||||
if (source.constructor === Uint8Array) { // We want a true Uint8Array, not something that extends it like Buffer
|
||||
return source;
|
||||
} else if (source instanceof ArrayBuffer) {
|
||||
return new Uint8Array(source);
|
||||
} else {
|
||||
} else if (ArrayBuffer.isView(source)) {
|
||||
return new Uint8Array(source.buffer, source.byteOffset, source.byteLength);
|
||||
} else {
|
||||
return new Uint8Array(source);
|
||||
}
|
||||
};
|
||||
|
||||
export const toDataView = (source: AllowSharedBufferSource) => {
|
||||
export const toDataView = (source: AllowSharedBufferSource): DataView => {
|
||||
if (source.constructor === DataView) {
|
||||
return source;
|
||||
} else if (source instanceof ArrayBuffer) {
|
||||
return new DataView(source);
|
||||
} else {
|
||||
} else if (ArrayBuffer.isView(source)) {
|
||||
return new DataView(source.buffer, source.byteOffset, source.byteLength);
|
||||
} else {
|
||||
return new DataView(source);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -515,7 +515,7 @@ export const isIso639Dash2LanguageCode = (x: string) => {
|
||||
};
|
||||
|
||||
// Since the result will be truncated, add a bit of eps to compensate for floating point errors
|
||||
export const SECOND_TO_MICROSECOND_FACTOR = /* #__PURE__ */ 1e6 * (1 + Number.EPSILON);
|
||||
export const SECOND_TO_MICROSECOND_FACTOR = 1e6 * (1 + Number.EPSILON);
|
||||
|
||||
/**
|
||||
* Sets all keys K of T to be required.
|
||||
@@ -584,6 +584,7 @@ export const retriedFetch = async (
|
||||
url: string | URL | Request,
|
||||
requestInit: RequestInit,
|
||||
getRetryDelay: (previousAttempts: number, error: unknown, url: string | URL | Request) => number | null,
|
||||
shouldStop: () => boolean,
|
||||
) => {
|
||||
let attempts = 0;
|
||||
|
||||
@@ -591,6 +592,10 @@ export const retriedFetch = async (
|
||||
try {
|
||||
return await fetchFn(url, requestInit);
|
||||
} catch (error) {
|
||||
if (shouldStop()) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
attempts++;
|
||||
const retryDelayInSeconds = getRetryDelay(attempts, error, url);
|
||||
|
||||
@@ -607,6 +612,10 @@ export const retriedFetch = async (
|
||||
if (retryDelayInSeconds > 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 * retryDelayInSeconds));
|
||||
}
|
||||
|
||||
if (shouldStop()) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -670,7 +679,15 @@ export const isWebKit = () => {
|
||||
}
|
||||
|
||||
// This even returns true for WebKit-wrapping browsers such as Chrome on iOS
|
||||
return isWebKitCache = !!(typeof navigator !== 'undefined' && navigator.vendor?.match(/apple/i));
|
||||
return isWebKitCache = !!(
|
||||
typeof navigator !== 'undefined'
|
||||
&& (
|
||||
navigator.vendor?.match(/apple/i)
|
||||
// Or, in workers:
|
||||
|| (/AppleWebKit/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent))
|
||||
|| /\b(iPad|iPhone|iPod)\b/.test(navigator.userAgent)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
let isFirefoxCache: boolean | null = null;
|
||||
@@ -688,7 +705,28 @@ export const isChromium = () => {
|
||||
return isChromiumCache;
|
||||
}
|
||||
|
||||
return isChromiumCache = !!(typeof navigator !== 'undefined' && navigator.vendor?.includes('Google Inc'));
|
||||
return isChromiumCache = !!(
|
||||
typeof navigator !== 'undefined'
|
||||
&& (navigator.vendor?.includes('Google Inc') || /Chrome/.test(navigator.userAgent))
|
||||
);
|
||||
};
|
||||
|
||||
let chromiumVersionCache: number | null = null;
|
||||
export const getChromiumVersion = () => {
|
||||
if (chromiumVersionCache !== null) {
|
||||
return chromiumVersionCache;
|
||||
}
|
||||
|
||||
if (typeof navigator === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = /\bChrome\/(\d+)/.exec(navigator.userAgent);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return chromiumVersionCache = Number(match[1]!);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,7 +10,7 @@ import { AudioCodec } from '../codec';
|
||||
import { Demuxer } from '../demuxer';
|
||||
import { Input } from '../input';
|
||||
import { InputAudioTrack, InputAudioTrackBacking } from '../input-track';
|
||||
import { MetadataTags } from '../tags';
|
||||
import { DEFAULT_TRACK_DISPOSITION, MetadataTags } from '../metadata';
|
||||
import { PacketRetrievalOptions } from '../media-sink';
|
||||
import { assert, AsyncMutex, binarySearchExact, binarySearchLessOrEqual, UNDETERMINED_LANGUAGE } from '../misc';
|
||||
import { EncodedPacket, PLACEHOLDER_DATA } from '../packet';
|
||||
@@ -257,6 +257,12 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking {
|
||||
return this.demuxer.firstFrameHeader.sampleRate;
|
||||
}
|
||||
|
||||
getDisposition() {
|
||||
return {
|
||||
...DEFAULT_TRACK_DISPOSITION,
|
||||
};
|
||||
}
|
||||
|
||||
async getDecoderConfig(): Promise<AudioDecoderConfig> {
|
||||
assert(this.demuxer.firstFrameHeader);
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { assert, toDataView } from '../misc';
|
||||
import { metadataTagsAreEmpty } from '../tags';
|
||||
import { metadataTagsAreEmpty } from '../metadata';
|
||||
import { Muxer } from '../muxer';
|
||||
import { Output, OutputAudioTrack } from '../output';
|
||||
import { Mp3OutputFormat } from '../output-format';
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Demuxer } from '../demuxer';
|
||||
import { Input } from '../input';
|
||||
import { InputAudioTrack, InputAudioTrackBacking } from '../input-track';
|
||||
import { PacketRetrievalOptions } from '../media-sink';
|
||||
import { MetadataTags } from '../tags';
|
||||
import { DEFAULT_TRACK_DISPOSITION, MetadataTags } from '../metadata';
|
||||
import {
|
||||
assert,
|
||||
AsyncMutex,
|
||||
@@ -463,6 +463,12 @@ class OggAudioTrackBacking implements InputAudioTrackBacking {
|
||||
return UNDETERMINED_LANGUAGE;
|
||||
}
|
||||
|
||||
getDisposition() {
|
||||
return {
|
||||
...DEFAULT_TRACK_DISPOSITION,
|
||||
};
|
||||
}
|
||||
|
||||
async getFirstTimestamp() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { OGGS } from './ogg-misc';
|
||||
|
||||
export const MIN_PAGE_HEADER_SIZE = 27;
|
||||
export const MAX_PAGE_HEADER_SIZE = 27 + 255;
|
||||
export const MAX_PAGE_SIZE = /* #__PURE__ */ MAX_PAGE_HEADER_SIZE + 255 * 255;
|
||||
export const MAX_PAGE_SIZE = MAX_PAGE_HEADER_SIZE + 255 * 255;
|
||||
|
||||
export type Page = {
|
||||
headerStartPos: number;
|
||||
|
||||
+6
-1
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { AsyncMutex, isIso639Dash2LanguageCode, Rotation } from './misc';
|
||||
import { MetadataTags, validateMetadataTags } from './tags';
|
||||
import { MetadataTags, TrackDisposition, validateMetadataTags, validateTrackDisposition } from './metadata';
|
||||
import { Muxer } from './muxer';
|
||||
import { OutputFormat } from './output-format';
|
||||
import { AudioSource, MediaSource, SubtitleSource, VideoSource } from './media-source';
|
||||
@@ -74,6 +74,8 @@ export type BaseTrackMetadata = {
|
||||
languageCode?: string;
|
||||
/** A user-defined name for this track, like "English" or "Director Commentary". */
|
||||
name?: string;
|
||||
/** The track's disposition, i.e. information about its intended usage. */
|
||||
disposition?: Partial<TrackDisposition>;
|
||||
/**
|
||||
* The maximum amount of encoded packets that will be added to this track. Setting this field provides the muxer
|
||||
* with an additional signal that it can use to preallocate space in the file.
|
||||
@@ -129,6 +131,9 @@ const validateBaseTrackMetadata = (metadata: BaseTrackMetadata) => {
|
||||
if (metadata.name !== undefined && typeof metadata.name !== 'string') {
|
||||
throw new TypeError('metadata.name, when provided, must be a string.');
|
||||
}
|
||||
if (metadata.disposition !== undefined) {
|
||||
validateTrackDisposition(metadata.disposition);
|
||||
}
|
||||
if (
|
||||
metadata.maximumPacketCount !== undefined
|
||||
&& (!Number.isInteger(metadata.maximumPacketCount) || metadata.maximumPacketCount < 0)
|
||||
|
||||
+65
-4
@@ -21,6 +21,54 @@ import {
|
||||
|
||||
polyfillSymbolDispose();
|
||||
|
||||
type FinalizationRegistryValue = {
|
||||
type: 'video';
|
||||
data: VideoFrame | OffscreenCanvas | Uint8Array;
|
||||
} | {
|
||||
type: 'audio';
|
||||
data: AudioData | Uint8Array;
|
||||
};
|
||||
|
||||
// Let's manually handle logging the garbage collection errors that are typically logged by the browser. This way, they
|
||||
// also kick for audio samples (which is normally not the case), making sure any incorrect code is quickly caught.
|
||||
let lastVideoGcErrorLog = -Infinity;
|
||||
let lastAudioGcErrorLog = -Infinity;
|
||||
let finalizationRegistry: FinalizationRegistry<FinalizationRegistryValue> | null = null;
|
||||
if (typeof FinalizationRegistry !== 'undefined') {
|
||||
finalizationRegistry = new FinalizationRegistry<FinalizationRegistryValue>((value) => {
|
||||
const now = Date.now();
|
||||
|
||||
if (value.type === 'video') {
|
||||
if (now - lastVideoGcErrorLog >= 1000) {
|
||||
// This error is annoying but oh so important
|
||||
console.error(
|
||||
`A VideoSample was garbage collected without first being closed. For proper resource management,`
|
||||
+ ` make sure to call close() on all your VideoSamples as soon as you're done using them.`,
|
||||
);
|
||||
|
||||
lastVideoGcErrorLog = now;
|
||||
}
|
||||
|
||||
if (typeof VideoFrame !== 'undefined' && value.data instanceof VideoFrame) {
|
||||
value.data.close(); // Prevent the browser error since we're logging our own
|
||||
}
|
||||
} else {
|
||||
if (now - lastAudioGcErrorLog >= 1000) {
|
||||
console.error(
|
||||
`An AudioSample was garbage collected without first being closed. For proper resource management,`
|
||||
+ ` make sure to call close() on all your AudioSamples as soon as you're done using them.`,
|
||||
);
|
||||
|
||||
lastAudioGcErrorLog = now;
|
||||
}
|
||||
|
||||
if (typeof AudioData !== 'undefined' && value.data instanceof AudioData) {
|
||||
value.data.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata used for VideoSample initialization.
|
||||
* @group Samples
|
||||
@@ -133,7 +181,11 @@ export class VideoSample implements Disposable {
|
||||
data: VideoFrame | CanvasImageSource | AllowSharedBufferSource,
|
||||
init?: VideoSampleInit,
|
||||
) {
|
||||
if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) {
|
||||
if (
|
||||
data instanceof ArrayBuffer
|
||||
|| (typeof SharedArrayBuffer !== 'undefined' && data instanceof SharedArrayBuffer)
|
||||
|| ArrayBuffer.isView(data)
|
||||
) {
|
||||
if (!init || typeof init !== 'object') {
|
||||
throw new TypeError('init must be an object.');
|
||||
}
|
||||
@@ -265,6 +317,8 @@ export class VideoSample implements Disposable {
|
||||
} else {
|
||||
throw new TypeError('Invalid data type: Must be a BufferSource or CanvasImageSource.');
|
||||
}
|
||||
|
||||
finalizationRegistry?.register(this, { type: 'video', data: this._data }, this);
|
||||
}
|
||||
|
||||
/** Clones this video sample. */
|
||||
@@ -313,6 +367,8 @@ export class VideoSample implements Disposable {
|
||||
return;
|
||||
}
|
||||
|
||||
finalizationRegistry?.unregister(this);
|
||||
|
||||
if (isVideoFrame(this._data)) {
|
||||
this._data.close();
|
||||
} else {
|
||||
@@ -955,6 +1011,8 @@ export class AudioSample implements Disposable {
|
||||
|
||||
this._data = dataBuffer;
|
||||
}
|
||||
|
||||
finalizationRegistry?.register(this, { type: 'audio', data: this._data }, this);
|
||||
}
|
||||
|
||||
/** Returns the number of bytes required to hold the audio sample's data as specified by the given options. */
|
||||
@@ -1116,9 +1174,8 @@ export class AudioSample implements Disposable {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Branch for Uint8Array data (non-AudioData)
|
||||
const uint8Data = this._data;
|
||||
const srcView = new DataView(uint8Data.buffer, uint8Data.byteOffset, uint8Data.byteLength);
|
||||
const srcView = toDataView(uint8Data);
|
||||
|
||||
const srcFormat = this.format;
|
||||
const readFn = getReadFunction(srcFormat);
|
||||
@@ -1188,6 +1245,8 @@ export class AudioSample implements Disposable {
|
||||
return;
|
||||
}
|
||||
|
||||
finalizationRegistry?.unregister(this);
|
||||
|
||||
if (isAudioData(this._data)) {
|
||||
this._data.close();
|
||||
} else {
|
||||
@@ -1250,7 +1309,9 @@ export class AudioSample implements Disposable {
|
||||
numberOfFrames: this.numberOfFrames,
|
||||
numberOfChannels: this.numberOfChannels,
|
||||
timestamp: this.microsecondTimestamp,
|
||||
data: this._data,
|
||||
data: this._data.buffer instanceof ArrayBuffer
|
||||
? this._data.buffer
|
||||
: this._data.slice(), // In the case of SharedArrayBuffer, convert to ArrayBuffer
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+45
-41
@@ -102,10 +102,17 @@ export class BufferSource extends Source {
|
||||
/** @internal */
|
||||
_onreadCalled = false;
|
||||
|
||||
/** Creates a new {@link BufferSource} backed the specified `ArrayBuffer` or `ArrayBufferView`. */
|
||||
constructor(buffer: ArrayBuffer | ArrayBufferView) {
|
||||
if (!(buffer instanceof ArrayBuffer) && !ArrayBuffer.isView(buffer)) {
|
||||
throw new TypeError('buffer must be an ArrayBuffer or ArrayBufferView.');
|
||||
/**
|
||||
* Creates a new {@link BufferSource} backed by the specified `ArrayBuffer`, `SharedArrayBuffer`,
|
||||
* or `ArrayBufferView`.
|
||||
*/
|
||||
constructor(buffer: AllowSharedBufferSource) {
|
||||
if (
|
||||
!(buffer instanceof ArrayBuffer)
|
||||
&& !(typeof SharedArrayBuffer !== 'undefined' && buffer instanceof SharedArrayBuffer)
|
||||
&& !ArrayBuffer.isView(buffer)
|
||||
) {
|
||||
throw new TypeError('buffer must be an ArrayBuffer, SharedArrayBuffer, or ArrayBufferView.');
|
||||
}
|
||||
|
||||
super();
|
||||
@@ -234,12 +241,7 @@ export class BlobSource extends Source {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
this._orchestrator.forgetWorker(worker);
|
||||
|
||||
if (worker.currentPos < worker.targetPos) { // I think this `if` should always hit?
|
||||
throw new Error('Blob reader stopped unexpectedly before all requested data was read.');
|
||||
}
|
||||
|
||||
break;
|
||||
throw new Error('Blob reader stopped unexpectedly before all requested data was read.');
|
||||
}
|
||||
|
||||
if (worker.aborted) {
|
||||
@@ -269,7 +271,7 @@ export class BlobSource extends Source {
|
||||
}
|
||||
}
|
||||
|
||||
const URL_SOURCE_MIN_LOAD_AMOUNT = /* #__PURE__ */ 0.5 * 2 ** 20; // 0.5 MiB
|
||||
const URL_SOURCE_MIN_LOAD_AMOUNT = 0.5 * 2 ** 20; // 0.5 MiB
|
||||
const DEFAULT_RETRY_DELAY
|
||||
= ((previousAttempts, error, src) => {
|
||||
// Check if this could be a CORS error. If so, we cannot recover from it and
|
||||
@@ -297,6 +299,10 @@ const DEFAULT_RETRY_DELAY
|
||||
= typeof navigator !== 'undefined' && typeof navigator.onLine === 'boolean' ? navigator.onLine : true;
|
||||
|
||||
if (isOnline && originOfSrc !== null && originOfSrc !== window.location.origin) {
|
||||
console.warn(
|
||||
`Request will not be retried because a CORS error was suspected due to different origins. You can`
|
||||
+ ` modify this behavior by providing your own function for the 'getRetryDelay' option.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -425,6 +431,7 @@ export class UrlSource extends Source {
|
||||
signal: abortController.signal,
|
||||
}),
|
||||
this._getRetryDelay,
|
||||
() => this._disposed,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -436,7 +443,7 @@ export class UrlSource extends Source {
|
||||
let fileSize: number;
|
||||
|
||||
if (response.status === 206) {
|
||||
fileSize = this._getPartialLengthFromRangeResponse(response);
|
||||
fileSize = this._getTotalLengthFromRangeResponse(response);
|
||||
worker = this._orchestrator.createWorker(0, Math.min(fileSize, URL_SOURCE_MIN_LOAD_AMOUNT));
|
||||
} else {
|
||||
// Server probably returned a 200.
|
||||
@@ -492,6 +499,7 @@ export class UrlSource extends Source {
|
||||
signal: abortController.signal,
|
||||
}),
|
||||
this._getRetryDelay,
|
||||
() => this._disposed,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -509,14 +517,6 @@ export class UrlSource extends Source {
|
||||
);
|
||||
}
|
||||
|
||||
const length = this._getPartialLengthFromRangeResponse(response);
|
||||
const required = worker.targetPos - worker.currentPos;
|
||||
if (length < required) {
|
||||
throw new Error(
|
||||
`HTTP response unexpectedly too short: Needed at least ${required} bytes, got only ${length}.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error(
|
||||
'Missing HTTP response body stream. The used fetch function must provide the response body as a'
|
||||
@@ -539,6 +539,11 @@ export class UrlSource extends Source {
|
||||
try {
|
||||
readResult = await reader.read();
|
||||
} catch (error) {
|
||||
if (this._disposed) {
|
||||
// No need to try to retry
|
||||
throw error;
|
||||
}
|
||||
|
||||
const retryDelayInSeconds = this._getRetryDelay(1, error, this._url);
|
||||
if (retryDelayInSeconds !== null) {
|
||||
console.error('Error while reading response stream. Attempting to resume.', error);
|
||||
@@ -557,16 +562,17 @@ export class UrlSource extends Source {
|
||||
const { done, value } = readResult;
|
||||
|
||||
if (done) {
|
||||
this._orchestrator.forgetWorker(worker);
|
||||
|
||||
if (worker.currentPos < worker.targetPos) {
|
||||
throw new Error(
|
||||
'Response stream reader stopped unexpectedly before all requested data was read.',
|
||||
);
|
||||
if (worker.currentPos >= worker.targetPos) {
|
||||
// All data was delivered, we're good
|
||||
this._orchestrator.forgetWorker(worker);
|
||||
worker.running = false;
|
||||
return;
|
||||
}
|
||||
|
||||
worker.running = false;
|
||||
return;
|
||||
// The response stopped early, before the target. This can happen if server decides to cap range
|
||||
// requests arbitrarily, even if the request had an uncapped end. In this case, let's fetch the rest
|
||||
// of the data using a new request.
|
||||
break;
|
||||
}
|
||||
|
||||
this.onread?.(worker.currentPos, worker.currentPos + value.length);
|
||||
@@ -586,25 +592,23 @@ export class UrlSource extends Source {
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
private _getPartialLengthFromRangeResponse(response: Response) {
|
||||
private _getTotalLengthFromRangeResponse(response: Response) {
|
||||
const contentRange = response.headers.get('Content-Range');
|
||||
if (contentRange) {
|
||||
const match = /\/(\d+)/.exec(contentRange);
|
||||
if (match) {
|
||||
return Number(match[1]);
|
||||
} else {
|
||||
throw new Error(`Invalid Content-Range header: ${contentRange}`);
|
||||
}
|
||||
}
|
||||
|
||||
const contentLength = response.headers.get('Content-Length');
|
||||
if (contentLength) {
|
||||
return Number(contentLength);
|
||||
} else {
|
||||
const contentLength = response.headers.get('Content-Length');
|
||||
if (contentLength) {
|
||||
return Number(contentLength);
|
||||
} else {
|
||||
throw new Error(
|
||||
'Partial HTTP response (status 206) must surface either Content-Range or'
|
||||
+ ' Content-Length header.',
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
'Partial HTTP response (status 206) must surface either Content-Range or'
|
||||
+ ' Content-Length header.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -639,7 +643,7 @@ export class FilePathSource extends Source {
|
||||
_fileHandle: FileHandle | null = null;
|
||||
|
||||
/** Creates a new {@link FilePathSource} backed by the file at the specified file path. */
|
||||
constructor(filePath: string, options: BlobSourceOptions = {}) {
|
||||
constructor(filePath: string, options: FilePathSourceOptions = {}) {
|
||||
if (typeof filePath !== 'string') {
|
||||
throw new TypeError('filePath must be a string.');
|
||||
}
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ export type StreamTargetChunk = {
|
||||
/** The operation type. */
|
||||
type: 'write'; // This ensures automatic compatibility with FileSystemWritableFileStream
|
||||
/** The data to write. */
|
||||
data: Uint8Array;
|
||||
data: Uint8Array<ArrayBuffer>;
|
||||
/** The byte offset in the output file at which to write the data. */
|
||||
position: number;
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Demuxer } from '../demuxer';
|
||||
import { Input } from '../input';
|
||||
import { InputAudioTrack, InputAudioTrackBacking } from '../input-track';
|
||||
import { PacketRetrievalOptions } from '../media-sink';
|
||||
import { MetadataTags } from '../tags';
|
||||
import { DEFAULT_TRACK_DISPOSITION, MetadataTags } from '../metadata';
|
||||
import { assert, UNDETERMINED_LANGUAGE } from '../misc';
|
||||
import { EncodedPacket, PLACEHOLDER_DATA } from '../packet';
|
||||
import { readAscii, readBytes, Reader, readU16, readU32, readU64 } from '../reader';
|
||||
@@ -407,6 +407,12 @@ class WaveAudioTrackBacking implements InputAudioTrackBacking {
|
||||
return UNDETERMINED_LANGUAGE;
|
||||
}
|
||||
|
||||
getDisposition() {
|
||||
return {
|
||||
...DEFAULT_TRACK_DISPOSITION,
|
||||
};
|
||||
}
|
||||
|
||||
async getFirstTimestamp() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Writer } from '../writer';
|
||||
import { EncodedPacket } from '../packet';
|
||||
import { WavOutputFormat } from '../output-format';
|
||||
import { assert, assertNever, isIso88591Compatible, keyValueIterator } from '../misc';
|
||||
import { MetadataTags, metadataTagsAreEmpty } from '../tags';
|
||||
import { MetadataTags, metadataTagsAreEmpty } from '../metadata';
|
||||
import { Id3V2Writer } from '../id3';
|
||||
|
||||
export class WaveMuxer extends Muxer {
|
||||
|
||||
+5
-5
@@ -89,8 +89,8 @@ export abstract class Writer {
|
||||
}
|
||||
}
|
||||
|
||||
const ARRAY_BUFFER_INITIAL_SIZE = /* #__PURE__ */ 2 ** 16;
|
||||
const ARRAY_BUFFER_MAX_SIZE = /* #__PURE__ */ 2 ** 32;
|
||||
const ARRAY_BUFFER_INITIAL_SIZE = 2 ** 16;
|
||||
const ARRAY_BUFFER_MAX_SIZE = 2 ** 32;
|
||||
|
||||
export class BufferTargetWriter extends Writer {
|
||||
private pos = 0;
|
||||
@@ -184,13 +184,13 @@ export class BufferTargetWriter extends Writer {
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_CHUNK_SIZE = /* #__PURE__ */ 2 ** 24;
|
||||
const DEFAULT_CHUNK_SIZE = 2 ** 24;
|
||||
const MAX_CHUNKS_AT_ONCE = 2;
|
||||
|
||||
interface Chunk {
|
||||
start: number;
|
||||
written: ChunkSection[];
|
||||
data: Uint8Array;
|
||||
data: Uint8Array<ArrayBuffer>;
|
||||
shouldFlush: boolean;
|
||||
}
|
||||
|
||||
@@ -281,7 +281,7 @@ export class StreamTargetWriter extends Writer {
|
||||
const chunks: {
|
||||
start: number;
|
||||
size: number;
|
||||
data?: Uint8Array;
|
||||
data?: Uint8Array<ArrayBuffer>;
|
||||
}[] = [];
|
||||
const sorted = [...this.sections].sort((a, b) => a.start - b.start);
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ test('can decode samples from a FLAC file', async () => {
|
||||
|
||||
const sink = new AudioSampleSink(track);
|
||||
|
||||
const sample = await sink.getSample(1);
|
||||
using sample = await sink.getSample(1);
|
||||
assert(sample);
|
||||
expect(sample.timestamp).toBe(0.9287981859410431);
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@ test('Can decode transparent video', async () => {
|
||||
expect(await videoTrack.canBeTransparent()).toBe(true);
|
||||
|
||||
const sink = new VideoSampleSink(videoTrack);
|
||||
const sample = (await sink.getSample(0.5))!;
|
||||
using sample = (await sink.getSample(0.5))!;
|
||||
|
||||
expect(sample.format).toContain('A'); // Probably RGBA
|
||||
expect(sample.hasAlpha).toBe(true);
|
||||
@@ -47,10 +47,10 @@ test('Can decode faulty transparent video and behaves gracefully', async () => {
|
||||
|
||||
const sink = new VideoSampleSink(videoTrack);
|
||||
|
||||
const startSample = (await sink.getSample(await videoTrack.getFirstTimestamp()))!;
|
||||
using startSample = (await sink.getSample(await videoTrack.getFirstTimestamp()))!;
|
||||
expect(startSample.format).toContain('A');
|
||||
|
||||
const secondSample = (await sink.getSample(secondKeyPacket.timestamp))!;
|
||||
using secondSample = (await sink.getSample(secondKeyPacket.timestamp))!;
|
||||
expect(secondSample.format).not.toContain('A'); // There was no alpha key frame for this one
|
||||
expect(secondSample.hasAlpha).toBe(false);
|
||||
});
|
||||
@@ -164,7 +164,7 @@ test('Can encode transparent video', async () => {
|
||||
|
||||
const sink = new VideoSampleSink(videoTrack);
|
||||
|
||||
const firstSample = (await sink.getSample(0))!;
|
||||
using firstSample = (await sink.getSample(0))!;
|
||||
expect(firstSample.format).toContain('A');
|
||||
|
||||
probeContext.clearRect(0, 0, probeCanvas.width, probeCanvas.height);
|
||||
@@ -201,7 +201,7 @@ test('Can encode video with alternating transparency', async () => {
|
||||
await output.start();
|
||||
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const sample = new VideoSample(new Uint8Array(640 * 480 * 4), {
|
||||
using sample = new VideoSample(new Uint8Array(640 * 480 * 4), {
|
||||
format: i % 2 ? 'RGBX' : 'RGBA',
|
||||
codedWidth: 640,
|
||||
codedHeight: 480,
|
||||
@@ -235,7 +235,7 @@ test('Can encode video with alternating transparency', async () => {
|
||||
const sampleSink = new VideoSampleSink(videoTrack);
|
||||
|
||||
i = 0;
|
||||
for await (const sample of sampleSink.samples()) {
|
||||
for await (using sample of sampleSink.samples()) {
|
||||
if (i % 2) {
|
||||
expect(sample.format).not.toContain('A');
|
||||
} else {
|
||||
@@ -299,7 +299,7 @@ test('Can transmux transparent video, discards alpha by default', async () => {
|
||||
expect(await videoTrack.canBeTransparent()).toBe(false);
|
||||
|
||||
const sink = new VideoSampleSink(videoTrack);
|
||||
const sample = (await sink.getSample(await videoTrack.getFirstTimestamp()))!;
|
||||
using sample = (await sink.getSample(await videoTrack.getFirstTimestamp()))!;
|
||||
expect(sample.hasAlpha).toBe(false);
|
||||
});
|
||||
|
||||
@@ -331,7 +331,7 @@ test('Can transmux transparent video, can keep alpha', async () => {
|
||||
expect(await videoTrack.canBeTransparent()).toBe(true);
|
||||
|
||||
const sink = new VideoSampleSink(videoTrack);
|
||||
const sample = (await sink.getSample(await videoTrack.getFirstTimestamp()))!;
|
||||
using sample = (await sink.getSample(await videoTrack.getFirstTimestamp()))!;
|
||||
expect(sample.format).toContain('A');
|
||||
expect(sample.hasAlpha).toBe(true);
|
||||
});
|
||||
@@ -370,7 +370,7 @@ test('Can reencode transparent video, keeping alpha', async () => {
|
||||
expect(videoTrack.displayWidth).toBe(320);
|
||||
|
||||
const sink = new VideoSampleSink(videoTrack);
|
||||
const sample = (await sink.getSample(await videoTrack.getFirstTimestamp()))!;
|
||||
using sample = (await sink.getSample(await videoTrack.getFirstTimestamp()))!;
|
||||
expect(sample.format).toContain('A');
|
||||
expect(sample.hasAlpha).toBe(true);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { Output } from '../../src/output.js';
|
||||
import { MkvOutputFormat } from '../../src/output-format.js';
|
||||
import { BufferTarget } from '../../src/target.js';
|
||||
import { EncodedVideoPacketSource } from '../../src/media-source.js';
|
||||
import { EncodedPacket } from '../../src/packet.js';
|
||||
import { Input } from '../../src/input.js';
|
||||
import { BufferSource } from '../../src/source.js';
|
||||
import { ALL_FORMATS } from '../../src/input-format.js';
|
||||
|
||||
test('Default track disposition', async () => {
|
||||
const output = new Output({
|
||||
format: new MkvOutputFormat(),
|
||||
target: new BufferTarget(),
|
||||
});
|
||||
|
||||
const source = new EncodedVideoPacketSource('avc');
|
||||
output.addVideoTrack(source);
|
||||
|
||||
await output.start();
|
||||
await source.add(new EncodedPacket(new Uint8Array(1024), 'key', 0, 1), {
|
||||
decoderConfig: {
|
||||
codec: 'avc1.123456',
|
||||
codedWidth: 1920,
|
||||
codedHeight: 1080,
|
||||
},
|
||||
});
|
||||
|
||||
await output.finalize();
|
||||
|
||||
using input = new Input({
|
||||
source: new BufferSource(output.target.buffer!),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const track = (await input.getPrimaryVideoTrack())!;
|
||||
|
||||
expect(track.disposition).toEqual({
|
||||
default: true,
|
||||
forced: false,
|
||||
original: false,
|
||||
hearingImpaired: false,
|
||||
visuallyImpaired: false,
|
||||
commentary: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('Customized track disposition', async () => {
|
||||
const output = new Output({
|
||||
format: new MkvOutputFormat(),
|
||||
target: new BufferTarget(),
|
||||
});
|
||||
|
||||
const source = new EncodedVideoPacketSource('avc');
|
||||
output.addVideoTrack(source, {
|
||||
disposition: {
|
||||
default: false,
|
||||
forced: true,
|
||||
original: true,
|
||||
hearingImpaired: true,
|
||||
visuallyImpaired: true,
|
||||
commentary: true,
|
||||
},
|
||||
});
|
||||
|
||||
await output.start();
|
||||
await source.add(new EncodedPacket(new Uint8Array(1024), 'key', 0, 1), {
|
||||
decoderConfig: {
|
||||
codec: 'avc1.123456',
|
||||
codedWidth: 1920,
|
||||
codedHeight: 1080,
|
||||
},
|
||||
});
|
||||
|
||||
await output.finalize();
|
||||
|
||||
using input = new Input({
|
||||
source: new BufferSource(output.target.buffer!),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const track = (await input.getPrimaryVideoTrack())!;
|
||||
|
||||
expect(track.disposition).toEqual({
|
||||
default: false,
|
||||
forced: true,
|
||||
original: true,
|
||||
hearingImpaired: true,
|
||||
visuallyImpaired: true,
|
||||
commentary: true,
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@ import { EncodedPacket } from '../../src/packet.js';
|
||||
import { Input } from '../../src/input.js';
|
||||
import { BufferSource, FilePathSource } from '../../src/source.js';
|
||||
import { ALL_FORMATS } from '../../src/input-format.js';
|
||||
import { AttachedFile, MetadataTags } from '../../src/tags.js';
|
||||
import { AttachedFile, MetadataTags } from '../../src/metadata.js';
|
||||
import path from 'node:path';
|
||||
import { AudioCodec, buildAudioCodecString } from '../../src/codec.js';
|
||||
import { Conversion } from '../../src/conversion.js';
|
||||
|
||||
Reference in New Issue
Block a user