diff --git a/examples/metadata-extraction/metadata-extraction.ts b/examples/metadata-extraction/metadata-extraction.ts
index f2d3fa8..792f312 100644
--- a/examples/metadata-extraction/metadata-extraction.ts
+++ b/examples/metadata-extraction/metadata-extraction.ts
@@ -1,18 +1,22 @@
-import { Input, ALL_FORMATS, BlobSource } from 'mediabunny';
+import { Input, ALL_FORMATS, BlobSource, UrlSource } from 'mediabunny';
import SampleFileUrl from '../../docs/assets/big-buck-bunny-trimmed.mp4';
(document.querySelector('#sample-file-download') as HTMLAnchorElement).href = SampleFileUrl;
-const selectMediaButton = document.querySelector('button') as HTMLButtonElement;
+const selectMediaButton = document.querySelector('#select-file') as HTMLButtonElement;
+const loadUrlButton = document.querySelector('#load-url') as HTMLButtonElement;
const fileNameElement = document.querySelector('#file-name') as HTMLParagraphElement;
const horizontalRule = document.querySelector('hr') as HTMLHRElement;
const bytesReadElement = document.querySelector('#bytes-read') as HTMLParagraphElement;
const metadataContainer = document.querySelector('#metadata-container') as HTMLDivElement;
-const extractMetadata = (file: File) => {
- // Create a new input from the file
+const extractMetadata = (resource: File | string) => {
+ // Create a new input from the resource
+ const source = resource instanceof File
+ ? new BlobSource(resource)
+ : new UrlSource(resource);
const input = new Input({
- source: new BlobSource(file),
+ source,
formats: ALL_FORMATS, // Accept all formats
});
@@ -78,7 +82,7 @@ const extractMetadata = (file: File) => {
}))),
};
- fileNameElement.textContent = file.name;
+ fileNameElement.textContent = resource instanceof File ? resource.name : resource;
horizontalRule.style.display = '';
bytesReadElement.innerHTML = '';
metadataContainer.innerHTML = '';
@@ -170,6 +174,19 @@ selectMediaButton.addEventListener('click', () => {
fileInput.click();
});
+loadUrlButton.addEventListener('click', () => {
+ const url = prompt(
+ 'Please enter a URL of a media file. Note that it must support cross-origin requests, so have the right'
+ + ' CORS headers set.',
+ 'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
+ );
+ if (!url) {
+ return;
+ }
+
+ extractMetadata(url);
+});
+
document.addEventListener('dragover', (event) => {
event.preventDefault();
event.dataTransfer!.dropEffect = 'copy';
diff --git a/examples/thumbnail-generation/index.html b/examples/thumbnail-generation/index.html
index dcda928..1ddd0d4 100644
--- a/examples/thumbnail-generation/index.html
+++ b/examples/thumbnail-generation/index.html
@@ -15,15 +15,22 @@
Select or drop a media file, and Mediabunny will extract video thumbnails for it.
-
- Select media file
-
+
+
diff --git a/examples/thumbnail-generation/thumbnail-generation.ts b/examples/thumbnail-generation/thumbnail-generation.ts
index 7ed09ee..faacc02 100644
--- a/examples/thumbnail-generation/thumbnail-generation.ts
+++ b/examples/thumbnail-generation/thumbnail-generation.ts
@@ -1,9 +1,10 @@
-import { Input, ALL_FORMATS, BlobSource, CanvasSink } from 'mediabunny';
+import { Input, ALL_FORMATS, BlobSource, UrlSource, CanvasSink } from 'mediabunny';
import SampleFileUrl from '../../docs/assets/big-buck-bunny-trimmed.mp4';
(document.querySelector('#sample-file-download') as HTMLAnchorElement).href = SampleFileUrl;
-const selectMediaButton = document.querySelector('button') as HTMLButtonElement;
+const selectMediaButton = document.querySelector('#select-file') as HTMLButtonElement;
+const loadUrlButton = document.querySelector('#load-url') as HTMLButtonElement;
const fileNameElement = document.querySelector('#file-name') as HTMLParagraphElement;
const horizontalRule = document.querySelector('hr') as HTMLHRElement;
const thumbnailContainer = document.querySelector('#thumbnail-container') as HTMLDivElement;
@@ -12,16 +13,19 @@ const errorElement = document.querySelector('#error-element') as HTMLParagraphEl
const THUMBNAIL_COUNT = 16;
const THUMBNAIL_SIZE = 200;
-const generateThumbnails = async (file: File) => {
- fileNameElement.textContent = file.name;
+const generateThumbnails = async (resource: File | string) => {
+ fileNameElement.textContent = resource instanceof File ? resource.name : resource;
horizontalRule.style.display = '';
errorElement.textContent = '';
thumbnailContainer.innerHTML = '';
try {
- // Create a new input from the file
+ // Create a new input from the resource
+ const source = resource instanceof File
+ ? new BlobSource(resource)
+ : new UrlSource(resource);
const input = new Input({
- source: new BlobSource(file),
+ source,
formats: ALL_FORMATS, // Accept all formats
});
@@ -122,6 +126,19 @@ selectMediaButton.addEventListener('click', () => {
fileInput.click();
});
+loadUrlButton.addEventListener('click', () => {
+ const url = prompt(
+ 'Please enter a URL of a media file. Note that it must support cross-origin requests, so have the right'
+ + ' CORS headers set.',
+ 'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
+ );
+ if (!url) {
+ return;
+ }
+
+ void generateThumbnails(url);
+});
+
document.addEventListener('dragover', (event) => {
event.preventDefault();
event.dataTransfer!.dropEffect = 'copy';
diff --git a/package-lock.json b/package-lock.json
index 63780a9..dbc4aae 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "mediabunny",
- "version": "1.11.2",
+ "version": "1.12.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mediabunny",
- "version": "1.11.2",
+ "version": "1.12.0",
"license": "MPL-2.0",
"workspaces": [
"packages/*"
@@ -38,7 +38,8 @@
"vite": "^6.3.5",
"vitepress": "^1.6.3",
"vitepress-plugin-llms": "^1.5.1",
- "vitepress-plugin-mermaid": "^2.0.17"
+ "vitepress-plugin-mermaid": "^2.0.17",
+ "vitest": "3.2.4"
},
"funding": {
"type": "individual",
@@ -2179,6 +2180,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/chai": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz",
+ "integrity": "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*"
+ }
+ },
"node_modules/@types/d3": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
@@ -2442,6 +2453,13 @@
"@types/ms": "*"
}
},
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/dom-mediacapture-transform": {
"version": "0.1.11",
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-transform/-/dom-mediacapture-transform-0.1.11.tgz",
@@ -2545,13 +2563,35 @@
"license": "MIT"
},
"node_modules/@types/node": {
- "version": "22.13.10",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz",
- "integrity": "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==",
+ "version": "22.18.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.0.tgz",
+ "integrity": "sha512-m5ObIqwsUp6BZzyiy4RdZpzWGub9bqLJMvZDD0QMXhxjqMHMENlj+SqF5QxoUwaQNFe+8kz8XM8ZQhqkQPTgMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "undici-types": "~6.20.0"
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/prop-types": {
+ "version": "15.7.15",
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true
+ },
+ "node_modules/@types/react": {
+ "version": "18.3.24",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.24.tgz",
+ "integrity": "sha512-0dLEBsA1kI3OezMBF8nSsb7Nk19ZnsyE1LLhB8r27KbgU5H4pvuqZLdtE+aUkJVoXgTVuA+iLIwmZ0TuK4tx6A==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "@types/prop-types": "*",
+ "csstype": "^3.0.2"
}
},
"node_modules/@types/trusted-types": {
@@ -2815,6 +2855,131 @@
"vue": "^3.2.25"
}
},
+ "node_modules/@vitest/expect": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
+ "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "3.2.4",
+ "@vitest/utils": "3.2.4",
+ "chai": "^5.2.0",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
+ "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "3.2.4",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.17"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/mocker/node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
+ "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
+ "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "3.2.4",
+ "pathe": "^2.0.3",
+ "strip-literal": "^3.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
+ "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.4",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
+ "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyspy": "^4.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
+ "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.4",
+ "loupe": "^3.1.4",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
"node_modules/@vue/compiler-core": {
"version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.13.tgz",
@@ -3206,6 +3371,16 @@
"sprintf-js": "~1.0.2"
}
},
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/bail": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
@@ -3280,6 +3455,16 @@
}
}
},
+ "node_modules/cac": {
+ "version": "6.7.14",
+ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -3301,6 +3486,23 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/chai": {
+ "version": "5.3.3",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
+ "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "assertion-error": "^2.0.1",
+ "check-error": "^2.1.1",
+ "deep-eql": "^5.0.1",
+ "loupe": "^3.1.0",
+ "pathval": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -3362,6 +3564,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/check-error": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz",
+ "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ }
+ },
"node_modules/cheerio": {
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.10.tgz",
@@ -4096,10 +4308,11 @@
"dev": true
},
"node_modules/debug": {
- "version": "4.4.0",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
- "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
+ "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
@@ -4126,6 +4339,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/deep-eql": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
+ "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -4289,6 +4512,13 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
+ "node_modules/es-module-lexer": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/esbuild": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.1.tgz",
@@ -4585,6 +4815,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/expect-type": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz",
+ "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/exsolve": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.4.tgz",
@@ -5688,6 +5928,13 @@
"loose-envify": "cli.js"
}
},
+ "node_modules/loupe": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
+ "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@@ -5900,9 +6147,9 @@
}
},
"node_modules/mediabunny": {
- "version": "1.11.1",
- "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.11.1.tgz",
- "integrity": "sha512-UsoLZi9aHcQWMHsQF0Y4xSREy2eByFvHRjK3kmvfuaYDHPiqsj0tN6HE+daSzr+WHUh98pFlu2XKXwzBp2AHcQ==",
+ "version": "1.11.2",
+ "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.11.2.tgz",
+ "integrity": "sha512-dZpaq+YMKo5dUz6HlwWtZJfKPf3rZCq9BXurC7/zVmgjB3sTW5l32VT65gWm/ojv0vUwH+WbtolkmOQX1viz5g==",
"license": "MPL-2.0",
"peer": true,
"workspaces": [
@@ -6798,6 +7045,16 @@
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true
},
+ "node_modules/pathval": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
+ "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.16"
+ }
+ },
"node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
@@ -7416,6 +7673,13 @@
"@types/hast": "^3.0.4"
}
},
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/slick": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/slick/-/slick-1.12.2.tgz",
@@ -7495,6 +7759,20 @@
"dev": true,
"license": "BSD-3-Clause"
},
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "3.9.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz",
+ "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/string-argv": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz",
@@ -7570,6 +7848,26 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/strip-literal": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.0.0.tgz",
+ "integrity": "sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^9.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/strip-literal/node_modules/js-tokens": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
+ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/stylis": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz",
@@ -7669,6 +7967,13 @@
"node": ">=18"
}
},
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/tinyexec": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
@@ -7676,9 +7981,9 @@
"dev": true
},
"node_modules/tinyglobby": {
- "version": "0.2.13",
- "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz",
- "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==",
+ "version": "0.2.14",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
+ "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7720,6 +8025,36 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/tinypool": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
+ "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
+ "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tinyspy": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.3.tgz",
+ "integrity": "sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -7872,9 +8207,9 @@
"dev": true
},
"node_modules/undici-types": {
- "version": "6.20.0",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
- "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
@@ -8133,6 +8468,29 @@
}
}
},
+ "node_modules/vite-node": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
+ "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cac": "^6.7.14",
+ "debug": "^4.4.1",
+ "es-module-lexer": "^1.7.0",
+ "pathe": "^2.0.3",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "bin": {
+ "vite-node": "vite-node.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
"node_modules/vite/node_modules/fdir": {
"version": "6.4.4",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz",
@@ -8746,6 +9104,92 @@
}
}
},
+ "node_modules/vitest": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
+ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/expect": "3.2.4",
+ "@vitest/mocker": "3.2.4",
+ "@vitest/pretty-format": "^3.2.4",
+ "@vitest/runner": "3.2.4",
+ "@vitest/snapshot": "3.2.4",
+ "@vitest/spy": "3.2.4",
+ "@vitest/utils": "3.2.4",
+ "chai": "^5.2.0",
+ "debug": "^4.4.1",
+ "expect-type": "^1.2.1",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.2",
+ "std-env": "^3.9.0",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^0.3.2",
+ "tinyglobby": "^0.2.14",
+ "tinypool": "^1.1.1",
+ "tinyrainbow": "^2.0.0",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
+ "vite-node": "3.2.4",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@types/debug": "^4.1.12",
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "@vitest/browser": "3.2.4",
+ "@vitest/ui": "3.2.4",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@types/debug": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest/node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
"node_modules/vscode-jsonrpc": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
@@ -8904,6 +9348,23 @@
"node": ">= 8"
}
},
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/wicked-good-xpath": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/wicked-good-xpath/-/wicked-good-xpath-1.3.0.tgz",
@@ -9017,7 +9478,7 @@
},
"packages/mp3-encoder": {
"name": "@mediabunny/mp3-encoder",
- "version": "1.11.2",
+ "version": "1.12.0",
"license": "MPL-2.0",
"devDependencies": {
"@types/emscripten": "^1.40.1"
diff --git a/package.json b/package.json
index 2072d8d..7b443cd 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "mediabunny",
"author": "Vanilagy",
- "version": "1.11.2",
+ "version": "1.12.0",
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
"type": "module",
"workspaces": [
@@ -27,6 +27,7 @@
"build": "./build.sh",
"watch": "tsx scripts/bundle.ts --watch",
"lint": "eslint .",
+ "test": "vitest --run",
"check": "tsc -p src --noEmit && tsc -p packages/mp3-encoder/src --noEmit && tsc -p scripts --noEmit && tsc -p tsconfig.vite.json --noEmit && rm tsconfig.vite.tsbuildinfo",
"check-docblocks": "tsx scripts/check-docblocks.ts dist/mediabunny.d.ts",
"docs:dev": "vitepress dev docs",
@@ -80,7 +81,8 @@
"vite": "^6.3.5",
"vitepress": "^1.6.3",
"vitepress-plugin-llms": "^1.5.1",
- "vitepress-plugin-mermaid": "^2.0.17"
+ "vitepress-plugin-mermaid": "^2.0.17",
+ "vitest": "3.2.4"
},
"keywords": [
"media",
diff --git a/packages/mp3-encoder/package.json b/packages/mp3-encoder/package.json
index 6109b80..b860afd 100644
--- a/packages/mp3-encoder/package.json
+++ b/packages/mp3-encoder/package.json
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/mp3-encoder",
"author": "Vanilagy",
- "version": "1.11.2",
+ "version": "1.12.0",
"description": "MP3 encoder extension for Mediabunny, based on LAME.",
"main": "./dist/bundles/mediabunny-mp3-encoder.mjs",
"module": "./dist/bundles/mediabunny-mp3-encoder.mjs",
diff --git a/packages/mp3-encoder/src/index.ts b/packages/mp3-encoder/src/index.ts
index 539175a..33e4a38 100644
--- a/packages/mp3-encoder/src/index.ts
+++ b/packages/mp3-encoder/src/index.ts
@@ -136,7 +136,7 @@ class Mp3Encoder extends CustomAudioEncoder {
let pos = 0;
while (pos <= this.currentBufferOffset - FRAME_HEADER_SIZE) {
const word = new DataView(this.buffer.buffer).getUint32(pos, false);
- const header = readFrameHeader(word, { pos, fileSize: null });
+ const header = readFrameHeader(word, null).header;
if (!header) {
break;
}
diff --git a/scripts/esbuild/inlined-workers.ts b/scripts/esbuild/inlined-workers.ts
index eee44ac..0f08ba9 100644
--- a/scripts/esbuild/inlined-workers.ts
+++ b/scripts/esbuild/inlined-workers.ts
@@ -40,10 +40,10 @@ export default async function inlineWorker(scriptText) {
let Worker;
try {
- Worker = (await import('worker_threads')).Worker;
+ Worker = (await import('worker_threads')).Worker;
} catch {
- const workerModule = 'worker_threads';
- Worker = require(workerModule).Worker;
+ const workerModule = 'worker_threads';
+ Worker = require(workerModule).Worker;
}
const worker = new Worker(scriptText, { eval: true });
diff --git a/shared/mp3-misc.ts b/shared/mp3-misc.ts
index fdd9554..67358f9 100644
--- a/shared/mp3-misc.ts
+++ b/shared/mp3-misc.ts
@@ -40,7 +40,6 @@ export const XING = 0x58696e67;
export const INFO = 0x496e666f;
export type FrameHeader = {
- startPos: number;
totalSize: number;
mpegVersionId: number;
layer: number;
@@ -70,27 +69,28 @@ export const getXingOffset = (mpegVersionId: number, channel: number) => {
: (channel === 3 ? 13 : 21);
};
-export const readFrameHeader = (word: number, reader: { pos: number; fileSize: number | null }): FrameHeader | null => {
- const startPos = reader.pos;
-
+export const readFrameHeader = (word: number, remainingBytes: number | null): {
+ header: FrameHeader | null;
+ bytesAdvanced: number;
+} => {
const firstByte = word >>> 24;
const secondByte = (word >>> 16) & 0xff;
const thirdByte = (word >>> 8) & 0xff;
const fourthByte = word & 0xff;
if (firstByte !== 0xff && secondByte !== 0xff && thirdByte !== 0xff && fourthByte !== 0xff) {
- reader.pos += 4;
- return null;
+ return {
+ header: null,
+ bytesAdvanced: 4,
+ };
}
- reader.pos += 1;
-
if (firstByte !== 0xff) {
- return null;
+ return { header: null, bytesAdvanced: 1 };
}
if ((secondByte & 0xe0) !== 0xe0) {
- return null;
+ return { header: null, bytesAdvanced: 1 };
}
const mpegVersionId = (secondByte >> 3) & 0x3;
@@ -110,21 +110,21 @@ export const readFrameHeader = (word: number, reader: { pos: number; fileSize: n
? MPEG_V1_BITRATES[layer]?.[bitrateIndex]
: MPEG_V2_BITRATES[layer]?.[bitrateIndex];
if (!kilobitRate || kilobitRate === -1) {
- return null;
+ return { header: null, bytesAdvanced: 1 };
}
const bitrate = kilobitRate * 1000;
const sampleRate = SAMPLING_RATES[mpegVersionId]?.[frequencyIndex];
if (!sampleRate || sampleRate === -1) {
- return null;
+ return { header: null, bytesAdvanced: 1 };
}
const frameLength = computeMp3FrameSize(layer, bitrate, sampleRate, padding);
- if (reader.fileSize !== null && reader.fileSize - startPos < frameLength) {
+ if (remainingBytes !== null && remainingBytes < frameLength) {
// The frame doesn't fit into the rest of the file
- return null;
+ return { header: null, bytesAdvanced: 1 };
}
let audioSamplesInFrame: number;
@@ -141,18 +141,20 @@ export const readFrameHeader = (word: number, reader: { pos: number; fileSize: n
}
return {
- startPos: startPos,
- totalSize: frameLength,
- mpegVersionId,
- layer,
- bitrate,
- frequencyIndex,
- sampleRate,
- channel,
- modeExtension,
- copyright,
- original,
- emphasis,
- audioSamplesInFrame,
+ header: {
+ totalSize: frameLength,
+ mpegVersionId,
+ layer,
+ bitrate,
+ frequencyIndex,
+ sampleRate,
+ channel,
+ modeExtension,
+ copyright,
+ original,
+ emphasis,
+ audioSamplesInFrame,
+ },
+ bytesAdvanced: 1,
};
};
diff --git a/src/adts/adts-demuxer.ts b/src/adts/adts-demuxer.ts
index e6d6f32..30f0ef0 100644
--- a/src/adts/adts-demuxer.ts
+++ b/src/adts/adts-demuxer.ts
@@ -20,7 +20,8 @@ import {
UNDETERMINED_LANGUAGE,
} from '../misc';
import { EncodedPacket, PLACEHOLDER_DATA } from '../packet';
-import { AdtsReader, FrameHeader, MAX_FRAME_HEADER_SIZE } from './adts-reader';
+import { readBytes, Reader } from '../reader';
+import { FrameHeader, MAX_FRAME_HEADER_SIZE, MIN_FRAME_HEADER_SIZE, readFrameHeader } from './adts-reader';
const SAMPLES_PER_AAC_FRAME = 1024;
@@ -32,30 +33,31 @@ type Sample = {
};
export class AdtsDemuxer extends Demuxer {
- reader: AdtsReader;
+ reader: Reader;
metadataPromise: Promise
| null = null;
firstFrameHeader: FrameHeader | null = null;
- loadedSamples: Sample[] = []; // All samples from the start of the file to lastLoadedPos
+ loadedSamples: Sample[] = [];
tracks: InputAudioTrack[] = [];
readingMutex = new AsyncMutex();
+ lastSampleLoaded = false;
lastLoadedPos = 0;
- fileSize = 0;
nextTimestampInSamples = 0;
constructor(input: Input) {
super(input);
- this.reader = new AdtsReader(input._mainReader);
+ this.reader = input._reader;
}
async readMetadata() {
return this.metadataPromise ??= (async () => {
- this.fileSize = await this.input.source.getSize();
-
- await this.loadNextChunk();
+ // Keep loading until we find the first frame header
+ while (!this.firstFrameHeader && !this.lastSampleLoaded) {
+ await this.advanceReader();
+ }
// There has to be a frame if this demuxer got selected
assert(this.firstFrameHeader);
@@ -65,55 +67,45 @@ export class AdtsDemuxer extends Demuxer {
})();
}
- async loadNextChunk() {
- assert(this.lastLoadedPos < this.fileSize);
-
- const chunkSize = 0.5 * 1024 * 1024; // 0.5 MiB
- const endPos = Math.min(this.lastLoadedPos + chunkSize, this.fileSize);
- await this.reader.reader.loadRange(this.lastLoadedPos, endPos);
-
- this.lastLoadedPos = endPos;
- assert(this.lastLoadedPos <= this.fileSize);
-
- this.parseFramesFromLoadedData();
- }
-
- private parseFramesFromLoadedData() {
- while (this.reader.pos <= this.fileSize - MAX_FRAME_HEADER_SIZE) {
- const startPos = this.reader.pos;
- const header = this.reader.readFrameHeader();
- if (!header) {
- break;
- }
-
- // Check if the entire frame fits in the loaded data
- if (startPos + header.frameLength > this.lastLoadedPos) {
- // Frame doesn't fit, reset positions and stop
- this.reader.pos = startPos;
- this.lastLoadedPos = startPos;
- break;
- }
-
- if (!this.firstFrameHeader) {
- this.firstFrameHeader = header;
- }
-
- const sampleRate = aacFrequencyTable[header.samplingFrequencyIndex];
- assert(sampleRate !== undefined);
- const sampleDuration = SAMPLES_PER_AAC_FRAME / sampleRate;
- const headerSize = header.crcCheck ? MAX_FRAME_HEADER_SIZE : MAX_FRAME_HEADER_SIZE - 2;
-
- const sample: Sample = {
- timestamp: this.nextTimestampInSamples / sampleRate,
- duration: sampleDuration,
- dataStart: startPos + headerSize,
- dataSize: header.frameLength - headerSize,
- };
-
- this.loadedSamples.push(sample);
- this.nextTimestampInSamples += SAMPLES_PER_AAC_FRAME;
- this.reader.pos = startPos + header.frameLength;
+ async advanceReader() {
+ let slice = this.reader.requestSliceRange(this.lastLoadedPos, MIN_FRAME_HEADER_SIZE, MAX_FRAME_HEADER_SIZE);
+ if (slice instanceof Promise) slice = await slice;
+ if (!slice) {
+ this.lastSampleLoaded = true;
+ return;
}
+
+ const header = readFrameHeader(slice);
+ if (!header) {
+ this.lastSampleLoaded = true;
+ return;
+ }
+
+ if (header.startPos + header.frameLength > this.reader.fileSize) {
+ // Frame doesn't fit in the rest of the file
+ this.lastSampleLoaded = true;
+ return;
+ }
+
+ if (!this.firstFrameHeader) {
+ this.firstFrameHeader = header;
+ }
+
+ const sampleRate = aacFrequencyTable[header.samplingFrequencyIndex];
+ assert(sampleRate !== undefined);
+ const sampleDuration = SAMPLES_PER_AAC_FRAME / sampleRate;
+ const headerSize = header.crcCheck ? MAX_FRAME_HEADER_SIZE : MIN_FRAME_HEADER_SIZE;
+
+ const sample: Sample = {
+ timestamp: this.nextTimestampInSamples / sampleRate,
+ duration: sampleDuration,
+ dataStart: header.startPos + headerSize,
+ dataSize: header.frameLength - headerSize,
+ };
+
+ this.loadedSamples.push(sample);
+ this.nextTimestampInSamples += SAMPLES_PER_AAC_FRAME;
+ this.lastLoadedPos = header.startPos + header.frameLength;
}
async getMimeType() {
@@ -219,7 +211,7 @@ class AdtsAudioTrackBacking implements InputAudioTrackBacking {
};
}
- getPacketAtIndex(sampleIndex: number, options: PacketRetrievalOptions) {
+ async getPacketAtIndex(sampleIndex: number, options: PacketRetrievalOptions) {
if (sampleIndex === -1) {
return null;
}
@@ -233,8 +225,11 @@ class AdtsAudioTrackBacking implements InputAudioTrackBacking {
if (options.metadataOnly) {
data = PLACEHOLDER_DATA;
} else {
- this.demuxer.reader.pos = rawSample.dataStart;
- data = this.demuxer.reader.readBytes(rawSample.dataSize);
+ let slice = this.demuxer.reader.requestSlice(rawSample.dataStart, rawSample.dataSize);
+ if (slice instanceof Promise) slice = await slice;
+ assert(slice);
+
+ data = readBytes(slice, rawSample.dataSize);
}
return new EncodedPacket(
@@ -247,7 +242,7 @@ class AdtsAudioTrackBacking implements InputAudioTrackBacking {
);
}
- async getFirstPacket(options: PacketRetrievalOptions) {
+ getFirstPacket(options: PacketRetrievalOptions) {
return this.getPacketAtIndex(0, options);
}
@@ -268,9 +263,9 @@ class AdtsAudioTrackBacking implements InputAudioTrackBacking {
// Ensure the next sample exists
while (
nextIndex >= this.demuxer.loadedSamples.length
- && this.demuxer.lastLoadedPos < this.demuxer.fileSize
+ && !this.demuxer.lastSampleLoaded
) {
- await this.demuxer.loadNextChunk();
+ await this.demuxer.advanceReader();
}
return this.getPacketAtIndex(nextIndex, options);
@@ -294,7 +289,7 @@ class AdtsAudioTrackBacking implements InputAudioTrackBacking {
return null;
}
- if (this.demuxer.lastLoadedPos === this.demuxer.fileSize) {
+ if (this.demuxer.lastSampleLoaded) {
// All data is loaded, return what we found
return this.getPacketAtIndex(index, options);
}
@@ -305,7 +300,7 @@ class AdtsAudioTrackBacking implements InputAudioTrackBacking {
}
// Otherwise, keep loading data
- await this.demuxer.loadNextChunk();
+ await this.demuxer.advanceReader();
}
} finally {
release();
diff --git a/src/adts/adts-reader.ts b/src/adts/adts-reader.ts
index 5abffca..bf21f6a 100644
--- a/src/adts/adts-reader.ts
+++ b/src/adts/adts-reader.ts
@@ -7,8 +7,9 @@
*/
import { Bitstream } from '../misc';
-import { Reader } from '../reader';
+import { FileSlice, readBytes } from '../reader';
+export const MIN_FRAME_HEADER_SIZE = 7;
export const MAX_FRAME_HEADER_SIZE = 9;
export type FrameHeader = {
@@ -21,76 +22,64 @@ export type FrameHeader = {
startPos: number;
};
-export class AdtsReader {
- pos = 0;
- constructor(public reader: Reader) {}
+export const readFrameHeader = (slice: FileSlice): FrameHeader | null => {
+ // https://wiki.multimedia.cx/index.php/ADTS (last visited: 2025/08/17)
- readBytes(length: number) {
- const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + length);
- this.pos += length;
+ const startPos = slice.filePos;
- return new Uint8Array(view.buffer, offset, length);
+ const bytes = readBytes(slice, 9); // 9 with CRC, 7 without CRC
+ const bitstream = new Bitstream(bytes);
+
+ const syncword = bitstream.readBits(12);
+ if (syncword !== 0b1111_11111111) {
+ return null;
}
- readFrameHeader(): FrameHeader | null {
- // https://wiki.multimedia.cx/index.php/ADTS (last visited: 2025/08/17)
-
- const startPos = this.pos;
-
- const bytes = this.readBytes(9); // 9 with CRC, 7 without CRC
- const bitstream = new Bitstream(bytes);
-
- const syncword = bitstream.readBits(12);
- if (syncword !== 0b1111_11111111) {
- return null;
- }
-
- bitstream.skipBits(1); // MPEG version
- const layer = bitstream.readBits(2);
- if (layer !== 0) {
- return null;
- }
-
- const protectionAbsence = bitstream.readBits(1);
- const objectType = bitstream.readBits(2) + 1;
- const samplingFrequencyIndex = bitstream.readBits(4);
- if (samplingFrequencyIndex === 15) {
- return null;
- }
-
- bitstream.skipBits(1); // Private bit
- const channelConfiguration = bitstream.readBits(3);
- if (channelConfiguration === 0) {
- throw new Error('ADTS frames with channel configuration 0 are not supported.');
- }
-
- bitstream.skipBits(1); // Originality
- bitstream.skipBits(1); // Home
- bitstream.skipBits(1); // Copyright ID bit
- bitstream.skipBits(1); // Copyright ID start
- const frameLength = bitstream.readBits(13);
- bitstream.skipBits(11); // Buffer fullness
- const numberOfAacFrames = bitstream.readBits(2) + 1;
- if (numberOfAacFrames !== 1) {
- throw new Error('ADTS frames with more than one AAC frame are not supported.');
- }
-
- let crcCheck: number | null = null;
-
- if (protectionAbsence === 1) { // No CRC
- this.pos -= 2;
- } else { // CRC
- crcCheck = bitstream.readBits(16);
- }
-
- return {
- objectType,
- samplingFrequencyIndex,
- channelConfiguration,
- frameLength,
- numberOfAacFrames,
- crcCheck,
- startPos,
- };
+ bitstream.skipBits(1); // MPEG version
+ const layer = bitstream.readBits(2);
+ if (layer !== 0) {
+ return null;
}
-}
+
+ const protectionAbsence = bitstream.readBits(1);
+ const objectType = bitstream.readBits(2) + 1;
+ const samplingFrequencyIndex = bitstream.readBits(4);
+ if (samplingFrequencyIndex === 15) {
+ return null;
+ }
+
+ bitstream.skipBits(1); // Private bit
+ const channelConfiguration = bitstream.readBits(3);
+ if (channelConfiguration === 0) {
+ throw new Error('ADTS frames with channel configuration 0 are not supported.');
+ }
+
+ bitstream.skipBits(1); // Originality
+ bitstream.skipBits(1); // Home
+ bitstream.skipBits(1); // Copyright ID bit
+ bitstream.skipBits(1); // Copyright ID start
+ const frameLength = bitstream.readBits(13);
+ bitstream.skipBits(11); // Buffer fullness
+ const numberOfAacFrames = bitstream.readBits(2) + 1;
+ if (numberOfAacFrames !== 1) {
+ throw new Error('ADTS frames with more than one AAC frame are not supported.');
+ }
+
+ let crcCheck: number | null = null;
+
+ if (protectionAbsence === 1) { // No CRC
+ slice.filePos -= 2;
+ } else { // CRC
+ crcCheck = bitstream.readBits(16);
+ }
+
+ return {
+ objectType,
+ samplingFrequencyIndex,
+ channelConfiguration,
+ frameLength,
+ numberOfAacFrames,
+ crcCheck,
+ startPos,
+ };
+};
diff --git a/src/codec-data.ts b/src/codec-data.ts
index edcd00f..d315199 100644
--- a/src/codec-data.ts
+++ b/src/codec-data.ts
@@ -1069,7 +1069,7 @@ export type Av1CodecInfo = {
};
/** Iterates over all OBUs in an AV1 packet bistream. */
-export function* iterateAv1PacketObus(packet: Uint8Array) {
+export const iterateAv1PacketObus = function* (packet: Uint8Array) {
// https://aomediacodec.github.io/av1-spec/av1-spec.pdf
const bitstream = new Bitstream(packet);
diff --git a/src/encode.ts b/src/encode.ts
index 8166c30..dd596c5 100644
--- a/src/encode.ts
+++ b/src/encode.ts
@@ -79,7 +79,15 @@ export const validateVideoEncodingConfig = (config: VideoEncodingConfig) => {
) {
throw new TypeError('config.keyFrameInterval, when provided, must be a non-negative number.');
}
- // todo here
+ if (
+ config.sizeChangeBehavior !== undefined
+ && !['deny', 'passThrough', 'fill', 'contain', 'cover'].includes(config.sizeChangeBehavior)
+ ) {
+ throw new TypeError(
+ 'config.sizeChangeBehavior, when provided, must be \'deny\', \'passThrough\', \'fill\', \'contain\''
+ + ' or \'cover\'.',
+ );
+ }
if (config.onEncodedPacket !== undefined && typeof config.onEncodedPacket !== 'function') {
throw new TypeError('config.onEncodedChunk, when provided, must be a function.');
}
diff --git a/src/index.ts b/src/index.ts
index 9354d29..1b9e5ad 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -94,11 +94,14 @@ export { Rotation, AnyIterable, SetRequired, MaybePromise } from './misc';
export {
Source,
BufferSource,
- StreamSource,
- StreamSourceOptions,
BlobSource,
+ BlobSourceOptions,
UrlSource,
UrlSourceOptions,
+ FilePathSource,
+ FilePathSourceOptions,
+ StreamSource,
+ StreamSourceOptions,
} from './source';
export {
InputFormat,
diff --git a/src/input-format.ts b/src/input-format.ts
index 961ef8b..1d4f17b 100644
--- a/src/input-format.ts
+++ b/src/input-format.ts
@@ -9,18 +9,25 @@
import { Demuxer } from './demuxer';
import { Input } from './input';
import { IsobmffDemuxer } from './isobmff/isobmff-demuxer';
-import { IsobmffReader } from './isobmff/isobmff-reader';
-import { EBMLId, EBMLReader, MIN_HEADER_SIZE } from './matroska/ebml';
+import {
+ EBMLId,
+ MAX_HEADER_SIZE,
+ MIN_HEADER_SIZE,
+ readAsciiString,
+ readElementHeader,
+ readElementSize,
+ readUnsignedInt,
+ readVarIntSize,
+} from './matroska/ebml';
import { MatroskaDemuxer } from './matroska/matroska-demuxer';
import { Mp3Demuxer } from './mp3/mp3-demuxer';
import { FRAME_HEADER_SIZE } from '../shared/mp3-misc';
-import { Mp3Reader } from './mp3/mp3-reader';
+import { readId3, readNextFrameHeader } from './mp3/mp3-reader';
import { OggDemuxer } from './ogg/ogg-demuxer';
-import { OggReader } from './ogg/ogg-reader';
-import { RiffReader } from './wave/riff-reader';
import { WaveDemuxer } from './wave/wave-demuxer';
-import { AdtsReader, MAX_FRAME_HEADER_SIZE } from './adts/adts-reader';
+import { MAX_FRAME_HEADER_SIZE, MIN_FRAME_HEADER_SIZE, readFrameHeader } from './adts/adts-reader';
import { AdtsDemuxer } from './adts/adts-demuxer';
+import { readAscii } from './reader';
/**
* Base class representing an input media file format.
@@ -46,20 +53,18 @@ export abstract class InputFormat {
export abstract class IsobmffInputFormat extends InputFormat {
/** @internal */
protected async _getMajorBrand(input: Input) {
- const sourceSize = await input._mainReader.source.getSize();
- if (sourceSize < 12) {
- return null;
- }
+ let slice = input._reader.requestSlice(0, 12);
+ if (slice instanceof Promise) slice = await slice;
+ if (!slice) return null;
- const isobmffReader = new IsobmffReader(input._mainReader);
- isobmffReader.pos = 4;
- const fourCc = isobmffReader.readAscii(4);
+ slice.skip(4);
+ const fourCc = readAscii(slice, 4);
if (fourCc !== 'ftyp') {
return null;
}
- return isobmffReader.readAscii(4);
+ return readAscii(slice, 4);
}
/** @internal */
@@ -108,10 +113,6 @@ export class QuickTimeInputFormat extends IsobmffInputFormat {
}
}
-function foo() {
- return 5;
-}
-
/**
* Matroska file format.
* @public
@@ -119,70 +120,71 @@ function foo() {
export class MatroskaInputFormat extends InputFormat {
/** @internal */
protected async isSupportedEBMLOfDocType(input: Input, desiredDocType: string) {
- const sourceSize = await input._mainReader.source.getSize();
- if (sourceSize < 8) {
- return false;
- }
+ let headerSlice = input._reader.requestSlice(0, MAX_HEADER_SIZE);
+ if (headerSlice instanceof Promise) headerSlice = await headerSlice;
+ if (!headerSlice) return false;
- const ebmlReader = new EBMLReader(input._mainReader);
- const varIntSize = ebmlReader.readVarIntSize();
+ const varIntSize = readVarIntSize(headerSlice);
if (varIntSize === null) {
return false;
}
- foo();
-
if (varIntSize < 1 || varIntSize > 8) {
return false;
}
- const id = ebmlReader.readUnsignedInt(varIntSize);
+ const id = readUnsignedInt(headerSlice, varIntSize);
if (id !== EBMLId.EBML) {
return false;
}
- const dataSize = ebmlReader.readElementSize();
+ const dataSize = readElementSize(headerSlice);
if (dataSize === null) {
return false; // Miss me with that shit
}
- const startPos = ebmlReader.pos;
- while (ebmlReader.pos <= startPos + dataSize - MIN_HEADER_SIZE) {
- const header = ebmlReader.readElementHeader();
+ let dataSlice = input._reader.requestSlice(headerSlice.filePos, dataSize);
+ if (dataSlice instanceof Promise) dataSlice = await dataSlice;
+ if (!dataSlice) return false;
+
+ const startPos = headerSlice.filePos;
+
+ while (dataSlice.filePos <= startPos + dataSize - MIN_HEADER_SIZE) {
+ const header = readElementHeader(dataSlice);
if (!header) break;
const { id, size } = header;
- const dataStartPos = ebmlReader.pos;
+ const dataStartPos = dataSlice.filePos;
if (size === null) return false;
switch (id) {
case EBMLId.EBMLVersion: {
- const ebmlVersion = ebmlReader.readUnsignedInt(size);
+ const ebmlVersion = readUnsignedInt(dataSlice, size);
if (ebmlVersion !== 1) {
return false;
}
}; break;
case EBMLId.EBMLReadVersion: {
- const ebmlReadVersion = ebmlReader.readUnsignedInt(size);
+ const ebmlReadVersion = readUnsignedInt(dataSlice, size);
if (ebmlReadVersion !== 1) {
return false;
}
}; break;
case EBMLId.DocType: {
- const docType = ebmlReader.readAsciiString(size);
+ const docType = readAsciiString(dataSlice, size);
if (docType !== desiredDocType) {
return false;
}
}; break;
case EBMLId.DocTypeVersion: {
- const docTypeVersion = ebmlReader.readUnsignedInt(size);
+ const docTypeVersion = readUnsignedInt(dataSlice, size);
if (docTypeVersion > 4) { // Support up to Matroska v4
return false;
}
}; break;
}
- ebmlReader.pos = dataStartPos + size;
+ dataSlice.filePos = dataStartPos + size;
}
return true;
@@ -233,25 +235,20 @@ export class WebMInputFormat extends MatroskaInputFormat {
export class Mp3InputFormat extends InputFormat {
/** @internal */
async _canReadInput(input: Input) {
- const sourceSize = await input._mainReader.source.getSize();
- if (sourceSize < 4) {
- return false;
- }
+ let slice = input._reader.requestSlice(0, 10);
+ if (slice instanceof Promise) slice = await slice;
+ if (!slice) return false;
- const mp3Reader = new Mp3Reader(input._mainReader);
- mp3Reader.fileSize = sourceSize;
+ let currentPos = 0;
- const id3Tag = mp3Reader.readId3();
+ const id3Tag = readId3(slice);
if (id3Tag) {
- mp3Reader.pos += id3Tag.size;
+ currentPos = slice.filePos + id3Tag.size;
}
- const framesStartPos = mp3Reader.pos;
- await mp3Reader.reader.loadRange(mp3Reader.pos, mp3Reader.pos + 4096);
-
- const firstHeader = mp3Reader.readNextFrameHeader(Math.min(framesStartPos + 4096, sourceSize));
- if (!firstHeader) {
+ const firstResult = await readNextFrameHeader(input._reader, currentPos, currentPos + 4096);
+ if (!firstResult) {
return false;
}
@@ -260,15 +257,18 @@ export class Mp3InputFormat extends InputFormat {
return true;
}
+ currentPos = firstResult.startPos += firstResult.header.totalSize;
+
// 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:
- mp3Reader.pos = firstHeader.startPos + firstHeader.totalSize;
- await mp3Reader.reader.loadRange(mp3Reader.pos, mp3Reader.pos + FRAME_HEADER_SIZE);
- const secondHeader = mp3Reader.readNextFrameHeader(mp3Reader.pos + FRAME_HEADER_SIZE);
- if (!secondHeader) {
+ const secondResult = await readNextFrameHeader(input._reader, currentPos, currentPos + FRAME_HEADER_SIZE);
+ if (!secondResult) {
return false;
}
+ const firstHeader = firstResult.header;
+ const secondHeader = secondResult.header;
+
// In a well-formed MP3 file, we'd expect these two frames to share some similarities:
if (firstHeader.channel !== secondHeader.channel || firstHeader.sampleRate !== secondHeader.sampleRate) {
return false;
@@ -299,19 +299,18 @@ export class Mp3InputFormat extends InputFormat {
export class WaveInputFormat extends InputFormat {
/** @internal */
async _canReadInput(input: Input) {
- const sourceSize = await input._mainReader.source.getSize();
- if (sourceSize < 12) {
- return false;
- }
+ let slice = input._reader.requestSlice(0, 12);
+ if (slice instanceof Promise) slice = await slice;
+ if (!slice) return false;
- const riffReader = new RiffReader(input._mainReader);
- const riffType = riffReader.readAscii(4);
+ const riffType = readAscii(slice, 4);
if (riffType !== 'RIFF' && riffType !== 'RIFX' && riffType !== 'RF64') {
return false;
}
- riffReader.pos = 8;
- const format = riffReader.readAscii(4);
+ slice.skip(4);
+
+ const format = readAscii(slice, 4);
return format === 'WAVE';
}
@@ -336,13 +335,11 @@ export class WaveInputFormat extends InputFormat {
export class OggInputFormat extends InputFormat {
/** @internal */
async _canReadInput(input: Input) {
- const sourceSize = await input._mainReader.source.getSize();
- if (sourceSize < 4) {
- return false;
- }
+ let slice = input._reader.requestSlice(0, 4);
+ if (slice instanceof Promise) slice = await slice;
+ if (!slice) return false;
- const oggReader = new OggReader(input._mainReader);
- return oggReader.readAscii(4) === 'OggS';
+ return readAscii(slice, 4) === 'OggS';
}
/** @internal */
@@ -366,24 +363,20 @@ export class OggInputFormat extends InputFormat {
export class AdtsInputFormat extends InputFormat {
/** @internal */
async _canReadInput(input: Input) {
- const sourceSize = await input._mainReader.source.getSize();
- if (sourceSize < MAX_FRAME_HEADER_SIZE) {
- return false;
- }
+ let slice = input._reader.requestSliceRange(0, MIN_FRAME_HEADER_SIZE, MAX_FRAME_HEADER_SIZE);
+ if (slice instanceof Promise) slice = await slice;
+ if (!slice) return false;
- const adtsReader = new AdtsReader(input._mainReader);
- const firstHeader = adtsReader.readFrameHeader();
+ const firstHeader = readFrameHeader(slice);
if (!firstHeader) {
return false;
}
- if (sourceSize < firstHeader.frameLength + MAX_FRAME_HEADER_SIZE) {
- return false;
- }
+ slice = input._reader.requestSliceRange(firstHeader.frameLength, MIN_FRAME_HEADER_SIZE, MAX_FRAME_HEADER_SIZE);
+ if (slice instanceof Promise) slice = await slice;
+ if (!slice) return false;
- adtsReader.pos = firstHeader.frameLength;
- await adtsReader.reader.loadRange(adtsReader.pos, adtsReader.pos + MAX_FRAME_HEADER_SIZE);
- const secondHeader = adtsReader.readFrameHeader();
+ const secondHeader = readFrameHeader(slice);
if (!secondHeader) {
return false;
}
diff --git a/src/input.ts b/src/input.ts
index 5c3d086..d70eea7 100644
--- a/src/input.ts
+++ b/src/input.ts
@@ -33,11 +33,11 @@ export class Input {
/** @internal */
_formats: InputFormat[];
/** @internal */
- _mainReader: Reader;
- /** @internal */
_demuxerPromise: Promise | null = null;
/** @internal */
_format: InputFormat | null = null;
+ /** @internal */
+ _reader: Reader;
constructor(options: InputOptions) {
if (!options || typeof options !== 'object') {
@@ -52,13 +52,13 @@ export class Input {
this._formats = options.formats;
this._source = options.source;
- this._mainReader = new Reader(options.source);
+ this._reader = new Reader(options.source);
}
/** @internal */
_getDemuxer() {
return this._demuxerPromise ??= (async () => {
- await this._mainReader.loadRange(0, 4096); // Load the first 4 kiB so we can determine the format
+ this._reader.fileSize = await this._source.getSize();
for (const format of this._formats) {
const canRead = await format._canReadInput(this);
diff --git a/src/isobmff/isobmff-demuxer.ts b/src/isobmff/isobmff-demuxer.ts
index ba5ec2d..0fa784d 100644
--- a/src/isobmff/isobmff-demuxer.ts
+++ b/src/isobmff/isobmff-demuxer.ts
@@ -19,12 +19,12 @@ import {
VideoCodec,
} from '../codec';
import {
+ Av1CodecInfo,
AvcDecoderConfigurationRecord,
+ extractAv1CodecInfoFromPacket,
+ extractVp9CodecInfoFromPacket,
HevcDecoderConfigurationRecord,
Vp9CodecInfo,
- Av1CodecInfo,
- extractVp9CodecInfoFromPacket,
- extractAv1CodecInfoFromPacket,
} from '../codec-data';
import { Demuxer } from '../demuxer';
import { Input } from '../input';
@@ -39,29 +39,50 @@ import {
import { PacketRetrievalOptions } from '../media-sink';
import {
assert,
- COLOR_PRIMARIES_MAP_INVERSE,
- MATRIX_COEFFICIENTS_MAP_INVERSE,
- TRANSFER_CHARACTERISTICS_MAP_INVERSE,
- binarySearchLessOrEqual,
- binarySearchExact,
- Rotation,
- last,
AsyncMutex,
- findLastIndex,
- UNDETERMINED_LANGUAGE,
- TransformationMatrix,
- roundToPrecision,
- isIso639Dash2LanguageCode,
- roundToMultiple,
- normalizeRotation,
+ binarySearchExact,
+ binarySearchLessOrEqual,
Bitstream,
+ COLOR_PRIMARIES_MAP_INVERSE,
+ findLastIndex,
insertSorted,
+ isIso639Dash2LanguageCode,
+ last,
+ MATRIX_COEFFICIENTS_MAP_INVERSE,
+ normalizeRotation,
+ roundToMultiple,
+ roundToPrecision,
+ Rotation,
textDecoder,
+ TransformationMatrix,
+ TRANSFER_CHARACTERISTICS_MAP_INVERSE,
+ UNDETERMINED_LANGUAGE,
} from '../misc';
import { EncodedPacket, PLACEHOLDER_DATA } from '../packet';
-import { Reader } from '../reader';
import { buildIsobmffMimeType } from './isobmff-misc';
-import { IsobmffReader, MAX_BOX_HEADER_SIZE, MIN_BOX_HEADER_SIZE } from './isobmff-reader';
+import {
+ MAX_BOX_HEADER_SIZE,
+ MIN_BOX_HEADER_SIZE,
+ readBoxHeader,
+ readFixed_16_16,
+ readFixed_2_30,
+ readIsomVariableInteger,
+} from './isobmff-reader';
+import {
+ FileSlice,
+ readBytes,
+ readF64Be,
+ readI16Be,
+ readI32Be,
+ readI64Be,
+ Reader,
+ readU16Be,
+ readU24Be,
+ readU32Be,
+ readU64Be,
+ readU8,
+ readAscii,
+} from '../reader';
type InternalTrack = {
id: number;
@@ -202,7 +223,9 @@ type Fragment = {
};
export class IsobmffDemuxer extends Demuxer {
- metadataReader: IsobmffReader;
+ reader: Reader;
+ moovSlice: FileSlice | null = null;
+
currentTrack: InternalTrack | null = null;
tracks: InternalTrack[] = [];
metadataPromise: Promise | null = null;
@@ -216,13 +239,10 @@ export class IsobmffDemuxer extends Demuxer {
currentFragment: Fragment | null = null;
fragmentLookupMutex = new AsyncMutex();
- chunkReader: IsobmffReader;
-
constructor(input: Input) {
super(input);
- this.metadataReader = new IsobmffReader(input._mainReader);
- this.chunkReader = new IsobmffReader(new Reader(input.source, 64 * 2 ** 20)); // Max 64 MiB of stored chunks
+ this.reader = input._reader;
}
override async computeDuration() {
@@ -251,29 +271,30 @@ export class IsobmffDemuxer extends Demuxer {
readMetadata() {
return this.metadataPromise ??= (async () => {
- const sourceSize = await this.metadataReader.reader.source.getSize();
+ let currentPos = 0;
+ while (currentPos < this.reader.fileSize) {
+ let slice = this.reader.requestSliceRange(currentPos, MIN_BOX_HEADER_SIZE, MAX_BOX_HEADER_SIZE);
+ if (slice instanceof Promise) slice = await slice;
+ if (!slice) break;
- while (this.metadataReader.pos < sourceSize) {
- await this.metadataReader.reader.loadRange(
- this.metadataReader.pos,
- this.metadataReader.pos + MAX_BOX_HEADER_SIZE,
- );
- const startPos = this.metadataReader.pos;
- const boxInfo = this.metadataReader.readBoxHeader();
+ const startPos = currentPos;
+ const boxInfo = readBoxHeader(slice);
if (!boxInfo) {
break;
}
if (boxInfo.name === 'ftyp') {
- const majorBrand = this.metadataReader.readAscii(4);
+ const majorBrand = readAscii(slice, 4);
this.isQuickTime = majorBrand === 'qt ';
} else if (boxInfo.name === 'moov') {
// Found moov, load it
- await this.metadataReader.reader.loadRange(
- this.metadataReader.pos,
- this.metadataReader.pos + boxInfo.contentSize,
- );
- this.readContiguousBoxes(boxInfo.contentSize);
+
+ let moovSlice = this.reader.requestSlice(slice.filePos, boxInfo.contentSize);
+ if (moovSlice instanceof Promise) moovSlice = await moovSlice;
+ if (!moovSlice) break;
+
+ this.moovSlice = moovSlice;
+ this.readContiguousBoxes(this.moovSlice);
for (const track of this.tracks) {
// Modify the edit list offset based on the previous segment durations. They are in different
@@ -286,32 +307,38 @@ export class IsobmffDemuxer extends Demuxer {
break;
}
- this.metadataReader.pos = startPos + boxInfo.totalSize;
+ currentPos = startPos + boxInfo.totalSize;
}
if (this.isFragmented) {
// The last 4 bytes may contain the size of the mfra box at the end of the file
- await this.metadataReader.reader.loadRange(sourceSize - 4, sourceSize);
+ let lastWordSlice = this.reader.requestSlice(this.reader.fileSize - 4, 4);
+ if (lastWordSlice instanceof Promise) lastWordSlice = await lastWordSlice;
+ assert(lastWordSlice);
- this.metadataReader.pos = sourceSize - 4;
- const lastWord = this.metadataReader.readU32();
- const potentialMfraPos = sourceSize - lastWord;
+ const lastWord = readU32Be(lastWordSlice);
+ const potentialMfraPos = this.reader.fileSize - lastWord;
- if (potentialMfraPos >= 0 && potentialMfraPos <= sourceSize - MAX_BOX_HEADER_SIZE) {
- // Load the header and a bit more, likely covering the entire box
- await this.metadataReader.reader.loadRange(potentialMfraPos, potentialMfraPos + 2 ** 16);
+ if (potentialMfraPos >= 0 && potentialMfraPos <= this.reader.fileSize - MAX_BOX_HEADER_SIZE) {
+ let mfraHeaderSlice = this.reader.requestSliceRange(
+ potentialMfraPos,
+ MIN_BOX_HEADER_SIZE,
+ MAX_BOX_HEADER_SIZE,
+ );
+ if (mfraHeaderSlice instanceof Promise) mfraHeaderSlice = await mfraHeaderSlice;
- this.metadataReader.pos = potentialMfraPos;
- const boxInfo = this.metadataReader.readBoxHeader();
+ if (mfraHeaderSlice) {
+ const boxInfo = readBoxHeader(mfraHeaderSlice);
- if (boxInfo && boxInfo.name === 'mfra') {
- // We found the mfra box, allowing for much better random access. Let's parse it.
+ if (boxInfo && boxInfo.name === 'mfra') {
+ // We found the mfra box, allowing for much better random access. Let's parse it.
+ let mfraSlice = this.reader.requestSlice(mfraHeaderSlice.filePos, boxInfo.contentSize);
+ if (mfraSlice instanceof Promise) mfraSlice = await mfraSlice;
- await this.metadataReader.reader.loadRange(
- potentialMfraPos,
- potentialMfraPos + boxInfo.totalSize,
- );
- this.readContiguousBoxes(boxInfo.contentSize);
+ if (mfraSlice) {
+ this.readContiguousBoxes(mfraSlice);
+ }
+ }
}
}
}
@@ -335,9 +362,11 @@ export class IsobmffDemuxer extends Demuxer {
};
internalTrack.sampleTable = sampleTable;
- this.metadataReader.pos = internalTrack.sampleTableByteOffset;
+ assert(this.moovSlice);
+ const stblContainerSlice = this.moovSlice.slice(internalTrack.sampleTableByteOffset);
+
this.currentTrack = internalTrack;
- this.traverseBox();
+ this.traverseBox(stblContainerSlice);
this.currentTrack = null;
const isPcmCodec = internalTrack.info?.type === 'audio'
@@ -459,22 +488,19 @@ export class IsobmffDemuxer extends Demuxer {
return sampleTable;
}
- async readFragment(): Promise {
- const startPos = this.metadataReader.pos;
+ async readFragment(startPos: number): Promise {
+ let headerSlice = this.reader.requestSliceRange(startPos, MIN_BOX_HEADER_SIZE, MAX_BOX_HEADER_SIZE);
+ if (headerSlice instanceof Promise) headerSlice = await headerSlice;
+ assert(headerSlice);
- await this.metadataReader.reader.loadRange(
- this.metadataReader.pos,
- this.metadataReader.pos + MAX_BOX_HEADER_SIZE,
- );
-
- const moofBoxInfo = this.metadataReader.readBoxHeader();
+ const moofBoxInfo = readBoxHeader(headerSlice);
assert(moofBoxInfo?.name === 'moof');
- const contentStart = this.metadataReader.pos;
- await this.metadataReader.reader.loadRange(contentStart, contentStart + moofBoxInfo.contentSize);
+ let entireSlice = this.reader.requestSlice(startPos, moofBoxInfo.totalSize);
+ if (entireSlice instanceof Promise) entireSlice = await entireSlice;
+ assert(entireSlice);
- this.metadataReader.pos = startPos;
- this.traverseBox();
+ this.traverseBox(entireSlice);
const index = binarySearchExact(this.fragments, startPos, x => x.moofOffset);
assert(index !== -1);
@@ -482,10 +508,6 @@ export class IsobmffDemuxer extends Demuxer {
const fragment = this.fragments[index]!;
assert(fragment.moofOffset === startPos);
- // We have read everything in the moof box, there's no need to keep the data around anymore
- // (keep the header tho)
- this.metadataReader.reader.forgetRange(contentStart, contentStart + moofBoxInfo.contentSize);
-
// It may be that some tracks don't define the base decode time, i.e. when the fragment begins. This means the
// only other option is to sum up the duration of all previous fragments.
for (const [trackId, trackData] of fragment.trackData) {
@@ -495,7 +517,7 @@ export class IsobmffDemuxer extends Demuxer {
const internalTrack = this.tracks.find(x => x.id === trackId)!;
- this.metadataReader.pos = 0;
+ let currentPos = 0;
let currentFragment: Fragment | null = null;
let lastFragment: Fragment | null = null;
@@ -509,34 +531,32 @@ export class IsobmffDemuxer extends Demuxer {
// already has final timestamps).
currentFragment = internalTrack.fragments[index]!;
lastFragment = currentFragment;
- this.metadataReader.pos = currentFragment.moofOffset + currentFragment.moofSize;
+ currentPos = currentFragment.moofOffset + currentFragment.moofSize;
}
- let nextFragmentIsFirstFragment = this.metadataReader.pos === 0;
+ let nextFragmentIsFirstFragment = currentPos === 0;
- while (this.metadataReader.pos <= startPos - MIN_BOX_HEADER_SIZE) {
+ while (currentPos <= startPos - MIN_BOX_HEADER_SIZE) {
if (currentFragment?.nextFragment) {
currentFragment = currentFragment.nextFragment;
- this.metadataReader.pos = currentFragment.moofOffset + currentFragment.moofSize;
+ currentPos = currentFragment.moofOffset + currentFragment.moofSize;
} else {
- await this.metadataReader.reader.loadRange(
- this.metadataReader.pos,
- this.metadataReader.pos + MAX_BOX_HEADER_SIZE,
- );
- const startPos = this.metadataReader.pos;
- const boxInfo = this.metadataReader.readBoxHeader();
+ let slice = this.reader.requestSliceRange(currentPos, MIN_BOX_HEADER_SIZE, MAX_BOX_HEADER_SIZE);
+ if (slice instanceof Promise) slice = await slice;
+ if (!slice) break;
+
+ const boxStartPos = currentPos;
+ const boxInfo = readBoxHeader(slice);
if (!boxInfo) {
break;
}
if (boxInfo.name === 'moof') {
- const index = binarySearchExact(this.fragments, startPos, x => x.moofOffset);
+ const index = binarySearchExact(this.fragments, boxStartPos, x => x.moofOffset);
let fragment: Fragment;
if (index === -1) {
- this.metadataReader.pos = startPos;
-
- fragment = await this.readFragment(); // Recursive call
+ fragment = await this.readFragment(boxStartPos); // Recursive call
} else {
// We already know this fragment
fragment = this.fragments[index]!;
@@ -552,7 +572,7 @@ export class IsobmffDemuxer extends Demuxer {
}
}
- this.metadataReader.pos = startPos + boxInfo.totalSize;
+ currentPos = boxStartPos + boxInfo.totalSize;
}
if (currentFragment && currentFragment.trackData.has(trackId)) {
@@ -573,11 +593,11 @@ export class IsobmffDemuxer extends Demuxer {
return fragment;
}
- readContiguousBoxes(totalSize: number) {
- const startIndex = this.metadataReader.pos;
+ readContiguousBoxes(slice: FileSlice) {
+ const startIndex = slice.filePos;
- while (this.metadataReader.pos - startIndex <= totalSize - MIN_BOX_HEADER_SIZE) {
- const foundBox = this.traverseBox();
+ while (slice.filePos - startIndex <= slice.length - MIN_BOX_HEADER_SIZE) {
+ const foundBox = this.traverseBox(slice);
if (!foundBox) {
break;
@@ -585,13 +605,14 @@ export class IsobmffDemuxer extends Demuxer {
}
}
- traverseBox() {
- const startPos = this.metadataReader.pos;
- const boxInfo = this.metadataReader.readBoxHeader();
+ traverseBox(slice: FileSlice): boolean {
+ const startPos = slice.filePos;
+ const boxInfo = readBoxHeader(slice);
if (!boxInfo) {
return false;
}
+ const contentStartPos = slice.filePos;
const boxEndPos = startPos + boxInfo.totalSize;
switch (boxInfo.name) {
@@ -601,21 +622,21 @@ export class IsobmffDemuxer extends Demuxer {
case 'mfra':
case 'edts':
case 'udta': {
- this.readContiguousBoxes(boxInfo.contentSize);
+ this.readContiguousBoxes(slice.slice(contentStartPos, boxInfo.contentSize));
}; break;
case 'mvhd': {
- const version = this.metadataReader.readU8();
- this.metadataReader.pos += 3; // Flags
+ const version = readU8(slice);
+ slice.skip(3); // Flags
if (version === 1) {
- this.metadataReader.pos += 8 + 8;
- this.movieTimescale = this.metadataReader.readU32();
- this.movieDurationInTimescale = this.metadataReader.readU64();
+ slice.skip(8 + 8);
+ this.movieTimescale = readU32Be(slice);
+ this.movieDurationInTimescale = readU64Be(slice);
} else {
- this.metadataReader.pos += 4 + 4;
- this.movieTimescale = this.metadataReader.readU32();
- this.movieDurationInTimescale = this.metadataReader.readU32();
+ slice.skip(4 + 4);
+ this.movieTimescale = readU32Be(slice);
+ this.movieDurationInTimescale = readU32Be(slice);
}
}; break;
@@ -643,7 +664,7 @@ export class IsobmffDemuxer extends Demuxer {
} satisfies InternalTrack as InternalTrack;
this.currentTrack = track;
- this.readContiguousBoxes(boxInfo.contentSize);
+ this.readContiguousBoxes(slice.slice(contentStartPos, boxInfo.contentSize));
if (track.id !== -1 && track.timescale !== -1 && track.info !== null) {
if (track.info.type === 'video' && track.info.width !== -1) {
@@ -664,8 +685,8 @@ export class IsobmffDemuxer extends Demuxer {
const track = this.currentTrack;
assert(track);
- const version = this.metadataReader.readU8();
- const flags = this.metadataReader.readU24();
+ const version = readU8(slice);
+ const flags = readU24Be(slice);
const trackEnabled = (flags & 0x1) !== 0;
if (!trackEnabled) {
@@ -674,30 +695,30 @@ export class IsobmffDemuxer extends Demuxer {
// Skip over creation & modification time to reach the track ID
if (version === 0) {
- this.metadataReader.pos += 8;
- track.id = this.metadataReader.readU32();
- this.metadataReader.pos += 4;
- track.durationInMovieTimescale = this.metadataReader.readU32();
+ slice.skip(8);
+ track.id = readU32Be(slice);
+ slice.skip(4);
+ track.durationInMovieTimescale = readU32Be(slice);
} else if (version === 1) {
- this.metadataReader.pos += 16;
- track.id = this.metadataReader.readU32();
- this.metadataReader.pos += 4;
- track.durationInMovieTimescale = this.metadataReader.readU64();
+ slice.skip(16);
+ track.id = readU32Be(slice);
+ slice.skip(4);
+ track.durationInMovieTimescale = readU64Be(slice);
} else {
throw new Error(`Incorrect track header version ${version}.`);
}
- this.metadataReader.pos += 2 * 4 + 2 + 2 + 2 + 2;
+ slice.skip(2 * 4 + 2 + 2 + 2 + 2);
const matrix: TransformationMatrix = [
- this.metadataReader.readFixed_16_16(),
- this.metadataReader.readFixed_16_16(),
- this.metadataReader.readFixed_2_30(),
- this.metadataReader.readFixed_16_16(),
- this.metadataReader.readFixed_16_16(),
- this.metadataReader.readFixed_2_30(),
- this.metadataReader.readFixed_16_16(),
- this.metadataReader.readFixed_16_16(),
- this.metadataReader.readFixed_2_30(),
+ readFixed_16_16(slice),
+ readFixed_16_16(slice),
+ readFixed_2_30(slice),
+ readFixed_16_16(slice),
+ readFixed_16_16(slice),
+ readFixed_2_30(slice),
+ readFixed_16_16(slice),
+ readFixed_16_16(slice),
+ readFixed_2_30(slice),
];
const rotation = normalizeRotation(roundToMultiple(extractRotationFromMatrix(matrix), 90));
@@ -710,21 +731,21 @@ export class IsobmffDemuxer extends Demuxer {
const track = this.currentTrack;
assert(track);
- const version = this.metadataReader.readU8();
- this.metadataReader.pos += 3; // Flags
+ const version = readU8(slice);
+ slice.skip(3); // Flags
let relevantEntryFound = false;
let previousSegmentDurations = 0;
- const entryCount = this.metadataReader.readU32();
+ const entryCount = readU32Be(slice);
for (let i = 0; i < entryCount; i++) {
const segmentDuration = version === 1
- ? this.metadataReader.readU64()
- : this.metadataReader.readU32();
+ ? readU64Be(slice)
+ : readU32Be(slice);
const mediaTime = version === 1
- ? this.metadataReader.readI64()
- : this.metadataReader.readI32();
- const mediaRate = this.metadataReader.readFixed_16_16();
+ ? readI64Be(slice)
+ : readI32Be(slice);
+ const mediaRate = readFixed_16_16(slice);
if (segmentDuration === 0) {
// Don't care
@@ -758,20 +779,20 @@ export class IsobmffDemuxer extends Demuxer {
const track = this.currentTrack;
assert(track);
- const version = this.metadataReader.readU8();
- this.metadataReader.pos += 3; // Flags
+ const version = readU8(slice);
+ slice.skip(3); // Flags
if (version === 0) {
- this.metadataReader.pos += 8;
- track.timescale = this.metadataReader.readU32();
- track.durationInMediaTimescale = this.metadataReader.readU32();
+ slice.skip(8);
+ track.timescale = readU32Be(slice);
+ track.durationInMediaTimescale = readU32Be(slice);
} else if (version === 1) {
- this.metadataReader.pos += 16;
- track.timescale = this.metadataReader.readU32();
- track.durationInMediaTimescale = this.metadataReader.readU64();
+ slice.skip(16);
+ track.timescale = readU32Be(slice);
+ track.durationInMediaTimescale = readU64Be(slice);
}
- let language = this.metadataReader.readU16();
+ let language = readU16Be(slice);
if (language > 0) {
track.languageCode = '';
@@ -792,8 +813,8 @@ export class IsobmffDemuxer extends Demuxer {
const track = this.currentTrack;
assert(track);
- this.metadataReader.pos += 8; // Version + flags + pre-defined
- const handlerType = this.metadataReader.readAscii(4);
+ slice.skip(8); // Version + flags + pre-defined
+ const handlerType = readAscii(slice, 4);
if (handlerType === 'vide') {
track.info = {
@@ -826,7 +847,7 @@ export class IsobmffDemuxer extends Demuxer {
track.sampleTableByteOffset = startPos;
- this.readContiguousBoxes(boxInfo.contentSize);
+ this.readContiguousBoxes(slice.slice(contentStartPos, boxInfo.contentSize));
}; break;
case 'stsd': {
@@ -837,14 +858,14 @@ export class IsobmffDemuxer extends Demuxer {
break;
}
- const stsdVersion = this.metadataReader.readU8();
- this.metadataReader.pos += 3; // Flags
+ const stsdVersion = readU8(slice);
+ slice.skip(3); // Flags
- const entries = this.metadataReader.readU32();
+ const entries = readU32Be(slice);
for (let i = 0; i < entries; i++) {
- const startPos = this.metadataReader.pos;
- const sampleBoxInfo = this.metadataReader.readBoxHeader();
+ const sampleBoxStartPos = slice.filePos;
+ const sampleBoxInfo = readBoxHeader(slice);
if (!sampleBoxInfo) {
break;
}
@@ -867,14 +888,19 @@ export class IsobmffDemuxer extends Demuxer {
console.warn(`Unsupported video codec (sample entry type '${sampleBoxInfo.name}').`);
}
- this.metadataReader.pos += 6 * 1 + 2 + 2 + 2 + 3 * 4;
+ slice.skip(6 * 1 + 2 + 2 + 2 + 3 * 4);
- track.info.width = this.metadataReader.readU16();
- track.info.height = this.metadataReader.readU16();
+ track.info.width = readU16Be(slice);
+ track.info.height = readU16Be(slice);
- this.metadataReader.pos += 4 + 4 + 4 + 2 + 32 + 2 + 2;
+ slice.skip(4 + 4 + 4 + 2 + 32 + 2 + 2);
- this.readContiguousBoxes((startPos + sampleBoxInfo.totalSize) - this.metadataReader.pos);
+ this.readContiguousBoxes(
+ slice.slice(
+ slice.filePos,
+ (sampleBoxStartPos + sampleBoxInfo.totalSize) - slice.filePos,
+ ),
+ );
} else {
if (lowercaseBoxName === 'mp4a') {
// We don't know the codec yet (might be AAC, might be MP3), need to read the esds box
@@ -904,36 +930,36 @@ export class IsobmffDemuxer extends Demuxer {
console.warn(`Unsupported audio codec (sample entry type '${sampleBoxInfo.name}').`);
}
- this.metadataReader.pos += 6 * 1 + 2;
+ slice.skip(6 * 1 + 2);
- const version = this.metadataReader.readU16();
- this.metadataReader.pos += 3 * 2;
+ const version = readU16Be(slice);
+ slice.skip(3 * 2);
- let channelCount = this.metadataReader.readU16();
- let sampleSize = this.metadataReader.readU16();
+ let channelCount = readU16Be(slice);
+ let sampleSize = readU16Be(slice);
- this.metadataReader.pos += 2 * 2;
+ slice.skip(2 * 2);
// Can't use fixed16_16 as that's signed
- let sampleRate = this.metadataReader.readU32() / 0x10000;
+ let sampleRate = readU32Be(slice) / 0x10000;
if (stsdVersion === 0 && version > 0) {
// Additional QuickTime fields
if (version === 1) {
- this.metadataReader.pos += 4;
- sampleSize = 8 * this.metadataReader.readU32();
- this.metadataReader.pos += 2 * 4;
+ slice.skip(4);
+ sampleSize = 8 * readU32Be(slice);
+ slice.skip(2 * 4);
} else if (version === 2) {
- this.metadataReader.pos += 4;
- sampleRate = this.metadataReader.readF64();
- channelCount = this.metadataReader.readU32();
- this.metadataReader.pos += 4; // Always 0x7f000000
+ slice.skip(4);
+ sampleRate = readF64Be(slice);
+ channelCount = readU32Be(slice);
+ slice.skip(4); // Always 0x7f000000
- sampleSize = this.metadataReader.readU32();
+ sampleSize = readU32Be(slice);
- const flags = this.metadataReader.readU32();
+ const flags = readU32Be(slice);
- this.metadataReader.pos += 2 * 4;
+ slice.skip(2 * 4);
if (lowercaseBoxName === 'lpcm') {
const bytesPerSample = (sampleSize + 7) >> 3;
@@ -1010,7 +1036,12 @@ export class IsobmffDemuxer extends Demuxer {
track.info.codec = 'pcm-f32be'; // Placeholder, will be adjusted by the pcmC box
}
- this.readContiguousBoxes((startPos + sampleBoxInfo.totalSize) - this.metadataReader.pos);
+ this.readContiguousBoxes(
+ slice.slice(
+ slice.filePos,
+ (sampleBoxStartPos + sampleBoxInfo.totalSize) - slice.filePos,
+ ),
+ );
}
}
}; break;
@@ -1019,31 +1050,31 @@ export class IsobmffDemuxer extends Demuxer {
const track = this.currentTrack;
assert(track && track.info);
- track.info.codecDescription = this.metadataReader.readBytes(boxInfo.contentSize);
+ track.info.codecDescription = readBytes(slice, boxInfo.contentSize);
}; break;
case 'hvcC': {
const track = this.currentTrack;
assert(track && track.info);
- track.info.codecDescription = this.metadataReader.readBytes(boxInfo.contentSize);
+ track.info.codecDescription = readBytes(slice, boxInfo.contentSize);
}; break;
case 'vpcC': {
const track = this.currentTrack;
assert(track && track.info?.type === 'video');
- this.metadataReader.pos += 4; // Version + flags
+ slice.skip(4); // Version + flags
- const profile = this.metadataReader.readU8();
- const level = this.metadataReader.readU8();
- const thirdByte = this.metadataReader.readU8();
+ const profile = readU8(slice);
+ const level = readU8(slice);
+ const thirdByte = readU8(slice);
const bitDepth = thirdByte >> 4;
const chromaSubsampling = (thirdByte >> 1) & 0b111;
const videoFullRangeFlag = thirdByte & 1;
- const colourPrimaries = this.metadataReader.readU8();
- const transferCharacteristics = this.metadataReader.readU8();
- const matrixCoefficients = this.metadataReader.readU8();
+ const colourPrimaries = readU8(slice);
+ const transferCharacteristics = readU8(slice);
+ const matrixCoefficients = readU8(slice);
track.info.vp9CodecInfo = {
profile,
@@ -1061,13 +1092,13 @@ export class IsobmffDemuxer extends Demuxer {
const track = this.currentTrack;
assert(track && track.info?.type === 'video');
- this.metadataReader.pos += 1; // Marker + version
+ slice.skip(1); // Marker + version
- const secondByte = this.metadataReader.readU8();
+ const secondByte = readU8(slice);
const profile = secondByte >> 5;
const level = secondByte & 0b11111;
- const thirdByte = this.metadataReader.readU8();
+ const thirdByte = readU8(slice);
const tier = thirdByte >> 7;
const highBitDepth = (thirdByte >> 6) & 1;
const twelveBit = (thirdByte >> 5) & 1;
@@ -1095,15 +1126,15 @@ export class IsobmffDemuxer extends Demuxer {
const track = this.currentTrack;
assert(track && track.info?.type === 'video');
- const colourType = this.metadataReader.readAscii(4);
+ const colourType = readAscii(slice, 4);
if (colourType !== 'nclx') {
break;
}
- const colourPrimaries = this.metadataReader.readU16();
- const transferCharacteristics = this.metadataReader.readU16();
- const matrixCoefficients = this.metadataReader.readU16();
- const fullRangeFlag = Boolean(this.metadataReader.readU8() & 0x80);
+ const colourPrimaries = readU16Be(slice);
+ const transferCharacteristics = readU16Be(slice);
+ const matrixCoefficients = readU16Be(slice);
+ const fullRangeFlag = Boolean(readU8(slice) & 0x80);
track.info.colorSpace = {
primaries: COLOR_PRIMARIES_MAP_INVERSE[colourPrimaries],
@@ -1114,46 +1145,46 @@ export class IsobmffDemuxer extends Demuxer {
}; break;
case 'wave': {
- this.readContiguousBoxes(boxInfo.contentSize);
+ this.readContiguousBoxes(slice.slice(contentStartPos, boxInfo.contentSize));
}; break;
case 'esds': {
const track = this.currentTrack;
assert(track && track.info?.type === 'audio');
- this.metadataReader.pos += 4; // Version + flags
+ slice.skip(4); // Version + flags
- const tag = this.metadataReader.readU8();
+ const tag = readU8(slice);
assert(tag === 0x03); // ES Descriptor
- this.metadataReader.readIsomVariableInteger(); // Length
+ readIsomVariableInteger(slice); // Length
- this.metadataReader.pos += 2; // ES ID
- const mixed = this.metadataReader.readU8();
+ slice.skip(2); // ES ID
+ const mixed = readU8(slice);
const streamDependenceFlag = (mixed & 0x80) !== 0;
const urlFlag = (mixed & 0x40) !== 0;
const ocrStreamFlag = (mixed & 0x20) !== 0;
if (streamDependenceFlag) {
- this.metadataReader.pos += 2;
+ slice.skip(2);
}
if (urlFlag) {
- const urlLength = this.metadataReader.readU8();
- this.metadataReader.pos += urlLength;
+ const urlLength = readU8(slice);
+ slice.skip(urlLength);
}
if (ocrStreamFlag) {
- this.metadataReader.pos += 2;
+ slice.skip(2);
}
- const decoderConfigTag = this.metadataReader.readU8();
+ const decoderConfigTag = readU8(slice);
assert(decoderConfigTag === 0x04); // DecoderConfigDescriptor
- const decoderConfigDescriptorLength = this.metadataReader.readIsomVariableInteger(); // Length
+ const decoderConfigDescriptorLength = readIsomVariableInteger(slice); // Length
- const payloadStart = this.metadataReader.pos;
+ const payloadStart = slice.filePos;
- const objectTypeIndication = this.metadataReader.readU8();
+ const objectTypeIndication = readU8(slice);
if (objectTypeIndication === 0x40 || objectTypeIndication === 0x67) {
track.info.codec = 'aac';
track.info.aacCodecInfo = { isMpeg2: objectTypeIndication === 0x67 };
@@ -1167,16 +1198,16 @@ export class IsobmffDemuxer extends Demuxer {
);
}
- this.metadataReader.pos += 1 + 3 + 4 + 4;
+ slice.skip(1 + 3 + 4 + 4);
- if (decoderConfigDescriptorLength > this.metadataReader.pos - payloadStart) {
+ if (decoderConfigDescriptorLength > slice.filePos - payloadStart) {
// There's a DecoderSpecificInfo at the end, let's read it
- const decoderSpecificInfoTag = this.metadataReader.readU8();
+ const decoderSpecificInfoTag = readU8(slice);
assert(decoderSpecificInfoTag === 0x05); // DecoderSpecificInfo
- const decoderSpecificInfoLength = this.metadataReader.readIsomVariableInteger();
- track.info.codecDescription = this.metadataReader.readBytes(decoderSpecificInfoLength);
+ const decoderSpecificInfoLength = readIsomVariableInteger(slice);
+ track.info.codecDescription = readBytes(slice, decoderSpecificInfoLength);
if (track.info.codec === 'aac') {
// Let's try to deduce more accurate values directly from the AudioSpecificConfig:
@@ -1195,7 +1226,7 @@ export class IsobmffDemuxer extends Demuxer {
const track = this.currentTrack;
assert(track && track.info?.type === 'audio');
- const littleEndian = this.metadataReader.readU16() & 0xff; // 0xff is from FFmpeg
+ const littleEndian = readU16Be(slice) & 0xff; // 0xff is from FFmpeg
if (littleEndian) {
if (track.info.codec === 'pcm-s16be') {
@@ -1216,13 +1247,13 @@ export class IsobmffDemuxer extends Demuxer {
const track = this.currentTrack;
assert(track && track.info?.type === 'audio');
- this.metadataReader.pos += 1 + 3; // Version + flags
+ slice.skip(1 + 3); // Version + flags
// ISO/IEC 23003-5
- const formatFlags = this.metadataReader.readU8();
+ const formatFlags = readU8(slice);
const isLittleEndian = Boolean(formatFlags & 0x01);
- const pcmSampleSize = this.metadataReader.readU8();
+ const pcmSampleSize = readU8(slice);
if (track.info.codec === 'pcm-s16be') {
// ipcm
@@ -1281,18 +1312,18 @@ export class IsobmffDemuxer extends Demuxer {
const track = this.currentTrack;
assert(track && track.info?.type === 'audio');
- this.metadataReader.pos += 1; // Version
+ slice.skip(1); // Version
// https://www.opus-codec.org/docs/opus_in_isobmff.html
- const outputChannelCount = this.metadataReader.readU8();
- const preSkip = this.metadataReader.readU16();
- const inputSampleRate = this.metadataReader.readU32();
- const outputGain = this.metadataReader.readI16();
- const channelMappingFamily = this.metadataReader.readU8();
+ const outputChannelCount = readU8(slice);
+ const preSkip = readU16Be(slice);
+ const inputSampleRate = readU32Be(slice);
+ const outputGain = readI16Be(slice);
+ const channelMappingFamily = readU8(slice);
let channelMappingTable: Uint8Array;
if (channelMappingFamily !== 0) {
- channelMappingTable = this.metadataReader.readBytes(2 + outputChannelCount);
+ channelMappingTable = readBytes(slice, 2 + outputChannelCount);
} else {
channelMappingTable = new Uint8Array(0);
}
@@ -1319,36 +1350,36 @@ export class IsobmffDemuxer extends Demuxer {
const track = this.currentTrack;
assert(track && track.info?.type === 'audio');
- this.metadataReader.pos += 4; // Version + flags
+ slice.skip(4); // Version + flags
// https://datatracker.ietf.org/doc/rfc9639/
const BLOCK_TYPE_MASK = 0x7f;
const LAST_METADATA_BLOCK_FLAG_MASK = 0x80;
- const startPos = this.metadataReader.pos;
+ const startPos = slice.filePos;
- while (this.metadataReader.pos < boxEndPos) {
- const flagAndType = this.metadataReader.readU8();
- const metadataBlockLength = this.metadataReader.readU24();
+ while (slice.filePos < boxEndPos) {
+ const flagAndType = readU8(slice);
+ const metadataBlockLength = readU24Be(slice);
const type = flagAndType & BLOCK_TYPE_MASK;
// It's a STREAMINFO block; let's extract the actual sample rate and channel count
if (type === 0) {
- this.metadataReader.pos += 10;
+ slice.skip(10);
- // Extract sample rate
- const word = this.metadataReader.readU32();
+ // Extract sample rate and channel count
+ const word = readU32Be(slice);
const sampleRate = word >>> 12;
const numberOfChannels = ((word >> 9) & 0b111) + 1;
track.info.sampleRate = sampleRate;
track.info.numberOfChannels = numberOfChannels;
- this.metadataReader.pos += 20;
+ slice.skip(20);
} else {
// Simply skip ahead to the next block
- this.metadataReader.pos += metadataBlockLength;
+ slice.skip(metadataBlockLength);
}
if (flagAndType & LAST_METADATA_BLOCK_FLAG_MASK) {
@@ -1356,9 +1387,9 @@ export class IsobmffDemuxer extends Demuxer {
}
}
- const endPos = this.metadataReader.pos;
- this.metadataReader.pos = startPos;
- const bytes = this.metadataReader.readBytes(endPos - startPos);
+ const endPos = slice.filePos;
+ slice.filePos = startPos;
+ const bytes = readBytes(slice, endPos - startPos);
const description = new Uint8Array(4 + bytes.byteLength);
const view = new DataView(description.buffer);
@@ -1377,16 +1408,16 @@ export class IsobmffDemuxer extends Demuxer {
break;
}
- this.metadataReader.pos += 4; // Version + flags
+ slice.skip(4); // Version + flags
- const entryCount = this.metadataReader.readU32();
+ const entryCount = readU32Be(slice);
let currentIndex = 0;
let currentTimestamp = 0;
for (let i = 0; i < entryCount; i++) {
- const sampleCount = this.metadataReader.readU32();
- const sampleDelta = this.metadataReader.readU32();
+ const sampleCount = readU32Be(slice);
+ const sampleDelta = readU32Be(slice);
track.sampleTable.sampleTimingEntries.push({
startIndex: currentIndex,
@@ -1408,14 +1439,14 @@ export class IsobmffDemuxer extends Demuxer {
break;
}
- this.metadataReader.pos += 1 + 3; // Version + flags
+ slice.skip(1 + 3); // Version + flags
- const entryCount = this.metadataReader.readU32();
+ const entryCount = readU32Be(slice);
let sampleIndex = 0;
for (let i = 0; i < entryCount; i++) {
- const sampleCount = this.metadataReader.readU32();
- const sampleOffset = this.metadataReader.readI32();
+ const sampleCount = readU32Be(slice);
+ const sampleOffset = readI32Be(slice);
track.sampleTable.sampleCompositionTimeOffsets.push({
startIndex: sampleIndex,
@@ -1435,14 +1466,14 @@ export class IsobmffDemuxer extends Demuxer {
break;
}
- this.metadataReader.pos += 4; // Version + flags
+ slice.skip(4); // Version + flags
- const sampleSize = this.metadataReader.readU32();
- const sampleCount = this.metadataReader.readU32();
+ const sampleSize = readU32Be(slice);
+ const sampleCount = readU32Be(slice);
if (sampleSize === 0) {
for (let i = 0; i < sampleCount; i++) {
- const sampleSize = this.metadataReader.readU32();
+ const sampleSize = readU32Be(slice);
track.sampleTable.sampleSizes.push(sampleSize);
}
} else {
@@ -1458,13 +1489,13 @@ export class IsobmffDemuxer extends Demuxer {
break;
}
- this.metadataReader.pos += 4; // Version + flags
- this.metadataReader.pos += 3; // Reserved
+ slice.skip(4); // Version + flags
+ slice.skip(3); // Reserved
- const fieldSize = this.metadataReader.readU8(); // in bits
- const sampleCount = this.metadataReader.readU32();
+ const fieldSize = readU8(slice); // in bits
+ const sampleCount = readU32Be(slice);
- const bytes = this.metadataReader.readBytes(Math.ceil(sampleCount * fieldSize / 8));
+ const bytes = readBytes(slice, Math.ceil(sampleCount * fieldSize / 8));
const bitstream = new Bitstream(bytes);
for (let i = 0; i < sampleCount; i++) {
@@ -1481,13 +1512,13 @@ export class IsobmffDemuxer extends Demuxer {
break;
}
- this.metadataReader.pos += 4; // Version + flags
+ slice.skip(4); // Version + flags
track.sampleTable.keySampleIndices = [];
- const entryCount = this.metadataReader.readU32();
+ const entryCount = readU32Be(slice);
for (let i = 0; i < entryCount; i++) {
- const sampleIndex = this.metadataReader.readU32() - 1; // Convert to 0-indexed
+ const sampleIndex = readU32Be(slice) - 1; // Convert to 0-indexed
track.sampleTable.keySampleIndices.push(sampleIndex);
}
@@ -1506,14 +1537,14 @@ export class IsobmffDemuxer extends Demuxer {
break;
}
- this.metadataReader.pos += 4;
+ slice.skip(4);
- const entryCount = this.metadataReader.readU32();
+ const entryCount = readU32Be(slice);
for (let i = 0; i < entryCount; i++) {
- const startChunkIndex = this.metadataReader.readU32() - 1; // Convert to 0-indexed
- const samplesPerChunk = this.metadataReader.readU32();
- const sampleDescriptionIndex = this.metadataReader.readU32();
+ const startChunkIndex = readU32Be(slice) - 1; // Convert to 0-indexed
+ const samplesPerChunk = readU32Be(slice);
+ const sampleDescriptionIndex = readU32Be(slice);
track.sampleTable.sampleToChunk.push({
startSampleIndex: -1,
@@ -1544,12 +1575,12 @@ export class IsobmffDemuxer extends Demuxer {
break;
}
- this.metadataReader.pos += 4; // Version + flags
+ slice.skip(4); // Version + flags
- const entryCount = this.metadataReader.readU32();
+ const entryCount = readU32Be(slice);
for (let i = 0; i < entryCount; i++) {
- const chunkOffset = this.metadataReader.readU32();
+ const chunkOffset = readU32Be(slice);
track.sampleTable.chunkOffsets.push(chunkOffset);
}
}; break;
@@ -1562,37 +1593,37 @@ export class IsobmffDemuxer extends Demuxer {
break;
}
- this.metadataReader.pos += 4; // Version + flags
+ slice.skip(4); // Version + flags
- const entryCount = this.metadataReader.readU32();
+ const entryCount = readU32Be(slice);
for (let i = 0; i < entryCount; i++) {
- const chunkOffset = this.metadataReader.readU64();
+ const chunkOffset = readU64Be(slice);
track.sampleTable.chunkOffsets.push(chunkOffset);
}
}; break;
case 'mvex': {
this.isFragmented = true;
- this.readContiguousBoxes(boxInfo.contentSize);
+ this.readContiguousBoxes(slice.slice(contentStartPos, boxInfo.contentSize));
}; break;
case 'mehd': {
- const version = this.metadataReader.readU8();
- this.metadataReader.pos += 3; // Flags
+ const version = readU8(slice);
+ slice.skip(3); // Flags
- const fragmentDuration = version === 1 ? this.metadataReader.readU64() : this.metadataReader.readU32();
+ const fragmentDuration = version === 1 ? readU64Be(slice) : readU32Be(slice);
this.movieDurationInTimescale = fragmentDuration;
}; break;
case 'trex': {
- this.metadataReader.pos += 4; // Version + flags
+ slice.skip(4); // Version + flags
- const trackId = this.metadataReader.readU32();
- const defaultSampleDescriptionIndex = this.metadataReader.readU32();
- const defaultSampleDuration = this.metadataReader.readU32();
- const defaultSampleSize = this.metadataReader.readU32();
- const defaultSampleFlags = this.metadataReader.readU32();
+ const trackId = readU32Be(slice);
+ const defaultSampleDescriptionIndex = readU32Be(slice);
+ const defaultSampleDuration = readU32Be(slice);
+ const defaultSampleSize = readU32Be(slice);
+ const defaultSampleFlags = readU32Be(slice);
// We store these separately rather than in the tracks since the tracks may not exist yet
this.fragmentTrackDefaults.push({
@@ -1605,10 +1636,10 @@ export class IsobmffDemuxer extends Demuxer {
}; break;
case 'tfra': {
- const version = this.metadataReader.readU8();
- this.metadataReader.pos += 3; // Flags
+ const version = readU8(slice);
+ slice.skip(3); // Flags
- const trackId = this.metadataReader.readU32();
+ const trackId = readU32Be(slice);
const track = this.tracks.find(x => x.id === trackId);
if (!track) {
break;
@@ -1616,30 +1647,26 @@ export class IsobmffDemuxer extends Demuxer {
track.fragmentLookupTable = [];
- const word = this.metadataReader.readU32();
+ const word = readU32Be(slice);
const lengthSizeOfTrafNum = (word & 0b110000) >> 4;
const lengthSizeOfTrunNum = (word & 0b001100) >> 2;
const lengthSizeOfSampleNum = word & 0b000011;
- const x = this.metadataReader;
- const functions = [x.readU8.bind(x), x.readU16.bind(x), x.readU24.bind(x), x.readU32.bind(x)];
+ const functions = [readU8, readU16Be, readU24Be, readU32Be];
const readTrafNum = functions[lengthSizeOfTrafNum]!;
const readTrunNum = functions[lengthSizeOfTrunNum]!;
const readSampleNum = functions[lengthSizeOfSampleNum]!;
- const numberOfEntries = this.metadataReader.readU32();
+ const numberOfEntries = readU32Be(slice);
for (let i = 0; i < numberOfEntries; i++) {
- const time = version === 1 ? this.metadataReader.readU64() : this.metadataReader.readU32();
- const moofOffset = version === 1 ? this.metadataReader.readU64() : this.metadataReader.readU32();
+ const time = version === 1 ? readU64Be(slice) : readU32Be(slice);
+ const moofOffset = version === 1 ? readU64Be(slice) : readU32Be(slice);
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- const trafNumber = readTrafNum();
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- const trunNumber = readTrunNum();
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- const sampleNumber = readSampleNum();
+ readTrafNum(slice);
+ readTrunNum(slice);
+ readSampleNum(slice);
track.fragmentLookupTable.push({
timestamp: time,
@@ -1660,7 +1687,7 @@ export class IsobmffDemuxer extends Demuxer {
isKnownToBeFirstFragment: false,
};
- this.readContiguousBoxes(boxInfo.contentSize);
+ this.readContiguousBoxes(slice.slice(contentStartPos, boxInfo.contentSize));
insertSorted(this.fragments, this.currentFragment, x => x.moofOffset);
@@ -1685,7 +1712,7 @@ export class IsobmffDemuxer extends Demuxer {
case 'traf': {
assert(this.currentFragment);
- this.readContiguousBoxes(boxInfo.contentSize);
+ this.readContiguousBoxes(slice.slice(contentStartPos, boxInfo.contentSize));
// It is possible that there is no current track, for example when we don't care about the track
// referenced in the track fragment header.
@@ -1722,9 +1749,9 @@ export class IsobmffDemuxer extends Demuxer {
case 'tfhd': {
assert(this.currentFragment);
- this.metadataReader.pos += 1; // Version
+ slice.skip(1); // Version
- const flags = this.metadataReader.readU24();
+ const flags = readU24Be(slice);
const baseDataOffsetPresent = Boolean(flags & 0x000001);
const sampleDescriptionIndexPresent = Boolean(flags & 0x000002);
const defaultSampleDurationPresent = Boolean(flags & 0x000008);
@@ -1733,7 +1760,7 @@ export class IsobmffDemuxer extends Demuxer {
const durationIsEmpty = Boolean(flags & 0x010000);
const defaultBaseIsMoof = Boolean(flags & 0x020000);
- const trackId = this.metadataReader.readU32();
+ const trackId = readU32Be(slice);
const track = this.tracks.find(x => x.id === trackId);
if (!track) {
// We don't care about this track
@@ -1753,21 +1780,21 @@ export class IsobmffDemuxer extends Demuxer {
};
if (baseDataOffsetPresent) {
- track.currentFragmentState.baseDataOffset = this.metadataReader.readU64();
+ track.currentFragmentState.baseDataOffset = readU64Be(slice);
} else if (defaultBaseIsMoof) {
track.currentFragmentState.baseDataOffset = this.currentFragment.moofOffset;
}
if (sampleDescriptionIndexPresent) {
- track.currentFragmentState.sampleDescriptionIndex = this.metadataReader.readU32();
+ track.currentFragmentState.sampleDescriptionIndex = readU32Be(slice);
}
if (defaultSampleDurationPresent) {
- track.currentFragmentState.defaultSampleDuration = this.metadataReader.readU32();
+ track.currentFragmentState.defaultSampleDuration = readU32Be(slice);
}
if (defaultSampleSizePresent) {
- track.currentFragmentState.defaultSampleSize = this.metadataReader.readU32();
+ track.currentFragmentState.defaultSampleSize = readU32Be(slice);
}
if (defaultSampleFlagsPresent) {
- track.currentFragmentState.defaultSampleFlags = this.metadataReader.readU32();
+ track.currentFragmentState.defaultSampleFlags = readU32Be(slice);
}
if (durationIsEmpty) {
track.currentFragmentState.defaultSampleDuration = 0;
@@ -1782,14 +1809,10 @@ export class IsobmffDemuxer extends Demuxer {
assert(track.currentFragmentState);
- // break;
+ const version = readU8(slice);
+ slice.skip(3); // Flags
- const version = this.metadataReader.readU8();
- this.metadataReader.pos += 3; // Flags
-
- const baseMediaDecodeTime = version === 0
- ? this.metadataReader.readU32()
- : this.metadataReader.readU64();
+ const baseMediaDecodeTime = version === 0 ? readU32Be(slice) : readU64Be(slice);
track.currentFragmentState.startTimestamp = baseMediaDecodeTime;
}; break;
@@ -1807,9 +1830,9 @@ export class IsobmffDemuxer extends Demuxer {
break;
}
- const version = this.metadataReader.readU8();
+ const version = readU8(slice);
- const flags = this.metadataReader.readU24();
+ const flags = readU24Be(slice);
const dataOffsetPresent = Boolean(flags & 0x000001);
const firstSampleFlagsPresent = Boolean(flags & 0x000004);
const sampleDurationPresent = Boolean(flags & 0x000100);
@@ -1817,15 +1840,15 @@ export class IsobmffDemuxer extends Demuxer {
const sampleFlagsPresent = Boolean(flags & 0x000400);
const sampleCompositionTimeOffsetsPresent = Boolean(flags & 0x000800);
- const sampleCount = this.metadataReader.readU32();
+ const sampleCount = readU32Be(slice);
let dataOffset = track.currentFragmentState.baseDataOffset;
if (dataOffsetPresent) {
- dataOffset += this.metadataReader.readI32();
+ dataOffset += readI32Be(slice);
}
let firstSampleFlags: number | null = null;
if (firstSampleFlagsPresent) {
- firstSampleFlags = this.metadataReader.readU32();
+ firstSampleFlags = readU32Be(slice);
}
let currentOffset = dataOffset;
@@ -1851,7 +1874,7 @@ export class IsobmffDemuxer extends Demuxer {
for (let i = 0; i < sampleCount; i++) {
let sampleDuration: number;
if (sampleDurationPresent) {
- sampleDuration = this.metadataReader.readU32();
+ sampleDuration = readU32Be(slice);
} else {
assert(track.currentFragmentState.defaultSampleDuration !== null);
sampleDuration = track.currentFragmentState.defaultSampleDuration;
@@ -1859,7 +1882,7 @@ export class IsobmffDemuxer extends Demuxer {
let sampleSize: number;
if (sampleSizePresent) {
- sampleSize = this.metadataReader.readU32();
+ sampleSize = readU32Be(slice);
} else {
assert(track.currentFragmentState.defaultSampleSize !== null);
sampleSize = track.currentFragmentState.defaultSampleSize;
@@ -1867,7 +1890,7 @@ export class IsobmffDemuxer extends Demuxer {
let sampleFlags: number;
if (sampleFlagsPresent) {
- sampleFlags = this.metadataReader.readU32();
+ sampleFlags = readU32Be(slice);
} else {
assert(track.currentFragmentState.defaultSampleFlags !== null);
sampleFlags = track.currentFragmentState.defaultSampleFlags;
@@ -1879,9 +1902,9 @@ export class IsobmffDemuxer extends Demuxer {
let sampleCompositionTimeOffset = 0;
if (sampleCompositionTimeOffsetsPresent) {
if (version === 0) {
- sampleCompositionTimeOffset = this.metadataReader.readU32();
+ sampleCompositionTimeOffset = readU32Be(slice);
} else {
- sampleCompositionTimeOffset = this.metadataReader.readI32();
+ sampleCompositionTimeOffset = readI32Be(slice);
}
}
@@ -1934,11 +1957,11 @@ export class IsobmffDemuxer extends Demuxer {
break;
}
- this.currentTrack.name = textDecoder.decode(this.metadataReader.readBytes(boxInfo.contentSize));
+ this.currentTrack.name = textDecoder.decode(readBytes(slice, boxInfo.contentSize));
}; break;
}
- this.metadataReader.pos = boxEndPos;
+ slice.filePos = boxEndPos;
return true;
}
}
@@ -2236,14 +2259,14 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
if (options.metadataOnly) {
data = PLACEHOLDER_DATA;
} else {
- // Load the entire chunk
- await this.internalTrack.demuxer.chunkReader.reader.loadRange(
- sampleInfo.chunkOffset,
- sampleInfo.chunkOffset + sampleInfo.chunkSize,
+ let slice = this.internalTrack.demuxer.reader.requestSlice(
+ sampleInfo.sampleOffset,
+ sampleInfo.sampleSize,
);
+ if (slice instanceof Promise) slice = await slice;
+ assert(slice);
- this.internalTrack.demuxer.chunkReader.pos = sampleInfo.sampleOffset;
- data = this.internalTrack.demuxer.chunkReader.readBytes(sampleInfo.sampleSize);
+ data = readBytes(slice, sampleInfo.sampleSize);
}
const timestamp = (sampleInfo.presentationTimestamp - this.internalTrack.editListOffset)
@@ -2276,11 +2299,14 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
if (options.metadataOnly) {
data = PLACEHOLDER_DATA;
} else {
- // Load the entire fragment
- await this.internalTrack.demuxer.chunkReader.reader.loadRange(fragment.dataStart, fragment.dataEnd);
+ let slice = this.internalTrack.demuxer.reader.requestSlice(
+ fragmentSample.byteOffset,
+ fragmentSample.byteSize,
+ );
+ if (slice instanceof Promise) slice = await slice;
+ assert(slice);
- this.internalTrack.demuxer.chunkReader.pos = fragmentSample.byteOffset;
- data = this.internalTrack.demuxer.chunkReader.readBytes(fragmentSample.byteSize);
+ data = readBytes(slice, fragmentSample.byteSize);
}
const timestamp = (fragmentSample.presentationTimestamp - this.internalTrack.editListOffset)
@@ -2388,9 +2414,6 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
return this.fetchPacketInFragment(fragment, sampleIndex, options);
}
- const metadataReader = demuxer.metadataReader;
- const sourceSize = await metadataReader.reader.source.getSize();
-
let prevFragment: Fragment | null = null;
let bestFragmentIndex = fragmentIndex;
let bestSampleIndex = sampleIndex;
@@ -2408,24 +2431,25 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
? this.internalTrack.fragmentLookupTable![lookupEntryIndex]!
: null;
+ let currentPos: number;
let nextFragmentIsFirstFragment = false;
if (fragmentIndex === -1) {
- metadataReader.pos = lookupEntry?.moofOffset ?? 0;
- nextFragmentIsFirstFragment = metadataReader.pos === 0;
+ currentPos = lookupEntry?.moofOffset ?? 0;
+ nextFragmentIsFirstFragment = currentPos === 0;
} else {
const fragment = this.internalTrack.fragments[fragmentIndex]!;
if (!lookupEntry || fragment.moofOffset >= lookupEntry.moofOffset) {
- metadataReader.pos = fragment.moofOffset + fragment.moofSize;
+ currentPos = fragment.moofOffset + fragment.moofSize;
prevFragment = fragment;
} else {
// Use the lookup entry
- metadataReader.pos = lookupEntry.moofOffset;
+ currentPos = lookupEntry.moofOffset;
}
}
- while (metadataReader.pos < sourceSize) {
+ while (currentPos < demuxer.reader.fileSize) {
if (prevFragment) {
const trackData = prevFragment.trackData.get(this.internalTrack.id);
if (trackData && trackData.startTimestamp > latestTimestamp) {
@@ -2435,16 +2459,19 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
if (prevFragment.nextFragment) {
// Skip ahead quickly without needing to read the file again
- metadataReader.pos = prevFragment.nextFragment.moofOffset + prevFragment.nextFragment.moofSize;
+ currentPos = prevFragment.nextFragment.moofOffset + prevFragment.nextFragment.moofSize;
prevFragment = prevFragment.nextFragment;
continue;
}
}
// Load the header
- await metadataReader.reader.loadRange(metadataReader.pos, metadataReader.pos + MAX_BOX_HEADER_SIZE);
- const startPos = metadataReader.pos;
- const boxInfo = metadataReader.readBoxHeader();
+ let slice = demuxer.reader.requestSliceRange(currentPos, MIN_BOX_HEADER_SIZE, MAX_BOX_HEADER_SIZE);
+ if (slice instanceof Promise) slice = await slice;
+ if (!slice) break;
+
+ const startPos = currentPos;
+ const boxInfo = readBoxHeader(slice);
if (!boxInfo) {
break;
}
@@ -2455,8 +2482,7 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
let fragment: Fragment;
if (index === -1) {
// This is the first time we've seen this fragment
- metadataReader.pos = startPos;
- fragment = await demuxer.readFragment();
+ fragment = await demuxer.readFragment(startPos);
} else {
// We already know this fragment
fragment = demuxer.fragments[index]!;
@@ -2482,7 +2508,7 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
}
}
- metadataReader.pos = startPos + boxInfo.totalSize;
+ currentPos = startPos + boxInfo.totalSize;
}
const bestFragment = bestFragmentIndex !== -1 ? this.internalTrack.fragments[bestFragmentIndex]! : null;
diff --git a/src/isobmff/isobmff-reader.ts b/src/isobmff/isobmff-reader.ts
index bbcd541..f1d0605 100644
--- a/src/isobmff/isobmff-reader.ts
+++ b/src/isobmff/isobmff-reader.ts
@@ -6,137 +6,50 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
-import { Reader } from '../reader';
+import { FileSlice, readAscii, readI32Be, readU32Be, readU64Be, readU8 } from '../reader';
export const MIN_BOX_HEADER_SIZE = 8;
export const MAX_BOX_HEADER_SIZE = 16;
-export class IsobmffReader {
- pos = 0;
+export const readBoxHeader = (slice: FileSlice) => {
+ let totalSize = readU32Be(slice);
+ const name = readAscii(slice, 4);
+ let headerSize = 8;
- constructor(public reader: Reader) {}
-
- readBytes(length: number) {
- const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + length);
- this.pos += length;
-
- return new Uint8Array(view.buffer, offset, length);
+ const hasLargeSize = totalSize === 1;
+ if (hasLargeSize) {
+ totalSize = readU64Be(slice);
+ headerSize = 16;
}
- readU8() {
- const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + 1);
- this.pos++;
-
- return view.getUint8(offset);
+ const contentSize = totalSize - headerSize;
+ if (contentSize < 0) {
+ return null; // Hardly a box is it
}
- readU16() {
- const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + 2);
- this.pos += 2;
+ return { name, totalSize, headerSize, contentSize };
+};
- return view.getUint16(offset, false);
- }
+export const readFixed_16_16 = (slice: FileSlice) => {
+ return readI32Be(slice) / 0x10000;
+};
- readI16() {
- const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + 2);
- this.pos += 2;
+export const readFixed_2_30 = (slice: FileSlice) => {
+ return readI32Be(slice) / 0x40000000;
+};
- return view.getInt16(offset, false);
- }
+export const readIsomVariableInteger = (slice: FileSlice) => {
+ let result = 0;
- readU24() {
- const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + 3);
- this.pos += 3;
+ for (let i = 0; i < 4; i++) {
+ result <<= 7;
+ const nextByte = readU8(slice);
+ result |= nextByte & 0x7f;
- const high = view.getUint16(offset, false);
- const low = view.getUint8(offset + 2);
- return high * 0x100 + low;
- }
-
- readU32() {
- const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + 4);
- this.pos += 4;
-
- return view.getUint32(offset, false);
- }
-
- readI32() {
- const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + 4);
- this.pos += 4;
-
- return view.getInt32(offset, false);
- }
-
- readU64() {
- const high = this.readU32();
- const low = this.readU32();
- return high * 0x100000000 + low;
- }
-
- readI64() {
- const high = this.readI32();
- const low = this.readU32();
- return high * 0x100000000 + low;
- }
-
- readF64() {
- const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + 8);
- this.pos += 8;
-
- return view.getFloat64(offset, false);
- }
-
- readFixed_16_16() {
- return this.readI32() / 0x10000;
- }
-
- readFixed_2_30() {
- return this.readI32() / 0x40000000;
- }
-
- readAscii(length: number) {
- const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + length);
- this.pos += length;
-
- let str = '';
- for (let i = 0; i < length; i++) {
- str += String.fromCharCode(view.getUint8(offset + i));
+ if ((nextByte & 0x80) === 0) {
+ break;
}
- return str;
}
- readIsomVariableInteger() {
- let result = 0;
-
- for (let i = 0; i < 4; i++) {
- result <<= 7;
- const nextByte = this.readU8();
- result |= nextByte & 0x7f;
-
- if ((nextByte & 0x80) === 0) {
- break;
- }
- }
-
- return result;
- }
-
- readBoxHeader() {
- let totalSize = this.readU32();
- const name = this.readAscii(4);
- let headerSize = 8;
-
- const hasLargeSize = totalSize === 1;
- if (hasLargeSize) {
- totalSize = this.readU64();
- headerSize = 16;
- }
-
- const contentSize = totalSize - headerSize;
- if (contentSize < 0) {
- return null; // Hardly a box is it
- }
-
- return { name, totalSize, headerSize, contentSize };
- }
-}
+ return result;
+};
diff --git a/src/matroska/ebml.ts b/src/matroska/ebml.ts
index 17207f8..6b14a42 100644
--- a/src/matroska/ebml.ts
+++ b/src/matroska/ebml.ts
@@ -8,7 +8,7 @@
import { MediaCodec } from '../codec';
import { assertNever, textDecoder, textEncoder } from '../misc';
-import { Reader } from '../reader';
+import { FileSlice, readBytes, Reader, readF32Be, readF64Be, readU8 } from '../reader';
import { Writer } from '../writer';
export interface EBMLElement {
@@ -68,6 +68,7 @@ export enum EBMLId {
DocType = 0x4282,
DocTypeVersion = 0x4287,
DocTypeReadVersion = 0x4285,
+ Segment = 0x18538067,
SeekHead = 0x114d9b74,
Seek = 0x4dbb,
SeekID = 0x53ab,
@@ -101,7 +102,6 @@ export enum EBMLId {
SamplingFrequency = 0xb5,
Channels = 0x9f,
BitDepth = 0x6264,
- Segment = 0x18538067,
SimpleBlock = 0xa3,
BlockGroup = 0xa0,
Block = 0xa1,
@@ -397,250 +397,215 @@ export class EBMLWriter {
}
}
-const MAX_VAR_INT_SIZE = 8;
+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 = 2 * MAX_VAR_INT_SIZE; // 8-byte ID and 8-byte size
-export class EBMLReader {
- pos = 0;
+export const readVarIntSize = (slice: FileSlice) => {
+ const firstByte = readU8(slice);
+ slice.skip(-1);
- constructor(public reader: Reader) {}
-
- readBytes(length: number) {
- const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + length);
- this.pos += length;
-
- return new Uint8Array(view.buffer, offset, length);
+ if (firstByte === 0) {
+ return null; // Invalid VINT
}
- readU8() {
- const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + 1);
- this.pos++;
-
- return view.getUint8(offset);
+ let width = 1;
+ let mask = 0x80;
+ while ((firstByte & mask) === 0) {
+ width++;
+ mask >>= 1;
}
- readS16() {
- const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + 2);
- this.pos += 2;
+ return width;
+};
- return view.getInt16(offset, false);
+export const readVarInt = (slice: FileSlice) => {
+ // Read the first byte to determine the width of the variable-length integer
+ const firstByte = readU8(slice);
+
+ if (firstByte === 0) {
+ return null; // Invalid VINT
}
- readVarIntSize() {
- const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + 1);
- const firstByte = view.getUint8(offset);
-
- if (firstByte === 0) {
- return null; // Invalid VINT
- }
-
- let width = 1;
- let mask = 0x80;
- while ((firstByte & mask) === 0) {
- width++;
- mask >>= 1;
- }
-
- return width;
+ // Find the position of VINT_MARKER, which determines the width
+ let width = 1;
+ let mask = 1 << 7;
+ while ((firstByte & mask) === 0) {
+ width++;
+ mask >>= 1;
}
- readVarInt() {
- // Read the first byte to determine the width of the variable-length integer
- const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + 1);
- const firstByte = view.getUint8(offset);
+ // First byte's value needs the marker bit cleared
+ let value = firstByte & (mask - 1);
- if (firstByte === 0) {
- return null; // Invalid VINT
- }
-
- // Find the position of VINT_MARKER, which determines the width
- let width = 1;
- let mask = 1 << 7;
- while ((firstByte & mask) === 0) {
- width++;
- mask >>= 1;
- }
-
- const { view: fullView, offset: fullOffset } = this.reader.getViewAndOffset(this.pos, this.pos + width);
-
- // First byte's value needs the marker bit cleared
- let value = firstByte & (mask - 1);
-
- // Read remaining bytes
- for (let i = 1; i < width; i++) {
- value *= 1 << 8;
- value += fullView.getUint8(fullOffset + i);
- }
-
- this.pos += width;
- return value;
+ // Read remaining bytes
+ for (let i = 1; i < width; i++) {
+ value *= 1 << 8;
+ value += readU8(slice);
}
- readUnsignedInt(width: number) {
- if (width < 1 || width > 8) {
- throw new Error('Bad unsigned int size ' + width);
- }
+ return value;
+};
- const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + width);
- let value = 0;
-
- // Read bytes from most significant to least significant
- for (let i = 0; i < width; i++) {
- value *= 1 << 8;
- value += view.getUint8(offset + i);
- }
-
- this.pos += width;
- return value;
+export const readUnsignedInt = (slice: FileSlice, width: number) => {
+ if (width < 1 || width > 8) {
+ throw new Error('Bad unsigned int size ' + width);
}
- readSignedInt(width: number) {
- let value = this.readUnsignedInt(width);
+ let value = 0;
- // If the highest bit is set, convert from two's complement
- if (value & (1 << (width * 8 - 1))) {
- value -= 2 ** (width * 8);
- }
-
- return value;
+ // Read bytes from most significant to least significant
+ for (let i = 0; i < width; i++) {
+ value *= 1 << 8;
+ value += readU8(slice);
}
- readFloat(width: number) {
- if (width === 0) {
- return 0;
- }
+ return value;
+};
- if (width !== 4 && width !== 8) {
- throw new Error('Bad float size ' + width);
- }
+export const readSignedInt = (slice: FileSlice, width: number) => {
+ let value = readUnsignedInt(slice, width);
- const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + width);
- const value = width === 4 ? view.getFloat32(offset, false) : view.getFloat64(offset, false);
-
- this.pos += width;
- return value;
+ // If the highest bit is set, convert from two's complement
+ if (value & (1 << (width * 8 - 1))) {
+ value -= 2 ** (width * 8);
}
- readAsciiString(length: number) {
- const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + length);
- this.pos += length;
+ return value;
+};
- // Actual string length might be shorter due to null terminators
- let strLength = 0;
- while (strLength < length && view.getUint8(offset + strLength) !== 0) {
- strLength += 1;
- }
-
- return String.fromCharCode(...new Uint8Array(view.buffer, offset, strLength));
+export const readElementId = (slice: FileSlice) => {
+ const size = readVarIntSize(slice);
+ if (size === null) {
+ return null;
}
- readUnicodeString(length: number) {
- const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + length);
- this.pos += length;
+ const id = readUnsignedInt(slice, size);
+ return id;
+};
- // Actual string length might be shorter due to null terminators
- let strLength = 0;
- while (strLength < length && view.getUint8(offset + strLength) !== 0) {
- strLength += 1;
- }
+export const readElementSize = (slice: FileSlice) => {
+ let size: number | null = readU8(slice);
- return textDecoder.decode(new Uint8Array(view.buffer, offset, strLength));
- }
+ if (size === 0xff) {
+ size = null;
+ } else {
+ slice.skip(-1);
+ size = readVarInt(slice);
- readElementId() {
- const size = this.readVarIntSize();
- if (size === null) {
- return null;
- }
-
- const id = this.readUnsignedInt(size);
- return id;
- }
-
- readElementSize() {
- let size: number | null = this.readU8();
-
- if (size === 0xff) {
+ // In some (livestreamed) files, this is the value of the size field. While this technically is just a very
+ // large number, it is intended to behave like the reserved size 0xFF, meaning the size is undefined. We
+ // catch the number here. Note that it cannot be perfectly represented as a double, but the comparison works
+ // nonetheless.
+ // eslint-disable-next-line no-loss-of-precision
+ if (size === 0x00ffffffffffffff) {
size = null;
- } else {
- this.pos--;
- size = this.readVarInt();
-
- // In some (livestreamed) files, this is the value of the size field. While this technically is just a very
- // large number, it is intended to behave like the reserved size 0xFF, meaning the size is undefined. We
- // catch the number here. Note that it cannot be perfectly represented as a double, but the comparison works
- // nonetheless.
- // eslint-disable-next-line no-loss-of-precision
- if (size === 0x00ffffffffffffff) {
- size = null;
- }
}
-
- return size;
}
- readElementHeader() {
- const id = this.readElementId();
- if (id === null) {
- return null;
- }
-
- const size = this.readElementSize();
-
- return { id, size };
- }
-
- /** Returns the byte offset in the file of the next element with a matching ID. */
- async searchForNextElementId(ids: EBMLId[], until: number) {
- const loadChunkSize = 2 ** 20; // 1 MiB
- const idsSet = new Set(ids);
-
- while (this.pos <= until - MIN_HEADER_SIZE) {
- if (!this.reader.rangeIsLoaded(this.pos, Math.min(this.pos + MAX_HEADER_SIZE, until))) {
- await this.reader.loadRange(this.pos, Math.min(this.pos + loadChunkSize, until));
- }
-
- const elementStartPos = this.pos;
- const elementHeader = this.readElementHeader();
- if (!elementHeader) {
- break;
- }
-
- if (idsSet.has(elementHeader.id)) {
- return elementStartPos;
- }
-
- assertDefinedSize(elementHeader.size);
-
- this.pos += elementHeader.size;
- }
+ return size;
+};
+export const readElementHeader = (slice: FileSlice) => {
+ const id = readElementId(slice);
+ if (id === null) {
return null;
}
- /** Searches for the next occurrence of an element ID using a naive byte-wise search. */
- async resync(ids: EBMLId[], until: number) {
- const loadChunkSize = 2 ** 20; // 1 MiB
- const idsSet = new Set(ids);
+ const size = readElementSize(slice);
- while (this.pos <= until - MIN_HEADER_SIZE) {
- if (!this.reader.rangeIsLoaded(this.pos, Math.min(this.pos + MAX_HEADER_SIZE, until))) {
- await this.reader.loadRange(this.pos, Math.min(this.pos + loadChunkSize, until));
- }
+ return { id, size };
+};
- const elementStartPos = this.pos;
- const elementId = this.readElementId();
+export const readAsciiString = (slice: FileSlice, length: number) => {
+ const bytes = readBytes(slice, length);
+
+ // Actual string length might be shorter due to null terminators
+ let strLength = 0;
+ while (strLength < length && bytes[strLength] !== 0) {
+ strLength += 1;
+ }
+
+ return String.fromCharCode(...bytes.subarray(0, strLength));
+};
+
+export const readUnicodeString = (slice: FileSlice, length: number) => {
+ const bytes = readBytes(slice, length);
+
+ // Actual string length might be shorter due to null terminators
+ let strLength = 0;
+ while (strLength < length && bytes[strLength] !== 0) {
+ strLength += 1;
+ }
+
+ return textDecoder.decode(bytes.subarray(0, strLength));
+};
+
+export const readFloat = (slice: FileSlice, width: number) => {
+ if (width === 0) {
+ return 0;
+ }
+
+ if (width !== 4 && width !== 8) {
+ throw new Error('Bad float size ' + width);
+ }
+
+ return width === 4 ? readF32Be(slice) : readF64Be(slice);
+};
+
+/** Returns the byte offset in the file of the next element with a matching ID. */
+export const searchForNextElementId = async (reader: Reader, startPos: number, ids: EBMLId[], until: number) => {
+ const idsSet = new Set(ids);
+ let currentPos = startPos;
+
+ while (currentPos < until) {
+ let slice = reader.requestSliceRange(currentPos, MIN_HEADER_SIZE, MAX_HEADER_SIZE);
+ if (slice instanceof Promise) slice = await slice;
+ if (!slice) break;
+
+ const elementHeader = readElementHeader(slice);
+ if (!elementHeader) {
+ break;
+ }
+
+ if (idsSet.has(elementHeader.id)) {
+ return currentPos;
+ }
+
+ assertDefinedSize(elementHeader.size);
+
+ currentPos = slice.filePos + elementHeader.size;
+ }
+
+ return null;
+};
+
+/** Searches for the next occurrence of an element ID using a naive byte-wise search. */
+export const resync = async (reader: Reader, startPos: number, ids: EBMLId[], until: number) => {
+ const CHUNK_SIZE = 2 ** 16; // So we don't need to grab thousands of slices
+ const idsSet = new Set(ids);
+ let currentPos = startPos;
+
+ while (currentPos < until) {
+ let slice = reader.requestSliceRange(currentPos, 0, Math.min(CHUNK_SIZE, until - currentPos));
+ if (slice instanceof Promise) slice = await slice;
+ if (!slice) break;
+ if (slice.length < MAX_VAR_INT_SIZE) break;
+
+ for (let i = 0; i < slice.length - MAX_VAR_INT_SIZE; i++) {
+ slice.filePos = currentPos;
+
+ const elementId = readElementId(slice);
if (elementId !== null && idsSet.has(elementId)) {
- return elementStartPos;
+ return currentPos;
}
- this.pos = elementStartPos + 1;
+ currentPos++;
}
-
- return null;
}
-}
+
+ return null;
+};
export const CODEC_STRING_MAP: Partial> = {
'avc': 'V_MPEG4/ISO/AVC',
@@ -667,38 +632,6 @@ export const CODEC_STRING_MAP: Partial> = {
'webvtt': 'S_TEXT/WEBVTT',
};
-export const readVarInt = (data: Uint8Array, offset: number) => {
- if (offset >= data.length) {
- throw new Error('Offset out of bounds.');
- }
-
- // Read the first byte to determine the width of the variable-length integer
- const firstByte = data[offset]!;
-
- // Find the position of VINT_MARKER, which determines the width
- let width = 1;
- let mask = 1 << 7;
- while ((firstByte & mask) === 0 && width < 8) {
- width++;
- mask >>= 1;
- }
-
- if (offset + width > data.length) {
- throw new Error('VarInt extends beyond data bounds.');
- }
-
- // First byte's value needs the marker bit cleared
- let value = firstByte & (mask - 1);
-
- // Read remaining bytes
- for (let i = 1; i < width; i++) {
- value *= 1 << 8;
- value += data[offset + i]!;
- }
-
- return { value, width };
-};
-
export function assertDefinedSize(size: number | null): asserts size is number {
if (size === null) {
throw new Error('Undefined element size is used in a place where it is not supported.');
diff --git a/src/matroska/matroska-demuxer.ts b/src/matroska/matroska-demuxer.ts
index e044c59..0310131 100644
--- a/src/matroska/matroska-demuxer.ts
+++ b/src/matroska/matroska-demuxer.ts
@@ -49,19 +49,27 @@ import {
UNDETERMINED_LANGUAGE,
} from '../misc';
import { EncodedPacket, PLACEHOLDER_DATA } from '../packet';
-import { Reader } from '../reader';
import {
assertDefinedSize,
CODEC_STRING_MAP,
EBMLId,
- EBMLReader,
LEVEL_0_AND_1_EBML_IDS,
LEVEL_1_EBML_IDS,
MAX_HEADER_SIZE,
MIN_HEADER_SIZE,
+ readAsciiString,
+ readUnicodeString,
+ readElementHeader,
+ readElementId,
+ readFloat,
+ readSignedInt,
+ readUnsignedInt,
readVarInt,
+ resync,
+ searchForNextElementId,
} from './ebml';
import { buildMatroskaMimeType } from './matroska-misc';
+import { FileSlice, readBytes, Reader, readI16Be, readU8 } from '../reader';
type Segment = {
seekHeadSeen: boolean;
@@ -180,8 +188,7 @@ const METADATA_ELEMENTS = [
const MAX_RESYNC_LENGTH = 10 * 2 ** 20; // 10 MiB
export class MatroskaDemuxer extends Demuxer {
- metadataReader: EBMLReader;
- clusterReader: EBMLReader;
+ reader: Reader;
readMetadataPromise: Promise | null = null;
@@ -197,10 +204,7 @@ export class MatroskaDemuxer extends Demuxer {
constructor(input: Input) {
super(input);
- this.metadataReader = new EBMLReader(input._mainReader);
-
- // Max 64 MiB of stored clusters
- this.clusterReader = new EBMLReader(new Reader(input.source, 64 * 2 ** 20));
+ this.reader = input._reader;
}
override async computeDuration() {
@@ -230,33 +234,32 @@ export class MatroskaDemuxer extends Demuxer {
readMetadata() {
return this.readMetadataPromise ??= (async () => {
- this.metadataReader.pos = 0;
-
- const fileSize = await this.input.source.getSize();
-
// Loop over all top-level elements in the file
- while (this.metadataReader.pos <= fileSize - MIN_HEADER_SIZE) {
- await this.metadataReader.reader.loadRange(
- this.metadataReader.pos,
- this.metadataReader.pos + MAX_HEADER_SIZE,
- );
+ let currentPos = 0;
+ while (currentPos < this.reader.fileSize) {
+ let slice = this.reader.requestSliceRange(currentPos, MIN_HEADER_SIZE, MAX_HEADER_SIZE);
+ if (slice instanceof Promise) slice = await slice;
+ if (!slice) break;
- const header = this.metadataReader.readElementHeader();
+ const header = readElementHeader(slice);
if (!header) {
break; // Zero padding at the end of the file triggers this, for example
}
const id = header.id;
let size = header.size;
- const startPos = this.metadataReader.pos;
+ const dataStartPos = slice.filePos;
if (id === EBMLId.EBML) {
assertDefinedSize(size);
- await this.metadataReader.reader.loadRange(this.metadataReader.pos, this.metadataReader.pos + size);
- this.readContiguousElements(this.metadataReader, size);
+ let slice = this.reader.requestSlice(dataStartPos, size);
+ if (slice instanceof Promise) slice = await slice;
+ if (!slice) break;
+
+ this.readContiguousElements(slice);
} else if (id === EBMLId.Segment) { // Segment found!
- await this.readSegment(size);
+ await this.readSegment(dataStartPos, size);
if (size === null) {
// Segment sizes can be undefined (common in livestreamed files), so assume this is the last
@@ -271,29 +274,29 @@ export class MatroskaDemuxer extends Demuxer {
if (size === null) {
// Just in case this is one of those weird sizeless clusters, let's do our best and still try to
// determine its size.
- const nextElementPos = await this.clusterReader.searchForNextElementId(
+ const nextElementPos = await searchForNextElementId(
+ this.reader,
+ dataStartPos,
LEVEL_0_AND_1_EBML_IDS,
- fileSize,
+ this.reader.fileSize,
);
- size = (nextElementPos ?? fileSize) - startPos;
+ size = (nextElementPos ?? this.reader.fileSize) - dataStartPos;
}
const lastSegment = last(this.segments);
if (lastSegment) {
// Extend the previous segment's size
- lastSegment.elementEndPos = startPos + size;
+ lastSegment.elementEndPos = dataStartPos + size;
}
}
assertDefinedSize(size);
- this.metadataReader.pos = startPos + size;
+ currentPos = dataStartPos + size;
}
})();
}
- async readSegment(dataSize: number | null) {
- const segmentDataStart = this.metadataReader.pos;
-
+ async readSegment(segmentDataStart: number, dataSize: number | null) {
this.currentSegment = {
seekHeadSeen: false,
infoSeen: false,
@@ -318,33 +321,29 @@ export class MatroskaDemuxer extends Demuxer {
};
this.segments.push(this.currentSegment);
- // Let's load a good amount of data, enough for all segment metadata to likely fit into (minus cues)
- await this.metadataReader.reader.loadRange(
- this.metadataReader.pos,
- this.metadataReader.pos + 2 ** 14,
- );
-
+ let currentPos = 0;
let clusterEncountered = false;
- while (this.metadataReader.pos <= this.currentSegment.elementEndPos - MIN_HEADER_SIZE) {
- await this.metadataReader.reader.loadRange(
- this.metadataReader.pos,
- this.metadataReader.pos + MAX_HEADER_SIZE,
- );
- const elementStartPos = this.metadataReader.pos;
- const header = this.metadataReader.readElementHeader();
+ while (currentPos < this.currentSegment.elementEndPos) {
+ let slice = this.reader.requestSliceRange(currentPos, MIN_HEADER_SIZE, MAX_HEADER_SIZE);
+ if (slice instanceof Promise) slice = await slice;
+ if (!slice) break;
+
+ const elementStartPos = currentPos;
+ const header = readElementHeader(slice);
if (!header || !LEVEL_1_EBML_IDS.includes(header.id)) {
// Potential junk. Let's try to resync
- this.metadataReader.pos = elementStartPos;
- const nextPos = await this.metadataReader.resync(
+ const nextPos = await resync(
+ this.reader,
+ elementStartPos,
LEVEL_1_EBML_IDS,
- Math.min(this.currentSegment.elementEndPos, this.metadataReader.pos + MAX_RESYNC_LENGTH),
+ Math.min(this.currentSegment.elementEndPos, elementStartPos + MAX_RESYNC_LENGTH),
);
if (nextPos) {
- this.metadataReader.pos = nextPos;
+ currentPos = nextPos;
continue;
} else {
break; // Resync failed
@@ -352,7 +351,7 @@ export class MatroskaDemuxer extends Demuxer {
}
const { id, size } = header;
- const dataStartPos = this.metadataReader.pos;
+ const dataStartPos = slice.filePos;
const metadataElementIndex = METADATA_ELEMENTS.findIndex(x => x.id === id);
if (metadataElementIndex !== -1) {
@@ -360,8 +359,13 @@ export class MatroskaDemuxer extends Demuxer {
this.currentSegment[field] = true;
assertDefinedSize(size);
- await this.metadataReader.reader.loadRange(this.metadataReader.pos, this.metadataReader.pos + size);
- this.readContiguousElements(this.metadataReader, size);
+
+ let slice = this.reader.requestSlice(dataStartPos, size);
+ if (slice instanceof Promise) slice = await slice;
+
+ if (slice) {
+ this.readContiguousElements(slice);
+ }
} else if (id === EBMLId.Cluster) {
if (!clusterEncountered) {
clusterEncountered = true;
@@ -370,7 +374,7 @@ export class MatroskaDemuxer extends Demuxer {
}
if (size !== null) {
- this.metadataReader.pos = dataStartPos + size;
+ currentPos = dataStartPos + size;
}
if (this.currentSegment.infoSeen && this.currentSegment.tracksSeen && this.currentSegment.cuesSeen) {
@@ -411,23 +415,31 @@ export class MatroskaDemuxer extends Demuxer {
// The seek head points us to the first cluster, nice
this.currentSegment.clusterSeekStartPos = segmentDataStart + seekEntry.segmentPosition;
} else {
- this.currentSegment.clusterSeekStartPos = this.metadataReader.pos;
+ this.currentSegment.clusterSeekStartPos = currentPos;
}
}
+ // Sort the seek entries by file position so reading them exhibits a sequential pattern
+ this.currentSegment.seekEntries.sort((a, b) => a.segmentPosition - b.segmentPosition);
+
// Use the seek head to read missing metadata elements
- for (const target of METADATA_ELEMENTS) {
+ for (const seekEntry of this.currentSegment.seekEntries) {
+ const target = METADATA_ELEMENTS.find(x => x.id === seekEntry.id);
+ if (!target) {
+ continue;
+ }
+
if (this.currentSegment[target.flag]) continue;
- const seekEntry = this.currentSegment.seekEntries.find(entry => entry.id === target.id);
- if (!seekEntry) continue;
-
- this.metadataReader.pos = segmentDataStart + seekEntry.segmentPosition;
- await this.metadataReader.reader.loadRange(
- this.metadataReader.pos,
- this.metadataReader.pos + 2 ** 12, // Load a larger range, assuming the correct element will be there
+ let slice = this.reader.requestSliceRange(
+ segmentDataStart + seekEntry.segmentPosition,
+ MIN_HEADER_SIZE,
+ MAX_HEADER_SIZE,
);
- const header = this.metadataReader.readElementHeader();
+ if (slice instanceof Promise) slice = await slice;
+ if (!slice) continue;
+
+ const header = readElementHeader(slice);
if (!header) continue;
const { id, size } = header;
@@ -436,8 +448,12 @@ export class MatroskaDemuxer extends Demuxer {
assertDefinedSize(size);
this.currentSegment[target.flag] = true;
- await this.metadataReader.reader.loadRange(this.metadataReader.pos, this.metadataReader.pos + size);
- this.readContiguousElements(this.metadataReader, size);
+
+ let dataSlice = this.reader.requestSlice(slice.filePos, size);
+ if (dataSlice instanceof Promise) dataSlice = await dataSlice;
+ if (!dataSlice) continue;
+
+ this.readContiguousElements(dataSlice);
}
if (this.currentSegment.timestampScale === -1) {
@@ -502,23 +518,26 @@ export class MatroskaDemuxer extends Demuxer {
this.currentSegment = null;
}
- async readCluster(segment: Segment) {
- await this.metadataReader.reader.loadRange(this.metadataReader.pos, this.metadataReader.pos + MAX_HEADER_SIZE);
+ async readCluster(startPos: number, segment: Segment) {
+ let headerSlice = this.reader.requestSliceRange(startPos, MIN_HEADER_SIZE, MAX_HEADER_SIZE);
+ if (headerSlice instanceof Promise) headerSlice = await headerSlice;
+ assert(headerSlice);
- const elementStartPos = this.metadataReader.pos;
- const elementHeader = this.metadataReader.readElementHeader();
+ const elementStartPos = startPos;
+ const elementHeader = readElementHeader(headerSlice);
assert(elementHeader);
const id = elementHeader.id;
let size = elementHeader.size;
- const dataStartPos = this.metadataReader.pos;
+ const dataStartPos = headerSlice.filePos;
if (size === null) {
// The cluster's size is undefined (can happen in livestreamed files). We'd still like to know the size of
// it, so we have no other choice but to iterate over the EBML structure until we find an element at level
// 0 or 1, indicating the end of the cluster (all elements inside the cluster are at level 2).
- this.clusterReader.pos = dataStartPos;
- const nextElementPos = await this.clusterReader.searchForNextElementId(
+ const nextElementPos = await searchForNextElementId(
+ this.reader,
+ dataStartPos,
LEVEL_0_AND_1_EBML_IDS,
segment.elementEndPos,
);
@@ -529,8 +548,8 @@ export class MatroskaDemuxer extends Demuxer {
assert(id === EBMLId.Cluster);
// Load the entire cluster
- this.clusterReader.pos = dataStartPos;
- await this.clusterReader.reader.loadRange(this.clusterReader.pos, this.clusterReader.pos + size);
+ let dataSlice = this.reader.requestSlice(dataStartPos, size);
+ if (dataSlice instanceof Promise) dataSlice = await dataSlice;
const cluster: Cluster = {
elementStartPos,
@@ -542,7 +561,10 @@ export class MatroskaDemuxer extends Demuxer {
isKnownToBeFirstCluster: false,
};
this.currentCluster = cluster;
- this.readContiguousElements(this.clusterReader, size);
+
+ if (dataSlice) {
+ this.readContiguousElements(dataSlice);
+ }
for (const [trackId, trackData] of cluster.trackData) {
const track = segment.tracks.find(x => x.id === trackId) ?? null;
@@ -651,12 +673,10 @@ export class MatroskaDemuxer extends Demuxer {
continue;
}
- const data = originalBlock.data;
- let pos = 0;
+ const slice = FileSlice.tempFromBytes(originalBlock.data);
const frameSizes: number[] = [];
- const frameCount = data[pos]! + 1;
- pos++;
+ const frameCount = readU8(slice) + 1;
switch (originalBlock.lacing) {
case BlockLacing.Xiph: {
@@ -666,10 +686,9 @@ export class MatroskaDemuxer extends Demuxer {
for (let i = 0; i < frameCount - 1; i++) {
let frameSize = 0;
- while (pos < data.length) {
- const value = data[pos]!;
+ while (slice.bufferPos < slice.length) {
+ const value = readU8(slice);
frameSize += value;
- pos++;
if (value < 255) {
frameSizes.push(frameSize);
@@ -681,12 +700,12 @@ export class MatroskaDemuxer extends Demuxer {
}
// Compute the last frame's size from whatever's left
- frameSizes.push(data.length - (pos + totalUsedSize));
+ frameSizes.push(slice.length - (slice.bufferPos + totalUsedSize));
}; break;
case BlockLacing.FixedSize: {
// Fixed size lacing: all frames have same size
- const totalDataSize = data.length - 1; // Minus the frame count byte
+ const totalDataSize = slice.length - 1; // Minus the frame count byte
const frameSize = Math.floor(totalDataSize / frameCount);
for (let i = 0; i < frameCount; i++) {
@@ -696,28 +715,32 @@ export class MatroskaDemuxer extends Demuxer {
case BlockLacing.Ebml: {
// EBML lacing: first size absolute, subsequent ones are coded as signed differences from the last
- const firstResult = readVarInt(data, pos);
- let currentSize = firstResult.value;
+ const firstResult = readVarInt(slice);
+ assert(firstResult !== null); // Assume it's not an invalid VINT
+
+ let currentSize = firstResult;
frameSizes.push(currentSize);
- pos += firstResult.width;
let totalUsedSize = currentSize;
for (let i = 1; i < frameCount - 1; i++) {
- const diffResult = readVarInt(data, pos);
- const unsignedDiff = diffResult.value;
- const bias = (1 << (diffResult.width * 7 - 1)) - 1; // Typo-corrected version of 2^((7*n)-1)^-1
+ const startPos = slice.bufferPos;
+ const diffResult = readVarInt(slice);
+ assert(diffResult !== null);
+
+ const unsignedDiff = diffResult;
+ const width = slice.bufferPos - startPos;
+ const bias = (1 << (width * 7 - 1)) - 1; // Typo-corrected version of 2^((7*n)-1)^-1
const diff = unsignedDiff - bias;
currentSize += diff;
frameSizes.push(currentSize);
- pos += diffResult.width;
totalUsedSize += currentSize;
}
// Compute the last frame's size from whatever's left
- frameSizes.push(data.length - (pos + totalUsedSize));
+ frameSizes.push(slice.length - (slice.bufferPos + totalUsedSize));
}; break;
default: assert(false);
@@ -726,12 +749,11 @@ export class MatroskaDemuxer extends Demuxer {
assert(frameSizes.length === frameCount);
blocks.splice(blockIndex, 1); // Remove the original block
- let dataOffset = pos;
// Now, let's insert each frame as its own block
for (let i = 0; i < frameCount; i++) {
const frameSize = frameSizes[i]!;
- const frameData = data.subarray(dataOffset, dataOffset + frameSize);
+ const frameData = readBytes(slice, frameSize);
const blockDuration = originalBlock.duration || (frameCount * (track?.defaultDuration ?? 0));
@@ -747,8 +769,6 @@ export class MatroskaDemuxer extends Demuxer {
data: frameData,
lacing: BlockLacing.None,
});
-
- dataOffset += frameSize;
}
blockIndex += frameCount; // Skip the blocks we just added
@@ -756,11 +776,11 @@ export class MatroskaDemuxer extends Demuxer {
}
}
- readContiguousElements(reader: EBMLReader, totalSize: number) {
- const startIndex = reader.pos;
+ readContiguousElements(slice: FileSlice) {
+ const startIndex = slice.filePos;
- while (reader.pos - startIndex <= totalSize - MIN_HEADER_SIZE) {
- const foundElement = this.traverseElement(reader);
+ while (slice.filePos - startIndex <= slice.length - MIN_HEADER_SIZE) {
+ const foundElement = this.traverseElement(slice);
if (!foundElement) {
break;
@@ -768,26 +788,26 @@ export class MatroskaDemuxer extends Demuxer {
}
}
- traverseElement(reader: EBMLReader): boolean {
- const header = reader.readElementHeader();
+ traverseElement(slice: FileSlice): boolean {
+ const header = readElementHeader(slice);
if (!header) {
return false;
}
const { id, size } = header;
- const dataStartPos = reader.pos;
+ const dataStartPos = slice.filePos;
assertDefinedSize(size);
switch (id) {
case EBMLId.DocType: {
- this.isWebM = reader.readAsciiString(size) === 'webm';
+ this.isWebM = readAsciiString(slice, size) === 'webm';
}; break;
case EBMLId.Seek: {
if (!this.currentSegment) break;
const seekEntry: SeekEntry = { id: -1, segmentPosition: -1 };
this.currentSegment.seekEntries.push(seekEntry);
- this.readContiguousElements(reader, size);
+ this.readContiguousElements(slice.slice(dataStartPos, size));
if (seekEntry.id === -1 || seekEntry.segmentPosition === -1) {
this.currentSegment.seekEntries.pop();
@@ -798,27 +818,27 @@ export class MatroskaDemuxer extends Demuxer {
const lastSeekEntry = this.currentSegment?.seekEntries[this.currentSegment.seekEntries.length - 1];
if (!lastSeekEntry) break;
- lastSeekEntry.id = reader.readUnsignedInt(size);
+ lastSeekEntry.id = readUnsignedInt(slice, size);
}; break;
case EBMLId.SeekPosition: {
const lastSeekEntry = this.currentSegment?.seekEntries[this.currentSegment.seekEntries.length - 1];
if (!lastSeekEntry) break;
- lastSeekEntry.segmentPosition = reader.readUnsignedInt(size);
+ lastSeekEntry.segmentPosition = readUnsignedInt(slice, size);
}; break;
case EBMLId.TimestampScale: {
if (!this.currentSegment) break;
- this.currentSegment.timestampScale = reader.readUnsignedInt(size);
+ this.currentSegment.timestampScale = readUnsignedInt(slice, size);
this.currentSegment.timestampFactor = 1e9 / this.currentSegment.timestampScale;
}; break;
case EBMLId.Duration: {
if (!this.currentSegment) break;
- this.currentSegment.duration = reader.readFloat(size);
+ this.currentSegment.duration = readFloat(slice, size);
}; break;
case EBMLId.TrackEntry: {
@@ -842,7 +862,7 @@ export class MatroskaDemuxer extends Demuxer {
info: null,
};
- this.readContiguousElements(reader, size);
+ this.readContiguousElements(slice.slice(dataStartPos, size));
if (
this.currentTrack
@@ -941,13 +961,13 @@ export class MatroskaDemuxer extends Demuxer {
case EBMLId.TrackNumber: {
if (!this.currentTrack) break;
- this.currentTrack.id = reader.readUnsignedInt(size);
+ this.currentTrack.id = readUnsignedInt(slice, size);
}; break;
case EBMLId.TrackType: {
if (!this.currentTrack) break;
- const type = reader.readUnsignedInt(size);
+ const type = readUnsignedInt(slice, size);
if (type === 1) {
this.currentTrack.info = {
type: 'video',
@@ -974,7 +994,7 @@ export class MatroskaDemuxer extends Demuxer {
case EBMLId.FlagEnabled: {
if (!this.currentTrack) break;
- const enabled = reader.readUnsignedInt(size);
+ const enabled = readUnsignedInt(slice, size);
if (!enabled) {
this.currentSegment!.tracks.pop();
this.currentTrack = null;
@@ -984,32 +1004,32 @@ export class MatroskaDemuxer extends Demuxer {
case EBMLId.FlagDefault: {
if (!this.currentTrack) break;
- this.currentTrack.isDefault = !!reader.readUnsignedInt(size);
+ this.currentTrack.isDefault = !!readUnsignedInt(slice, size);
}; break;
case EBMLId.CodecID: {
if (!this.currentTrack) break;
- this.currentTrack.codecId = reader.readAsciiString(size);
+ this.currentTrack.codecId = readAsciiString(slice, size);
}; break;
case EBMLId.CodecPrivate: {
if (!this.currentTrack) break;
- this.currentTrack.codecPrivate = reader.readBytes(size);
+ this.currentTrack.codecPrivate = readBytes(slice, size);
}; break;
case EBMLId.DefaultDuration: {
if (!this.currentTrack) break;
this.currentTrack.defaultDuration
- = this.currentTrack.segment.timestampFactor * reader.readUnsignedInt(size) / 1e9;
+ = this.currentTrack.segment.timestampFactor * readUnsignedInt(slice, size) / 1e9;
}; break;
case EBMLId.Name: {
if (!this.currentTrack) break;
- this.currentTrack.name = reader.readUnicodeString(size);
+ this.currentTrack.name = readUnicodeString(slice, size);
}; break;
case EBMLId.Language: {
@@ -1019,7 +1039,7 @@ export class MatroskaDemuxer extends Demuxer {
break;
}
- this.currentTrack.languageCode = reader.readAsciiString(size);
+ this.currentTrack.languageCode = readAsciiString(slice, size);
if (!isIso639Dash2LanguageCode(this.currentTrack.languageCode)) {
this.currentTrack.languageCode = UNDETERMINED_LANGUAGE;
@@ -1029,7 +1049,7 @@ export class MatroskaDemuxer extends Demuxer {
case EBMLId.LanguageBCP47: {
if (!this.currentTrack) break;
- const bcp47 = reader.readAsciiString(size);
+ const bcp47 = readAsciiString(slice, size);
const languageSubtag = bcp47.split('-')[0];
if (languageSubtag) {
@@ -1046,32 +1066,32 @@ export class MatroskaDemuxer extends Demuxer {
case EBMLId.Video: {
if (this.currentTrack?.info?.type !== 'video') break;
- this.readContiguousElements(reader, size);
+ this.readContiguousElements(slice.slice(dataStartPos, size));
}; break;
case EBMLId.PixelWidth: {
if (this.currentTrack?.info?.type !== 'video') break;
- this.currentTrack.info.width = reader.readUnsignedInt(size);
+ this.currentTrack.info.width = readUnsignedInt(slice, size);
}; break;
case EBMLId.PixelHeight: {
if (this.currentTrack?.info?.type !== 'video') break;
- this.currentTrack.info.height = reader.readUnsignedInt(size);
+ this.currentTrack.info.height = readUnsignedInt(slice, size);
}; break;
case EBMLId.Colour: {
if (this.currentTrack?.info?.type !== 'video') break;
this.currentTrack.info.colorSpace = {};
- this.readContiguousElements(reader, size);
+ this.readContiguousElements(slice.slice(dataStartPos, size));
}; break;
case EBMLId.MatrixCoefficients: {
if (this.currentTrack?.info?.type !== 'video' || !this.currentTrack.info.colorSpace) break;
- const matrixCoefficients = reader.readUnsignedInt(size);
+ const matrixCoefficients = readUnsignedInt(slice, size);
const mapped = MATRIX_COEFFICIENTS_MAP_INVERSE[matrixCoefficients] ?? null;
this.currentTrack.info.colorSpace.matrix = mapped as VideoColorSpaceInit['matrix'];
}; break;
@@ -1079,13 +1099,13 @@ export class MatroskaDemuxer extends Demuxer {
case EBMLId.Range: {
if (this.currentTrack?.info?.type !== 'video' || !this.currentTrack.info.colorSpace) break;
- this.currentTrack.info.colorSpace.fullRange = reader.readUnsignedInt(size) === 2;
+ this.currentTrack.info.colorSpace.fullRange = readUnsignedInt(slice, size) === 2;
}; break;
case EBMLId.TransferCharacteristics: {
if (this.currentTrack?.info?.type !== 'video' || !this.currentTrack.info.colorSpace) break;
- const transferCharacteristics = reader.readUnsignedInt(size);
+ const transferCharacteristics = readUnsignedInt(slice, size);
const mapped = TRANSFER_CHARACTERISTICS_MAP_INVERSE[transferCharacteristics] ?? null;
this.currentTrack.info.colorSpace.transfer = mapped as VideoColorSpaceInit['transfer'];
}; break;
@@ -1093,7 +1113,7 @@ export class MatroskaDemuxer extends Demuxer {
case EBMLId.Primaries: {
if (this.currentTrack?.info?.type !== 'video' || !this.currentTrack.info.colorSpace) break;
- const primaries = reader.readUnsignedInt(size);
+ const primaries = readUnsignedInt(slice, size);
const mapped = COLOR_PRIMARIES_MAP_INVERSE[primaries] ?? null;
this.currentTrack.info.colorSpace.primaries = mapped as VideoColorSpaceInit['primaries'];
}; break;
@@ -1101,13 +1121,13 @@ export class MatroskaDemuxer extends Demuxer {
case EBMLId.Projection: {
if (this.currentTrack?.info?.type !== 'video') break;
- this.readContiguousElements(reader, size);
+ this.readContiguousElements(slice.slice(dataStartPos, size));
}; break;
case EBMLId.ProjectionPoseRoll: {
if (this.currentTrack?.info?.type !== 'video') break;
- const rotation = reader.readFloat(size);
+ const rotation = readFloat(slice, size);
const flippedRotation = -rotation; // Convert counter-clockwise to clockwise
try {
@@ -1120,36 +1140,36 @@ export class MatroskaDemuxer extends Demuxer {
case EBMLId.Audio: {
if (this.currentTrack?.info?.type !== 'audio') break;
- this.readContiguousElements(reader, size);
+ this.readContiguousElements(slice.slice(dataStartPos, size));
}; break;
case EBMLId.SamplingFrequency: {
if (this.currentTrack?.info?.type !== 'audio') break;
- this.currentTrack.info.sampleRate = reader.readFloat(size);
+ this.currentTrack.info.sampleRate = readFloat(slice, size);
}; break;
case EBMLId.Channels: {
if (this.currentTrack?.info?.type !== 'audio') break;
- this.currentTrack.info.numberOfChannels = reader.readUnsignedInt(size);
+ this.currentTrack.info.numberOfChannels = readUnsignedInt(slice, size);
}; break;
case EBMLId.BitDepth: {
if (this.currentTrack?.info?.type !== 'audio') break;
- this.currentTrack.info.bitDepth = reader.readUnsignedInt(size);
+ this.currentTrack.info.bitDepth = readUnsignedInt(slice, size);
}; break;
case EBMLId.CuePoint: {
if (!this.currentSegment) break;
- this.readContiguousElements(reader, size);
+ this.readContiguousElements(slice.slice(dataStartPos, size));
this.currentCueTime = null;
}; break;
case EBMLId.CueTime: {
- this.currentCueTime = reader.readUnsignedInt(size);
+ this.currentCueTime = readUnsignedInt(slice, size);
}; break;
case EBMLId.CueTrackPositions: {
@@ -1158,7 +1178,7 @@ export class MatroskaDemuxer extends Demuxer {
const cuePoint: CuePoint = { time: this.currentCueTime, trackId: -1, clusterPosition: -1 };
this.currentSegment.cuePoints.push(cuePoint);
- this.readContiguousElements(reader, size);
+ this.readContiguousElements(slice.slice(dataStartPos, size));
if (cuePoint.trackId === -1 || cuePoint.clusterPosition === -1) {
this.currentSegment.cuePoints.pop();
@@ -1169,7 +1189,7 @@ export class MatroskaDemuxer extends Demuxer {
const lastCuePoint = this.currentSegment?.cuePoints[this.currentSegment.cuePoints.length - 1];
if (!lastCuePoint) break;
- lastCuePoint.trackId = reader.readUnsignedInt(size);
+ lastCuePoint.trackId = readUnsignedInt(slice, size);
}; break;
case EBMLId.CueClusterPosition: {
@@ -1177,24 +1197,24 @@ export class MatroskaDemuxer extends Demuxer {
if (!lastCuePoint) break;
assert(this.currentSegment);
- lastCuePoint.clusterPosition = this.currentSegment.dataStartPos + reader.readUnsignedInt(size);
+ lastCuePoint.clusterPosition = this.currentSegment.dataStartPos + readUnsignedInt(slice, size);
}; break;
case EBMLId.Timestamp: {
if (!this.currentCluster) break;
- this.currentCluster.timestamp = reader.readUnsignedInt(size);
+ this.currentCluster.timestamp = readUnsignedInt(slice, size);
}; break;
case EBMLId.SimpleBlock: {
if (!this.currentCluster) break;
- const trackNumber = reader.readVarInt();
+ const trackNumber = readVarInt(slice);
if (trackNumber === null) break;
- const relativeTimestamp = reader.readS16();
+ const relativeTimestamp = readI16Be(slice);
- const flags = reader.readU8();
+ const flags = readU8(slice);
const isKeyFrame = !!(flags & 0x80);
const lacing = (flags >> 1) & 0x3 as BlockLacing; // If the block is laced, we'll expand it later
@@ -1204,7 +1224,7 @@ export class MatroskaDemuxer extends Demuxer {
duration: 0, // Will set later
isKeyFrame,
referencedTimestamps: [],
- data: reader.readBytes(size - (reader.pos - dataStartPos)),
+ data: readBytes(slice, size - (slice.filePos - dataStartPos)),
lacing,
});
}; break;
@@ -1212,7 +1232,7 @@ export class MatroskaDemuxer extends Demuxer {
case EBMLId.BlockGroup: {
if (!this.currentCluster) break;
- this.readContiguousElements(reader, size);
+ this.readContiguousElements(slice.slice(dataStartPos, size));
if (this.currentBlock) {
for (let i = 0; i < this.currentBlock.referencedTimestamps.length; i++) {
@@ -1226,12 +1246,12 @@ export class MatroskaDemuxer extends Demuxer {
case EBMLId.Block: {
if (!this.currentCluster) break;
- const trackNumber = reader.readVarInt();
+ const trackNumber = readVarInt(slice);
if (trackNumber === null) break;
- const relativeTimestamp = reader.readS16();
+ const relativeTimestamp = readI16Be(slice);
- const flags = reader.readU8();
+ const flags = readU8(slice);
const lacing = (flags >> 1) & 0x3 as BlockLacing; // If the block is laced, we'll expand it later
const trackData = this.getTrackDataInCluster(this.currentCluster, trackNumber);
@@ -1240,7 +1260,7 @@ export class MatroskaDemuxer extends Demuxer {
duration: 0, // Will set later
isKeyFrame: true,
referencedTimestamps: [],
- data: reader.readBytes(size - (reader.pos - dataStartPos)),
+ data: readBytes(slice, size - (slice.filePos - dataStartPos)),
lacing,
};
trackData.blocks.push(this.currentBlock);
@@ -1249,7 +1269,7 @@ export class MatroskaDemuxer extends Demuxer {
case EBMLId.BlockDuration: {
if (!this.currentBlock) break;
- this.currentBlock.duration = reader.readUnsignedInt(size);
+ this.currentBlock.duration = readUnsignedInt(slice, size);
}; break;
case EBMLId.ReferenceBlock: {
@@ -1257,14 +1277,14 @@ export class MatroskaDemuxer extends Demuxer {
this.currentBlock.isKeyFrame = false;
- const relativeTimestamp = reader.readSignedInt(size);
+ const relativeTimestamp = readSignedInt(slice, size);
// We'll offset this by the block's timestamp later
this.currentBlock.referencedTimestamps.push(relativeTimestamp);
}; break;
}
- reader.pos = dataStartPos + size;
+ slice.filePos = dataStartPos + size;
return true;
}
}
@@ -1618,10 +1638,6 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
return this.fetchPacketInCluster(cluster, blockIndex, options);
}
- // We use the metadata reader to find the cluster, but the cluster reader to load the cluster
- const metadataReader = demuxer.metadataReader;
- const clusterReader = demuxer.clusterReader;
-
let prevCluster: Cluster | null = null;
let bestClusterIndex = clusterIndex;
let bestBlockIndex = blockIndex;
@@ -1635,24 +1651,25 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
);
const cuePoint = cuePointIndex !== -1 ? this.internalTrack.cuePoints[cuePointIndex]! : null;
+ let currentPos: number;
let nextClusterIsFirstCluster = false;
if (clusterIndex === -1) {
- metadataReader.pos = cuePoint?.clusterPosition ?? segment.clusterSeekStartPos;
- nextClusterIsFirstCluster = metadataReader.pos === segment.clusterSeekStartPos;
+ currentPos = cuePoint?.clusterPosition ?? segment.clusterSeekStartPos;
+ nextClusterIsFirstCluster = currentPos === segment.clusterSeekStartPos;
} else {
const cluster = this.internalTrack.clusters[clusterIndex]!;
if (!cuePoint || cluster.elementStartPos >= cuePoint.clusterPosition) {
- metadataReader.pos = cluster.elementEndPos;
+ currentPos = cluster.elementEndPos;
prevCluster = cluster;
} else {
// Use the lookup entry
- metadataReader.pos = cuePoint.clusterPosition;
+ currentPos = cuePoint.clusterPosition;
}
}
- while (metadataReader.pos <= segment.elementEndPos - MIN_HEADER_SIZE) {
+ while (currentPos <= segment.elementEndPos - MIN_HEADER_SIZE) {
if (prevCluster) {
const trackData = prevCluster.trackData.get(this.internalTrack.id);
if (trackData && trackData.startTimestamp > latestTimestamp) {
@@ -1662,30 +1679,32 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
if (prevCluster.nextCluster) {
// Skip ahead quickly without needing to read the file again
- metadataReader.pos = prevCluster.nextCluster.elementEndPos;
+ currentPos = prevCluster.nextCluster.elementEndPos;
prevCluster = prevCluster.nextCluster;
continue;
}
}
// Load the header
- await metadataReader.reader.loadRange(metadataReader.pos, metadataReader.pos + MAX_HEADER_SIZE);
- const elementStartPos = metadataReader.pos;
- const elementHeader = metadataReader.readElementHeader();
+ let slice = demuxer.reader.requestSliceRange(currentPos, MIN_HEADER_SIZE, MAX_HEADER_SIZE);
+ if (slice instanceof Promise) slice = await slice;
+ if (!slice) break;
+
+ const elementStartPos = currentPos;
+ const elementHeader = readElementHeader(slice);
if (!elementHeader || !LEVEL_1_EBML_IDS.includes(elementHeader.id)) {
// There's an element here that shouldn't be here (or Void). Might be garbage. In this case, let's
// try and resync to the next valid element.
-
- metadataReader.pos = elementStartPos;
-
- const nextPos = await metadataReader.resync(
+ const nextPos = await resync(
+ demuxer.reader,
+ elementStartPos,
LEVEL_1_EBML_IDS,
- Math.min(segment.elementEndPos, metadataReader.pos + MAX_RESYNC_LENGTH),
+ Math.min(segment.elementEndPos, elementStartPos + MAX_RESYNC_LENGTH),
);
if (nextPos) {
- metadataReader.pos = nextPos;
+ currentPos = nextPos;
continue;
} else {
break; // Resync failed
@@ -1694,7 +1713,7 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
const id = elementHeader.id;
let size = elementHeader.size;
- const dataStartPos = metadataReader.pos;
+ const dataStartPos = slice.filePos;
if (id === EBMLId.Cluster) {
const index = binarySearchExact(segment.clusters, elementStartPos, x => x.elementStartPos);
@@ -1702,8 +1721,7 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
let cluster: Cluster;
if (index === -1) {
// This is the first time we've seen this cluster
- metadataReader.pos = elementStartPos;
- cluster = await demuxer.readCluster(segment);
+ cluster = await demuxer.readCluster(elementStartPos, segment);
} else {
// We already know this cluster
cluster = segment.clusters[index]!;
@@ -1739,8 +1757,9 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
size = prevCluster.elementEndPos - dataStartPos;
} else {
// Search for the next element at level 0 or 1
- clusterReader.pos = dataStartPos;
- const nextElementPos = await clusterReader.searchForNextElementId(
+ const nextElementPos = await searchForNextElementId(
+ demuxer.reader,
+ dataStartPos,
LEVEL_0_AND_1_EBML_IDS,
segment.elementEndPos,
);
@@ -1756,8 +1775,12 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
// Check the next element. If it's a new segment, we know this segment ends here. The new
// segment is just ignored, since we're likely in a livestreamed file and thus only care about
// the first segment.
- clusterReader.pos = endPos;
- const elementId = clusterReader.readElementId();
+
+ let slice = demuxer.reader.requestSliceRange(endPos, MIN_HEADER_SIZE, MAX_HEADER_SIZE);
+ if (slice instanceof Promise) slice = await slice;
+ if (!slice) break;
+
+ const elementId = readElementId(slice);
if (elementId === EBMLId.Segment) {
segment.elementEndPos = endPos;
break;
@@ -1765,7 +1788,7 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
}
}
- metadataReader.pos = dataStartPos + size;
+ currentPos = dataStartPos + size;
}
const bestCluster = bestClusterIndex !== -1 ? this.internalTrack.clusters[bestClusterIndex]! : null;
diff --git a/src/misc.ts b/src/misc.ts
index 94fdd65..7d2b1c8 100644
--- a/src/misc.ts
+++ b/src/misc.ts
@@ -633,3 +633,7 @@ export const isSafari = () => {
* @public
*/
export type MaybePromise = T | Promise;
+
+export const closedIntervalsOverlap = (startA: number, endA: number, startB: number, endB: number) => {
+ return startA <= endB && startB <= endA;
+};
diff --git a/src/mp3/mp3-demuxer.ts b/src/mp3/mp3-demuxer.ts
index 012b91c..16537cc 100644
--- a/src/mp3/mp3-demuxer.ts
+++ b/src/mp3/mp3-demuxer.ts
@@ -14,7 +14,8 @@ import { PacketRetrievalOptions } from '../media-sink';
import { assert, AsyncMutex, binarySearchExact, binarySearchLessOrEqual, UNDETERMINED_LANGUAGE } from '../misc';
import { EncodedPacket, PLACEHOLDER_DATA } from '../packet';
import { FrameHeader, getXingOffset, INFO, XING } from '../../shared/mp3-misc';
-import { Mp3Reader } from './mp3-reader';
+import { readId3, readNextFrameHeader } from './mp3-reader';
+import { readBytes, Reader, readU32Be } from '../reader';
type Sample = {
timestamp: number;
@@ -24,7 +25,7 @@ type Sample = {
};
export class Mp3Demuxer extends Demuxer {
- reader: Mp3Reader;
+ reader: Reader;
metadataPromise: Promise | null = null;
firstFrameHeader: FrameHeader | null = null;
@@ -33,24 +34,21 @@ export class Mp3Demuxer extends Demuxer {
tracks: InputAudioTrack[] = [];
readingMutex = new AsyncMutex();
+ lastSampleLoaded = false;
lastLoadedPos = 0;
- fileSize = 0;
nextTimestampInSamples = 0;
constructor(input: Input) {
super(input);
- this.reader = new Mp3Reader(input._mainReader);
+ this.reader = input._reader;
}
async readMetadata() {
return this.metadataPromise ??= (async () => {
- this.fileSize = await this.input.source.getSize();
- this.reader.fileSize = this.fileSize;
-
// Keep loading until we find the first frame header
- while (!this.firstFrameHeader && this.lastLoadedPos < this.fileSize) {
- await this.loadNextChunk();
+ while (!this.firstFrameHeader && this.lastLoadedPos < this.reader.fileSize) {
+ await this.advanceReader();
}
// There has to be a frame if this demuxer got selected
@@ -60,72 +58,65 @@ export class Mp3Demuxer extends Demuxer {
})();
}
- /** Loads the next 0.5 MiB of frames. */
- async loadNextChunk() {
- assert(this.lastLoadedPos < this.fileSize);
+ async advanceReader() {
+ if (this.lastLoadedPos === 0) {
+ let slice = this.reader.requestSlice(0, 10);
+ if (slice instanceof Promise) slice = await slice;
- const chunkSize = 0.5 * 1024 * 1024; // 0.5 MiB
- const endPos = Math.min(this.lastLoadedPos + chunkSize, this.fileSize);
- await this.reader.reader.loadRange(this.lastLoadedPos, endPos);
+ if (!slice) {
+ this.lastSampleLoaded = true;
+ return;
+ }
- this.lastLoadedPos = endPos;
- assert(this.lastLoadedPos <= this.fileSize);
-
- if (this.reader.pos === 0) {
// First time, let's see if there's an ID3 tag
- const id3Tag = this.reader.readId3();
+ const id3Tag = readId3(slice);
if (id3Tag) {
- this.reader.pos += id3Tag.size;
+ this.lastLoadedPos += 10 + id3Tag.size;
}
}
- this.parseFramesFromLoadedData();
- }
+ const startPos = this.lastLoadedPos;
- private parseFramesFromLoadedData() {
- while (true) {
- const startPos = this.reader.pos;
- const header = this.reader.readNextFrameHeader();
- if (!header) {
- break;
- }
-
- // Check if the entire frame fits in the loaded data
- if (header.startPos + header.totalSize > this.lastLoadedPos) {
- // Frame doesn't fit, reset positions and stop
- this.reader.pos = startPos;
- this.lastLoadedPos = startPos; // Snap this back too so that the next read is frame-aligned
-
- break;
- }
-
- const xingOffset = getXingOffset(header.mpegVersionId, header.channel);
- this.reader.pos = header.startPos + xingOffset;
- const word = this.reader.readU32();
- const isXing = word === XING || word === INFO;
-
- this.reader.pos = header.startPos + header.totalSize - 1; // -1 in case the frame is 1 byte too short
-
- if (isXing) {
- // There's no actual audio data in this frame, so let's skip it
- continue;
- }
-
- if (!this.firstFrameHeader) {
- this.firstFrameHeader = header;
- }
-
- const sampleDuration = header.audioSamplesInFrame / header.sampleRate;
- const sample: Sample = {
- timestamp: this.nextTimestampInSamples / header.sampleRate,
- duration: sampleDuration,
- dataStart: header.startPos,
- dataSize: header.totalSize,
- };
-
- this.loadedSamples.push(sample);
- this.nextTimestampInSamples += header.audioSamplesInFrame;
+ const result = await readNextFrameHeader(this.reader, startPos, this.reader.fileSize);
+ if (!result) {
+ this.lastSampleLoaded = true;
+ return;
}
+
+ const header = result.header;
+
+ this.lastLoadedPos = result.startPos + header.totalSize - 1; // -1 in case the frame is 1 byte too short
+
+ const xingOffset = getXingOffset(header.mpegVersionId, header.channel);
+
+ let slice = this.reader.requestSlice(startPos + xingOffset, 4);
+ if (slice instanceof Promise) slice = await slice;
+ assert(slice);
+
+ const word = readU32Be(slice);
+ const isXing = word === XING || word === INFO;
+
+ if (isXing) {
+ // There's no actual audio data in this frame, so let's skip it
+ return;
+ }
+
+ if (!this.firstFrameHeader) {
+ this.firstFrameHeader = header;
+ }
+
+ const sampleDuration = header.audioSamplesInFrame / header.sampleRate;
+ const sample: Sample = {
+ timestamp: this.nextTimestampInSamples / header.sampleRate,
+ duration: sampleDuration,
+ dataStart: startPos,
+ dataSize: header.totalSize,
+ };
+
+ this.loadedSamples.push(sample);
+ this.nextTimestampInSamples += header.audioSamplesInFrame;
+
+ return;
}
async getMimeType() {
@@ -204,7 +195,7 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking {
};
}
- getPacketAtIndex(sampleIndex: number, options: PacketRetrievalOptions) {
+ async getPacketAtIndex(sampleIndex: number, options: PacketRetrievalOptions) {
if (sampleIndex === -1) {
return null;
}
@@ -218,8 +209,11 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking {
if (options.metadataOnly) {
data = PLACEHOLDER_DATA;
} else {
- this.demuxer.reader.pos = rawSample.dataStart;
- data = this.demuxer.reader.readBytes(rawSample.dataSize);
+ let slice = this.demuxer.reader.requestSlice(rawSample.dataStart, rawSample.dataSize);
+ if (slice instanceof Promise) slice = await slice;
+ assert(slice);
+
+ data = readBytes(slice, rawSample.dataSize);
}
return new EncodedPacket(
@@ -232,7 +226,7 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking {
);
}
- async getFirstPacket(options: PacketRetrievalOptions) {
+ getFirstPacket(options: PacketRetrievalOptions) {
return this.getPacketAtIndex(0, options);
}
@@ -253,9 +247,9 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking {
// Ensure the next sample exists
while (
nextIndex >= this.demuxer.loadedSamples.length
- && this.demuxer.lastLoadedPos < this.demuxer.fileSize
+ && !this.demuxer.lastSampleLoaded
) {
- await this.demuxer.loadNextChunk();
+ await this.demuxer.advanceReader();
}
return this.getPacketAtIndex(nextIndex, options);
@@ -266,6 +260,7 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking {
async getPacket(timestamp: number, options: PacketRetrievalOptions) {
const release = await this.demuxer.readingMutex.acquire();
+
try {
while (true) {
const index = binarySearchLessOrEqual(
@@ -279,7 +274,7 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking {
return null;
}
- if (this.demuxer.lastLoadedPos === this.demuxer.fileSize) {
+ if (this.demuxer.lastSampleLoaded) {
// All data is loaded, return what we found
return this.getPacketAtIndex(index, options);
}
@@ -290,7 +285,7 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking {
}
// Otherwise, keep loading data
- await this.demuxer.loadNextChunk();
+ await this.demuxer.advanceReader();
}
} finally {
release();
diff --git a/src/mp3/mp3-muxer.ts b/src/mp3/mp3-muxer.ts
index 051bcf9..509c005 100644
--- a/src/mp3/mp3-muxer.ts
+++ b/src/mp3/mp3-muxer.ts
@@ -59,7 +59,7 @@ export class Mp3Muxer extends Muxer {
}
const word = view.getUint32(0, false);
- const header = readFrameHeader(word, { pos: 0, fileSize: null });
+ const header = readFrameHeader(word, null).header;
if (!header) {
throw new Error('Invalid MP3 header in sample.');
}
diff --git a/src/mp3/mp3-reader.ts b/src/mp3/mp3-reader.ts
index 9a9627d..a28159c 100644
--- a/src/mp3/mp3-reader.ts
+++ b/src/mp3/mp3-reader.ts
@@ -6,78 +6,45 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
-import { assert } from '../misc';
-import { Reader } from '../reader';
import { FRAME_HEADER_SIZE, FrameHeader, readFrameHeader } from '../../shared/mp3-misc';
+import { FileSlice, readAscii, Reader, readU32Be } from '../reader';
-export class Mp3Reader {
- pos = 0;
- fileSize: number | null = null;
-
- constructor(public reader: Reader) {}
-
- readBytes(length: number) {
- const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + length);
- this.pos += length;
-
- return new Uint8Array(view.buffer, offset, length);
- }
-
- readU16() {
- const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + 2);
- this.pos += 2;
-
- return view.getUint16(offset, false);
- }
-
- readU32() {
- const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + 4);
- this.pos += 4;
-
- return view.getUint32(offset, false);
- }
-
- readAscii(length: number) {
- const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + length);
- this.pos += length;
-
- let str = '';
- for (let i = 0; i < length; i++) {
- str += String.fromCharCode(view.getUint8(offset + i));
- }
- return str;
- }
-
- readId3() {
- const tag = this.readAscii(3);
- if (tag !== 'ID3') {
- this.pos -= 3;
- return null;
- }
-
- this.pos += 3;
-
- const size = decodeSynchsafe(this.readU32());
- return { size };
- }
-
- readNextFrameHeader(until?: number): FrameHeader | null {
- assert(this.fileSize);
- until ??= this.fileSize;
-
- while (this.pos <= until - FRAME_HEADER_SIZE) {
- const word = this.readU32();
- this.pos -= 4;
-
- const header = readFrameHeader(word, this);
- if (header) {
- return header;
- }
- }
-
+export const readId3 = (slice: FileSlice) => {
+ const tag = readAscii(slice, 3);
+ if (tag !== 'ID3') {
+ slice.skip(-3);
return null;
}
-}
+
+ slice.skip(3);
+
+ const size = decodeSynchsafe(readU32Be(slice));
+ return { size };
+};
+
+export const readNextFrameHeader = async (reader: Reader, startPos: number, until: number): Promise<{
+ header: FrameHeader;
+ startPos: number;
+} | null> => {
+ let currentPos = startPos;
+
+ while (currentPos < until) {
+ let slice = reader.requestSlice(currentPos, FRAME_HEADER_SIZE);
+ if (slice instanceof Promise) slice = await slice;
+ if (!slice) break;
+
+ const word = readU32Be(slice);
+
+ const result = readFrameHeader(word, reader.fileSize - currentPos);
+ if (result.header) {
+ return { header: result.header, startPos: currentPos };
+ }
+
+ currentPos += result.bytesAdvanced;
+ }
+
+ return null;
+};
export const decodeSynchsafe = (synchsafed: number) => {
let mask = 0x7f000000;
diff --git a/src/ogg/ogg-demuxer.ts b/src/ogg/ogg-demuxer.ts
index 39a5ed3..17b70ed 100644
--- a/src/ogg/ogg-demuxer.ts
+++ b/src/ogg/ogg-demuxer.ts
@@ -12,11 +12,18 @@ import { Demuxer } from '../demuxer';
import { Input } from '../input';
import { InputAudioTrack, InputAudioTrackBacking } from '../input-track';
import { PacketRetrievalOptions } from '../media-sink';
-import { assert, AsyncMutex, findLast, roundToPrecision, toDataView, UNDETERMINED_LANGUAGE } from '../misc';
+import { assert, findLast, roundToPrecision, toDataView, UNDETERMINED_LANGUAGE } from '../misc';
import { EncodedPacket, PLACEHOLDER_DATA } from '../packet';
-import { Reader } from '../reader';
+import { readBytes, Reader } from '../reader';
import { buildOggMimeType, computeOggPageCrc, extractSampleMetadata, OggCodecInfo } from './ogg-misc';
-import { MAX_PAGE_HEADER_SIZE, MAX_PAGE_SIZE, MIN_PAGE_HEADER_SIZE, OggReader, Page } from './ogg-reader';
+import {
+ findNextPageHeader,
+ MAX_PAGE_HEADER_SIZE,
+ MAX_PAGE_SIZE,
+ MIN_PAGE_HEADER_SIZE,
+ Page,
+ readPageHeader,
+} from './ogg-reader';
type LogicalBitstream = {
serialNumber: number;
@@ -36,36 +43,27 @@ type Packet = {
};
export class OggDemuxer extends Demuxer {
- reader: OggReader;
- /**
- * Lots of reading operations require multiple async reads and thus need to be mutually exclusive to avoid
- * conflicts in reader position.
- */
- readingMutex = new AsyncMutex();
+ reader: Reader;
metadataPromise: Promise | null = null;
- fileSize: number | null = null;
bitstreams: LogicalBitstream[] = [];
tracks: InputAudioTrack[] = [];
constructor(input: Input) {
super(input);
- // We don't need a persistent metadata reader as we read all metadata once at the start and then never again
- this.reader = new OggReader(new Reader(input.source, 64 * 2 ** 20));
+ this.reader = input._reader;
}
async readMetadata() {
return this.metadataPromise ??= (async () => {
- this.fileSize = await this.input.source.getSize();
+ let currentPos = 0;
+ while (currentPos <= this.reader.fileSize - MIN_PAGE_HEADER_SIZE) {
+ let slice = this.reader.requestSliceRange(currentPos, MIN_PAGE_HEADER_SIZE, MAX_PAGE_HEADER_SIZE);
+ if (slice instanceof Promise) slice = await slice;
+ if (!slice) break;
- while (this.reader.pos < this.fileSize - MIN_PAGE_HEADER_SIZE) {
- await this.reader.reader.loadRange(
- this.reader.pos,
- this.reader.pos + MAX_PAGE_HEADER_SIZE,
- );
-
- const page = this.reader.readPageHeader();
+ const page = readPageHeader(slice);
if (!page) {
break;
}
@@ -91,11 +89,11 @@ export class OggDemuxer extends Demuxer {
lastMetadataPacket: null,
});
- this.reader.pos = page.headerStartPos + page.totalSize;
+ currentPos = page.headerStartPos + page.totalSize;
}
for (const bitstream of this.bitstreams) {
- const firstPacket = await this.readPacket(this.reader, bitstream.bosPage, 0);
+ const firstPacket = await this.readPacket(bitstream.bosPage, 0);
if (!firstPacket) {
continue;
}
@@ -135,30 +133,22 @@ export class OggDemuxer extends Demuxer {
}
async readVorbisMetadata(firstPacket: Packet, bitstream: LogicalBitstream) {
- let nextPacketPosition = await this.findNextPacketStart(this.reader, firstPacket);
+ let nextPacketPosition = await this.findNextPacketStart(firstPacket);
if (!nextPacketPosition) {
return;
}
- const secondPacket = await this.readPacket(
- this.reader,
- nextPacketPosition.startPage,
- nextPacketPosition.startSegmentIndex,
- );
+ const secondPacket = await this.readPacket(nextPacketPosition.startPage, nextPacketPosition.startSegmentIndex);
if (!secondPacket) {
return;
}
- nextPacketPosition = await this.findNextPacketStart(this.reader, secondPacket);
+ nextPacketPosition = await this.findNextPacketStart(secondPacket);
if (!nextPacketPosition) {
return;
}
- const thirdPacket = await this.readPacket(
- this.reader,
- nextPacketPosition.startPage,
- nextPacketPosition.startSegmentIndex,
- );
+ const thirdPacket = await this.readPacket(nextPacketPosition.startPage, nextPacketPosition.startSegmentIndex);
if (!thirdPacket) {
return;
}
@@ -224,13 +214,12 @@ export class OggDemuxer extends Demuxer {
// From https://datatracker.ietf.org/doc/html/rfc7845#section-5:
// "An Ogg Opus logical stream contains exactly two mandatory header packets: an identification header and a
// comment header."
- const nextPacketPosition = await this.findNextPacketStart(this.reader, firstPacket);
+ const nextPacketPosition = await this.findNextPacketStart(firstPacket);
if (!nextPacketPosition) {
return;
}
const secondPacket = await this.readPacket(
- this.reader,
nextPacketPosition.startPage,
nextPacketPosition.startSegmentIndex,
);
@@ -253,9 +242,8 @@ export class OggDemuxer extends Demuxer {
};
}
- async readPacket(reader: OggReader, startPage: Page, startSegmentIndex: number): Promise {
+ async readPacket(startPage: Page, startSegmentIndex: number): Promise {
assert(startSegmentIndex < startPage.lacingValues.length);
- assert(this.fileSize);
let startDataOffset = 0;
for (let i = 0; i < startSegmentIndex; i++) {
@@ -271,12 +259,10 @@ export class OggDemuxer extends Demuxer {
outer:
while (true) {
// Load the entire page data
- await reader.reader.loadRange(
- currentPage.dataStartPos,
- currentPage.dataStartPos + currentPage.dataSize,
- );
- reader.pos = currentPage.dataStartPos;
- const pageData = reader.readBytes(currentPage.dataSize);
+ let pageSlice = this.reader.requestSlice(currentPage.dataStartPos, currentPage.dataSize);
+ if (pageSlice instanceof Promise) pageSlice = await pageSlice;
+ assert(pageSlice);
+ const pageData = readBytes(pageSlice, currentPage.dataSize);
while (true) {
if (currentSegmentIndex === currentPage.lacingValues.length) {
@@ -296,14 +282,19 @@ export class OggDemuxer extends Demuxer {
}
// The packet extends to the next page; let's find it
+ let currentPos = currentPage.headerStartPos + currentPage.totalSize;
while (true) {
- reader.pos = currentPage.headerStartPos + currentPage.totalSize;
- if (reader.pos >= this.fileSize - MIN_PAGE_HEADER_SIZE) {
+ if (currentPos > this.reader.fileSize - MIN_PAGE_HEADER_SIZE) {
return null;
}
- await reader.reader.loadRange(reader.pos, reader.pos + MAX_PAGE_HEADER_SIZE);
- const nextPage = reader.readPageHeader();
+ let headerSlice = this.reader.requestSliceRange(currentPos, MIN_PAGE_HEADER_SIZE, MAX_PAGE_HEADER_SIZE);
+ if (headerSlice instanceof Promise) headerSlice = await headerSlice;
+ if (!headerSlice) {
+ return null;
+ }
+
+ const nextPage = readPageHeader(headerSlice);
if (!nextPage) {
return null;
}
@@ -312,6 +303,7 @@ export class OggDemuxer extends Demuxer {
if (currentPage.serialNumber === startPage.serialNumber) {
break;
}
+ currentPos = currentPage.headerStartPos + currentPage.totalSize;
}
startDataOffset = 0;
@@ -336,9 +328,7 @@ export class OggDemuxer extends Demuxer {
};
}
- async findNextPacketStart(reader: OggReader, lastPacket: Packet) {
- assert(this.fileSize !== null);
-
+ async findNextPacketStart(lastPacket: Packet) {
// If there's another segment in the same page, return it
if (lastPacket.endSegmentIndex < lastPacket.endPage.lacingValues.length - 1) {
return { startPage: lastPacket.endPage, startSegmentIndex: lastPacket.endSegmentIndex + 1 };
@@ -351,14 +341,19 @@ export class OggDemuxer extends Demuxer {
}
// Otherwise, search for the next page belonging to the same bitstream
- reader.pos = lastPacket.endPage.headerStartPos + lastPacket.endPage.totalSize;
+ let currentPos = lastPacket.endPage.headerStartPos + lastPacket.endPage.totalSize;
while (true) {
- if (reader.pos >= this.fileSize - MIN_PAGE_HEADER_SIZE) {
+ if (currentPos >= this.reader.fileSize - MIN_PAGE_HEADER_SIZE) {
return null;
}
- await reader.reader.loadRange(reader.pos, reader.pos + MAX_PAGE_HEADER_SIZE);
- const nextPage = reader.readPageHeader();
+ let slice = this.reader.requestSliceRange(currentPos, MIN_PAGE_HEADER_SIZE, MAX_PAGE_HEADER_SIZE);
+ if (slice instanceof Promise) slice = await slice;
+ if (!slice) {
+ return null;
+ }
+
+ const nextPage = readPageHeader(slice);
if (!nextPage) {
return null;
}
@@ -367,7 +362,7 @@ export class OggDemuxer extends Demuxer {
return { startPage: nextPage, startSegmentIndex: 0 };
}
- reader.pos = nextPage.headerStartPos + nextPage.totalSize;
+ currentPos = nextPage.headerStartPos + nextPage.totalSize;
}
}
@@ -508,409 +503,386 @@ class OggAudioTrackBacking implements InputAudioTrackBacking {
return encodedPacket;
}
- async getFirstPacket(options: PacketRetrievalOptions, exclusive = true) {
- const release = exclusive ? await this.demuxer.readingMutex.acquire() : null;
-
- try {
- assert(this.bitstream.lastMetadataPacket);
- const packetPosition = await this.demuxer.findNextPacketStart(
- this.demuxer.reader,
- this.bitstream.lastMetadataPacket,
- );
- if (!packetPosition) {
- return null;
- }
-
- let timestampInSamples = 0;
- if (this.bitstream.codecInfo.codec === 'opus') {
- assert(this.bitstream.codecInfo.opusInfo);
- timestampInSamples -= this.bitstream.codecInfo.opusInfo.preSkip;
- }
-
- const packet = await this.demuxer.readPacket(
- this.demuxer.reader,
- packetPosition.startPage,
- packetPosition.startSegmentIndex,
- );
-
- return this.createEncodedPacketFromOggPacket(
- packet,
- {
- timestampInSamples,
- vorbisLastBlocksize: null,
- },
- options,
- );
- } finally {
- release?.();
+ async getFirstPacket(options: PacketRetrievalOptions) {
+ assert(this.bitstream.lastMetadataPacket);
+ const packetPosition = await this.demuxer.findNextPacketStart(this.bitstream.lastMetadataPacket);
+ if (!packetPosition) {
+ return null;
}
+
+ let timestampInSamples = 0;
+ if (this.bitstream.codecInfo.codec === 'opus') {
+ assert(this.bitstream.codecInfo.opusInfo);
+ timestampInSamples -= this.bitstream.codecInfo.opusInfo.preSkip;
+ }
+
+ const packet = await this.demuxer.readPacket(packetPosition.startPage, packetPosition.startSegmentIndex);
+
+ return this.createEncodedPacketFromOggPacket(
+ packet,
+ {
+ timestampInSamples,
+ vorbisLastBlocksize: null,
+ },
+ options,
+ );
}
async getNextPacket(prevPacket: EncodedPacket, options: PacketRetrievalOptions) {
- const release = await this.demuxer.readingMutex.acquire();
-
- try {
- const prevMetadata = this.encodedPacketToMetadata.get(prevPacket);
- if (!prevMetadata) {
- throw new Error('Packet was not created from this track.');
- }
-
- const packetPosition = await this.demuxer.findNextPacketStart(this.demuxer.reader, prevMetadata.packet);
- if (!packetPosition) {
- return null;
- }
-
- const timestampInSamples = prevMetadata.timestampInSamples + prevMetadata.durationInSamples;
-
- const packet = await this.demuxer.readPacket(
- this.demuxer.reader,
- packetPosition.startPage,
- packetPosition.startSegmentIndex,
- );
-
- return this.createEncodedPacketFromOggPacket(
- packet,
- {
- timestampInSamples,
- vorbisLastBlocksize: prevMetadata.vorbisBlockSize,
- },
- options,
- );
- } finally {
- release();
+ const prevMetadata = this.encodedPacketToMetadata.get(prevPacket);
+ if (!prevMetadata) {
+ throw new Error('Packet was not created from this track.');
}
+
+ const packetPosition = await this.demuxer.findNextPacketStart(prevMetadata.packet);
+ if (!packetPosition) {
+ return null;
+ }
+
+ const timestampInSamples = prevMetadata.timestampInSamples + prevMetadata.durationInSamples;
+
+ const packet = await this.demuxer.readPacket(
+ packetPosition.startPage,
+ packetPosition.startSegmentIndex,
+ );
+
+ return this.createEncodedPacketFromOggPacket(
+ packet,
+ {
+ timestampInSamples,
+ vorbisLastBlocksize: prevMetadata.vorbisBlockSize,
+ },
+ options,
+ );
}
async getPacket(timestamp: number, options: PacketRetrievalOptions) {
- const release = await this.demuxer.readingMutex.acquire();
+ const timestampInSamples = roundToPrecision(timestamp * this.internalSampleRate, 14);
+ if (timestampInSamples === 0) {
+ // Fast path for timestamp 0 - avoids binary search when playing back from the start
+ return this.getFirstPacket(options);
+ }
+ if (timestampInSamples < 0) {
+ // There's nothing here
+ return null;
+ }
- try {
- assert(this.demuxer.fileSize !== null);
+ assert(this.bitstream.lastMetadataPacket);
+ const startPosition = await this.demuxer.findNextPacketStart(this.bitstream.lastMetadataPacket);
+ if (!startPosition) {
+ return null;
+ }
- const timestampInSamples = roundToPrecision(timestamp * this.internalSampleRate, 14);
- if (timestampInSamples === 0) {
- // Fast path for timestamp 0 - avoids binary search when playing back from the start
- return this.getFirstPacket(options, false);
- }
- if (timestampInSamples < 0) {
- // There's nothing here
- return null;
- }
+ let lowPage = startPosition.startPage;
+ let high = this.demuxer.reader.fileSize;
- const reader = this.demuxer.reader;
+ const lowPages: Page[] = [lowPage];
- assert(this.bitstream.lastMetadataPacket);
- const startPosition = await this.demuxer.findNextPacketStart(
- reader,
- this.bitstream.lastMetadataPacket,
- );
- if (!startPosition) {
- return null;
- }
+ // First, let's perform a binary serach (bisection search) on the file to find the approximate page where
+ // we'll find the packet. We want to find a page whose end packet position is less than or equal to the
+ // packet position we're searching for.
- let lowPage = startPosition.startPage;
- let high = this.demuxer.fileSize;
+ // Outer loop: Does the binary serach
+ outer:
+ while (lowPage.headerStartPos + lowPage.totalSize < high) {
+ const low = lowPage.headerStartPos;
+ const mid = Math.floor((low + high) / 2);
- const lowPages: Page[] = [lowPage];
+ let searchStartPos = mid;
- // First, let's perform a binary serach (bisection search) on the file to find the approximate page where
- // we'll find the packet. We want to find a page whose end packet position is less than or equal to the
- // packet position we're searching for.
+ // Inner loop: Does a linear forward scan if the page cannot be found immediately
+ while (true) {
+ const until = Math.min(
+ searchStartPos + MAX_PAGE_SIZE,
+ high - MIN_PAGE_HEADER_SIZE,
+ );
- // Outer loop: Does the binary serach
- outer:
- while (lowPage.headerStartPos + lowPage.totalSize < high) {
- const low = lowPage.headerStartPos;
- const mid = Math.floor((low + high) / 2);
-
- let searchStartPos = mid;
-
- // Inner loop: Does a linear forward scan if the page cannot be found immediately
- while (true) {
- const until = Math.min(
- searchStartPos + MAX_PAGE_SIZE,
- high - MIN_PAGE_HEADER_SIZE,
- );
-
- await reader.reader.loadRange(searchStartPos, until);
-
- reader.pos = searchStartPos;
- const found = reader.findNextPageHeader(until);
-
- if (!found) {
- high = mid + MIN_PAGE_HEADER_SIZE;
- continue outer;
- }
-
- await reader.reader.loadRange(reader.pos, reader.pos + MAX_PAGE_HEADER_SIZE);
- const page = reader.readPageHeader();
- assert(page);
-
- let pageValid = false;
- if (page.serialNumber === this.bitstream.serialNumber) {
- // Serial numbers are basically random numbers, and the chance of finding a fake page with
- // matching serial number is astronomically low, so we can be pretty sure this page is legit.
- pageValid = true;
- } else {
- await reader.reader.loadRange(page.headerStartPos, page.headerStartPos + page.totalSize);
-
- // Validate the page by checking checksum
- reader.pos = page.headerStartPos;
- const bytes = reader.readBytes(page.totalSize);
- const crc = computeOggPageCrc(bytes);
-
- pageValid = crc === page.checksum;
- }
-
- if (!pageValid) {
- // Keep searching for a valid page
- searchStartPos = page.headerStartPos + 4; // 'OggS' is 4 bytes
- continue;
- }
-
- if (pageValid && page.serialNumber !== this.bitstream.serialNumber) {
- // Page is valid but from a different bitstream, so keep searching forward until we find one
- // belonging to the our bitstream
- searchStartPos = page.headerStartPos + page.totalSize;
- continue;
- }
-
- const isContinuationPage = page.granulePosition === -1;
- if (isContinuationPage) {
- // No packet ends on this page - keep looking
- searchStartPos = page.headerStartPos + page.totalSize;
- continue;
- }
-
- // The page is valid and belongs to our bitstream; let's check its granule position to see where we
- // need to take the bisection search.
- if (this.granulePositionToTimestampInSamples(page.granulePosition) > timestampInSamples) {
- high = page.headerStartPos;
- } else {
- lowPage = page;
- lowPages.push(page);
- }
+ let searchSlice = this.demuxer.reader.requestSlice(searchStartPos, until - searchStartPos);
+ if (searchSlice instanceof Promise) searchSlice = await searchSlice;
+ assert(searchSlice);
+ const found = findNextPageHeader(searchSlice, until);
+ if (!found) {
+ high = mid + MIN_PAGE_HEADER_SIZE;
continue outer;
}
+
+ let headerSlice = this.demuxer.reader.requestSliceRange(
+ searchSlice.filePos,
+ MIN_PAGE_HEADER_SIZE,
+ MAX_PAGE_HEADER_SIZE,
+ );
+ if (headerSlice instanceof Promise) headerSlice = await headerSlice;
+ assert(headerSlice);
+
+ const page = readPageHeader(headerSlice);
+ assert(page);
+
+ let pageValid = false;
+ if (page.serialNumber === this.bitstream.serialNumber) {
+ // Serial numbers are basically random numbers, and the chance of finding a fake page with
+ // matching serial number is astronomically low, so we can be pretty sure this page is legit.
+ pageValid = true;
+ } else {
+ let pageSlice = this.demuxer.reader.requestSlice(page.headerStartPos, page.totalSize);
+ if (pageSlice instanceof Promise) pageSlice = await pageSlice;
+ assert(pageSlice);
+
+ // Validate the page by checking checksum
+ const bytes = readBytes(pageSlice, page.totalSize);
+ const crc = computeOggPageCrc(bytes);
+
+ pageValid = crc === page.checksum;
+ }
+
+ if (!pageValid) {
+ // Keep searching for a valid page
+ searchStartPos = page.headerStartPos + 4; // 'OggS' is 4 bytes
+ continue;
+ }
+
+ if (pageValid && page.serialNumber !== this.bitstream.serialNumber) {
+ // Page is valid but from a different bitstream, so keep searching forward until we find one
+ // belonging to the our bitstream
+ searchStartPos = page.headerStartPos + page.totalSize;
+ continue;
+ }
+
+ const isContinuationPage = page.granulePosition === -1;
+ if (isContinuationPage) {
+ // No packet ends on this page - keep looking
+ searchStartPos = page.headerStartPos + page.totalSize;
+ continue;
+ }
+
+ // The page is valid and belongs to our bitstream; let's check its granule position to see where we
+ // need to take the bisection search.
+ if (this.granulePositionToTimestampInSamples(page.granulePosition) > timestampInSamples) {
+ high = page.headerStartPos;
+ } else {
+ lowPage = page;
+ lowPages.push(page);
+ }
+
+ continue outer;
+ }
+ }
+
+ // Now we have the last page with a packet position <= the packet position we're looking for, but there
+ // might be multiple pages with the packet position, in which case we actually need to find the first of
+ // such pages. We'll do this in two steps: First, let's find the latest page we know with an earlier packet
+ // position, and then linear scan ourselves forward until we find the correct page.
+
+ let lowerPage = startPosition.startPage;
+ for (const otherLowPage of lowPages) {
+ if (otherLowPage.granulePosition === lowPage.granulePosition) {
+ break;
}
- // Now we have the last page with a packet position <= the packet position we're looking for, but there
- // might be multiple pages with the packet position, in which case we actually need to find the first of
- // such pages. We'll do this in two steps: First, let's find the latest page we know with an earlier packet
- // position, and then linear scan ourselves forward until we find the correct page.
+ if (!lowerPage || otherLowPage.headerStartPos > lowerPage.headerStartPos) {
+ lowerPage = otherLowPage;
+ }
+ }
- let lowerPage = startPosition.startPage;
- for (const otherLowPage of lowPages) {
- if (otherLowPage.granulePosition === lowPage.granulePosition) {
+ let currentPage = lowerPage;
+ // Keep track of the pages we traversed, we need these later for backwards seeking
+ const previousPages: Page[] = [currentPage];
+
+ while (true) {
+ // This loop must terminate as we'll eventually reach lowPage
+ if (
+ currentPage.serialNumber === this.bitstream.serialNumber
+ && currentPage.granulePosition === lowPage.granulePosition
+ ) {
+ break;
+ }
+
+ const nextPos = currentPage.headerStartPos + currentPage.totalSize;
+ let slice = this.demuxer.reader.requestSliceRange(nextPos, MIN_PAGE_HEADER_SIZE, MAX_PAGE_HEADER_SIZE);
+ if (slice instanceof Promise) slice = await slice;
+ assert(slice);
+
+ const nextPage = readPageHeader(slice);
+ assert(nextPage);
+
+ currentPage = nextPage;
+
+ if (currentPage.serialNumber === this.bitstream.serialNumber) {
+ previousPages.push(currentPage);
+ }
+ }
+
+ assert(currentPage.granulePosition !== -1);
+
+ let currentSegmentIndex: number | null = null;
+ let currentTimestampInSamples: number;
+ let currentTimestampIsCorrect: boolean;
+
+ // These indicate the end position of the packet that the granule position belongs to
+ let endPage = currentPage;
+ let endSegmentIndex = 0;
+
+ if (currentPage.headerStartPos === startPosition.startPage.headerStartPos) {
+ currentTimestampInSamples = this.granulePositionToTimestampInSamples(0);
+ currentTimestampIsCorrect = true;
+ currentSegmentIndex = 0;
+ } else {
+ currentTimestampInSamples = 0; // Placeholder value! We'll refine it once we can
+ currentTimestampIsCorrect = false;
+
+ // Find the segment index of the next packet
+ for (let i = currentPage.lacingValues.length - 1; i >= 0; i--) {
+ const value = currentPage.lacingValues[i]!;
+ if (value < 255) {
+ // We know the last packet ended at i, so the next one starts at i + 1
+ currentSegmentIndex = i + 1;
break;
}
-
- if (!lowerPage || otherLowPage.headerStartPos > lowerPage.headerStartPos) {
- lowerPage = otherLowPage;
- }
}
- let currentPage: Page | null = lowerPage;
- // Keep track of the pages we traversed, we need these later for backwards seeking
- const previousPages: Page[] = [currentPage];
-
- while (true) {
- // This loop must terminate as we'll eventually reach lowPage
- if (
- currentPage.serialNumber === this.bitstream.serialNumber
- && currentPage.granulePosition === lowPage.granulePosition
- ) {
- break;
- }
-
- reader.pos = currentPage.headerStartPos + currentPage.totalSize;
- await reader.reader.loadRange(reader.pos, reader.pos + MAX_PAGE_HEADER_SIZE);
-
- const nextPage = reader.readPageHeader();
- assert(nextPage);
-
- currentPage = nextPage;
-
- if (currentPage.serialNumber === this.bitstream.serialNumber) {
- previousPages.push(currentPage);
- }
+ // This must hold: Since this page has a granule position set, that means there must be a packet that
+ // ends in this page.
+ if (currentSegmentIndex === null) {
+ throw new Error('Invalid page with granule position: no packets end on this page.');
}
- assert(currentPage.granulePosition !== -1);
+ endSegmentIndex = currentSegmentIndex - 1;
+ const pseudopacket: Packet = {
+ data: PLACEHOLDER_DATA,
+ endPage,
+ endSegmentIndex,
+ };
+ const nextPosition = await this.demuxer.findNextPacketStart(pseudopacket);
- let currentSegmentIndex: number | null = null;
- let currentTimestampInSamples: number;
- let currentTimestampIsCorrect: boolean;
+ if (nextPosition) {
+ // Let's rewind a single step (packet) - this previous packet ensures that we'll correctly compute
+ // the duration for the packet we're looking for.
+ const endPosition = findPreviousPacketEndPosition(previousPages, currentPage, currentSegmentIndex);
+ assert(endPosition);
- // These indicate the end position of the packet that the granule position belongs to
- let endPage = currentPage;
- let endSegmentIndex = 0;
-
- if (currentPage.headerStartPos === startPosition.startPage.headerStartPos) {
- currentTimestampInSamples = this.granulePositionToTimestampInSamples(0);
- currentTimestampIsCorrect = true;
- currentSegmentIndex = 0;
+ const startPosition = findPacketStartPosition(
+ previousPages, endPosition.page, endPosition.segmentIndex,
+ );
+ if (startPosition) {
+ currentPage = startPosition.page;
+ currentSegmentIndex = startPosition.segmentIndex;
+ }
} else {
- currentTimestampInSamples = 0; // Placeholder value! We'll refine it once we can
- currentTimestampIsCorrect = false;
-
- // Find the segment index of the next packet
- for (let i = currentPage.lacingValues.length - 1; i >= 0; i--) {
- const value = currentPage.lacingValues[i]!;
- if (value < 255) {
- // We know the last packet ended at i, so the next one starts at i + 1
- currentSegmentIndex = i + 1;
+ // There is no next position, which means we're looking for the last packet in the bitstream. The
+ // granule position on the last page tends to be fucky, so let's instead start the search on the
+ // page before that. So let's loop until we find a packet that ends in a previous page.
+ while (true) {
+ const endPosition = findPreviousPacketEndPosition(
+ previousPages, currentPage, currentSegmentIndex,
+ );
+ if (!endPosition) {
break;
}
- }
-
- // This must hold: Since this page has a granule position set, that means there must be a packet that
- // ends in this page.
- if (currentSegmentIndex === null) {
- throw new Error('Invalid page with granule position: no packets end on this page.');
- }
-
- endSegmentIndex = currentSegmentIndex - 1;
- const pseudopacket: Packet = {
- data: PLACEHOLDER_DATA,
- endPage,
- endSegmentIndex,
- };
- const nextPosition = await this.demuxer.findNextPacketStart(reader, pseudopacket);
-
- if (nextPosition) {
- // Let's rewind a single step (packet) - this previous packet ensures that we'll correctly compute
- // the duration for the packet we're looking for.
- const endPosition = findPreviousPacketEndPosition(previousPages, currentPage, currentSegmentIndex);
- assert(endPosition);
const startPosition = findPacketStartPosition(
previousPages, endPosition.page, endPosition.segmentIndex,
);
- if (startPosition) {
- currentPage = startPosition.page;
- currentSegmentIndex = startPosition.segmentIndex;
+ if (!startPosition) {
+ break;
}
- } else {
- // There is no next position, which means we're looking for the last packet in the bitstream. The
- // granule position on the last page tends to be fucky, so let's instead start the search on the
- // page before that. So let's loop until we find a packet that ends in a previous page.
- while (true) {
- const endPosition = findPreviousPacketEndPosition(
- previousPages, currentPage, currentSegmentIndex,
- );
- if (!endPosition) {
- break;
- }
- const startPosition = findPacketStartPosition(
- previousPages, endPosition.page, endPosition.segmentIndex,
- );
- if (!startPosition) {
- break;
- }
+ currentPage = startPosition.page;
+ currentSegmentIndex = startPosition.segmentIndex;
- currentPage = startPosition.page;
- currentSegmentIndex = startPosition.segmentIndex;
-
- if (endPosition.page.headerStartPos !== endPage.headerStartPos) {
- endPage = endPosition.page;
- endSegmentIndex = endPosition.segmentIndex;
- break;
- }
+ if (endPosition.page.headerStartPos !== endPage.headerStartPos) {
+ endPage = endPosition.page;
+ endSegmentIndex = endPosition.segmentIndex;
+ break;
}
}
}
+ }
- let lastEncodedPacket: EncodedPacket | null = null;
- let lastEncodedPacketMetadata: EncodedPacketMetadata | null = null;
+ let lastEncodedPacket: EncodedPacket | null = null;
+ let lastEncodedPacketMetadata: EncodedPacketMetadata | null = null;
- // Alright, now it's time for the final, granular seek: We keep iterating over packets until we've found the
- // one with the correct timestamp - i.e., the last one with a timestamp <= the timestamp we're looking for.
- while (currentPage !== null) {
- assert(currentSegmentIndex !== null);
+ // Alright, now it's time for the final, granular seek: We keep iterating over packets until we've found the
+ // one with the correct timestamp - i.e., the last one with a timestamp <= the timestamp we're looking for.
+ while (currentPage !== null) {
+ assert(currentSegmentIndex !== null);
- const packet = await this.demuxer.readPacket(reader, currentPage, currentSegmentIndex);
- if (!packet) {
- break;
- }
+ const packet = await this.demuxer.readPacket(currentPage, currentSegmentIndex);
+ if (!packet) {
+ break;
+ }
- // We might need to skip the packet if it's a metadata one
- const skipPacket = currentPage.headerStartPos === startPosition.startPage.headerStartPos
- && currentSegmentIndex < startPosition.startSegmentIndex;
+ // We might need to skip the packet if it's a metadata one
+ const skipPacket = currentPage.headerStartPos === startPosition.startPage.headerStartPos
+ && currentSegmentIndex < startPosition.startSegmentIndex;
- if (!skipPacket) {
- let encodedPacket = this.createEncodedPacketFromOggPacket(
+ if (!skipPacket) {
+ let encodedPacket = this.createEncodedPacketFromOggPacket(
+ packet,
+ {
+ timestampInSamples: currentTimestampInSamples,
+ vorbisLastBlocksize: lastEncodedPacketMetadata?.vorbisBlockSize ?? null,
+ },
+ options,
+ );
+ assert(encodedPacket);
+
+ let encodedPacketMetadata = this.encodedPacketToMetadata.get(encodedPacket);
+ assert(encodedPacketMetadata);
+
+ if (
+ !currentTimestampIsCorrect
+ && packet.endPage.headerStartPos === endPage.headerStartPos
+ && packet.endSegmentIndex === endSegmentIndex
+ ) {
+ // We know this packet end timestamp can be derived from the page's granule position
+ currentTimestampInSamples = this.granulePositionToTimestampInSamples(
+ currentPage.granulePosition,
+ );
+ currentTimestampIsCorrect = true;
+
+ // Let's backpatch the packet we just created with the correct timestamp
+ encodedPacket = this.createEncodedPacketFromOggPacket(
packet,
{
- timestampInSamples: currentTimestampInSamples,
+ timestampInSamples: currentTimestampInSamples - encodedPacketMetadata.durationInSamples,
vorbisLastBlocksize: lastEncodedPacketMetadata?.vorbisBlockSize ?? null,
},
options,
);
assert(encodedPacket);
- let encodedPacketMetadata = this.encodedPacketToMetadata.get(encodedPacket);
+ encodedPacketMetadata = this.encodedPacketToMetadata.get(encodedPacket);
assert(encodedPacketMetadata);
-
- if (
- !currentTimestampIsCorrect
- && packet.endPage.headerStartPos === endPage.headerStartPos
- && packet.endSegmentIndex === endSegmentIndex
- ) {
- // We know this packet end timestamp can be derived from the page's granule position
- currentTimestampInSamples = this.granulePositionToTimestampInSamples(
- currentPage.granulePosition,
- );
- currentTimestampIsCorrect = true;
-
- // Let's backpatch the packet we just created with the correct timestamp
- encodedPacket = this.createEncodedPacketFromOggPacket(
- packet,
- {
- timestampInSamples: currentTimestampInSamples - encodedPacketMetadata.durationInSamples,
- vorbisLastBlocksize: lastEncodedPacketMetadata?.vorbisBlockSize ?? null,
- },
- options,
- );
- assert(encodedPacket);
-
- encodedPacketMetadata = this.encodedPacketToMetadata.get(encodedPacket);
- assert(encodedPacketMetadata);
- } else {
- currentTimestampInSamples += encodedPacketMetadata.durationInSamples;
- }
-
- lastEncodedPacket = encodedPacket;
- lastEncodedPacketMetadata = encodedPacketMetadata;
-
- if (
- currentTimestampIsCorrect
- && (
- // Next timestamp will be too late
- Math.max(currentTimestampInSamples, 0) > timestampInSamples
- // This timestamp already matches
- || Math.max(encodedPacketMetadata.timestampInSamples, 0) === timestampInSamples
- )
- ) {
- break;
- }
+ } else {
+ currentTimestampInSamples += encodedPacketMetadata.durationInSamples;
}
- const nextPosition = await this.demuxer.findNextPacketStart(reader, packet);
- if (!nextPosition) {
+ lastEncodedPacket = encodedPacket;
+ lastEncodedPacketMetadata = encodedPacketMetadata;
+
+ if (
+ currentTimestampIsCorrect
+ && (
+ // Next timestamp will be too late
+ Math.max(currentTimestampInSamples, 0) > timestampInSamples
+ // This timestamp already matches
+ || Math.max(encodedPacketMetadata.timestampInSamples, 0) === timestampInSamples
+ )
+ ) {
break;
}
-
- currentPage = nextPosition.startPage;
- currentSegmentIndex = nextPosition.startSegmentIndex;
}
- return lastEncodedPacket;
- } finally {
- release();
+ const nextPosition = await this.demuxer.findNextPacketStart(packet);
+ if (!nextPosition) {
+ break;
+ }
+
+ currentPage = nextPosition.startPage;
+ currentSegmentIndex = nextPosition.startSegmentIndex;
}
+
+ return lastEncodedPacket;
}
getKeyPacket(timestamp: number, options: PacketRetrievalOptions) {
diff --git a/src/ogg/ogg-reader.ts b/src/ogg/ogg-reader.ts
index 3459b30..011b126 100644
--- a/src/ogg/ogg-reader.ts
+++ b/src/ogg/ogg-reader.ts
@@ -6,7 +6,7 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
-import { Reader } from '../reader';
+import { FileSlice, readI64Le, readU32Le, readU8 } from '../reader';
import { OGGS } from './ogg-misc';
export const MIN_PAGE_HEADER_SIZE = 27;
@@ -26,118 +26,68 @@ export type Page = {
lacingValues: Uint8Array;
};
-export class OggReader {
- pos = 0;
- constructor(public reader: Reader) {}
+export const readPageHeader = (slice: FileSlice): Page | null => {
+ const startPos = slice.filePos;
- readBytes(length: number) {
- const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + length);
- this.pos += length;
-
- return new Uint8Array(view.buffer, offset, length);
+ const capturePattern = readU32Le(slice);
+ if (capturePattern !== OGGS) {
+ return null;
}
- readU8() {
- const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + 1);
- this.pos += 1;
+ slice.skip(1); // Version
+ const headerType = readU8(slice);
+ const granulePosition = readI64Le(slice);
+ const serialNumber = readU32Le(slice);
+ const sequenceNumber = readU32Le(slice);
+ const checksum = readU32Le(slice);
- return view.getUint8(offset);
+ const numberPageSegments = readU8(slice);
+ const lacingValues = new Uint8Array(numberPageSegments);
+
+ for (let i = 0; i < numberPageSegments; i++) {
+ lacingValues[i] = readU8(slice);
}
- readU32() {
- const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + 4);
- this.pos += 4;
+ const headerSize = 27 + numberPageSegments;
+ const dataSize = lacingValues.reduce((a, b) => a + b, 0);
+ const totalSize = headerSize + dataSize;
- return view.getUint32(offset, true);
- }
+ return {
+ headerStartPos: startPos,
+ totalSize,
+ dataStartPos: startPos + headerSize,
+ dataSize,
+ headerType,
+ granulePosition,
+ serialNumber,
+ sequenceNumber,
+ checksum,
+ lacingValues,
+ };
+};
- readI32() {
- const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + 4);
- this.pos += 4;
+export const findNextPageHeader = (slice: FileSlice, until: number) => {
+ while (slice.filePos < until - (4 - 1)) { // Size of word minus 1
+ const word = readU32Le(slice);
+ const firstByte = word & 0xff;
+ const secondByte = (word >>> 8) & 0xff;
+ const thirdByte = (word >>> 16) & 0xff;
+ const fourthByte = (word >>> 24) & 0xff;
- return view.getInt32(offset, true);
- }
-
- readI64() {
- const low = this.readU32();
- const high = this.readI32();
- return high * 0x100000000 + low;
- }
-
- readAscii(length: number) {
- const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + length);
- this.pos += length;
-
- let str = '';
- for (let i = 0; i < length; i++) {
- str += String.fromCharCode(view.getUint8(offset + i));
- }
- return str;
- }
-
- readPageHeader(): Page | null {
- const startPos = this.pos;
-
- const capturePattern = this.readU32();
- if (capturePattern !== OGGS) {
- return null;
+ const O = 0x4f; // 'O'
+ if (firstByte !== O && secondByte !== O && thirdByte !== O && fourthByte !== O) {
+ continue;
}
- this.pos += 1; // Version
- const headerType = this.readU8();
- const granulePosition = this.readI64();
- const serialNumber = this.readU32();
- const sequenceNumber = this.readU32();
- const checksum = this.readU32();
+ slice.skip(-4);
- const numberPageSegments = this.readU8();
- const lacingValues = new Uint8Array(numberPageSegments);
-
- for (let i = 0; i < numberPageSegments; i++) {
- lacingValues[i] = this.readU8();
+ if (word === OGGS) {
+ // We have found the capture pattern
+ return true;
}
- const headerSize = 27 + numberPageSegments;
- const dataSize = lacingValues.reduce((a, b) => a + b, 0);
- const totalSize = headerSize + dataSize;
-
- return {
- headerStartPos: startPos,
- totalSize,
- dataStartPos: startPos + headerSize,
- dataSize,
- headerType,
- granulePosition,
- serialNumber,
- sequenceNumber,
- checksum,
- lacingValues,
- };
+ slice.skip(1);
}
- findNextPageHeader(until: number) {
- while (this.pos < until - (4 - 1)) { // Size of word minus 1
- const word = this.readU32();
- const firstByte = word & 0xff;
- const secondByte = (word >>> 8) & 0xff;
- const thirdByte = (word >>> 16) & 0xff;
- const fourthByte = (word >>> 24) & 0xff;
-
- const O = 0x4f; // 'O'
- if (firstByte !== O && secondByte !== O && thirdByte !== O && fourthByte !== O) {
- continue;
- }
-
- this.pos -= 4;
-
- if (word === OGGS) {
- // We have found the capture pattern
- return true;
- }
-
- this.pos += 1;
- }
-
- return false;
- }
-}
+ return false;
+};
diff --git a/src/reader.ts b/src/reader.ts
index 138601a..6344e91 100644
--- a/src/reader.ts
+++ b/src/reader.ts
@@ -6,200 +6,229 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
-import { assert, binarySearchLessOrEqual, removeItem } from './misc';
+import { clamp, MaybePromise, toDataView } from './misc';
import { Source } from './source';
-type ReadSegment = {
- start: number;
- end: number;
- bytes: Uint8Array;
- view: DataView;
- age: number;
-};
-
-type LoadingSegment = {
- start: number;
- end: number;
- promise: Promise;
-};
-
export class Reader {
- loadedSegments: ReadSegment[] = [];
- loadingSegments: LoadingSegment[] = [];
- sourceSizePromise: Promise | null = null;
- nextAge = 0;
- totalStoredBytes = 0;
+ fileSize!: number;
- constructor(public source: Source, public maxStorableBytes = Infinity) {}
+ constructor(public source: Source) {}
- async loadRange(start: number, end: number) {
- end = Math.min(end, await this.source.getSize());
-
- if (start >= end) {
- return;
+ requestSlice(start: number, length: number): MaybePromise {
+ if (start + length > this.fileSize) {
+ return null;
}
- const matchingLoadingSegment = this.loadingSegments.find(x => x.start <= start && x.end >= end);
- if (matchingLoadingSegment) {
- // Simply wait for the existing promise to finish to avoid loading the same range twice
- await matchingLoadingSegment.promise;
- return;
- }
+ const end = start + length;
+ const result = this.source._read(start, end);
- const index = binarySearchLessOrEqual(
- this.loadedSegments,
+ if (result instanceof Promise) {
+ return result.then((x) => {
+ if (!x) {
+ return null;
+ }
+
+ return new FileSlice(x.bytes, x.view, x.offset, start, end);
+ });
+ } else {
+ if (!result) {
+ return null;
+ }
+
+ return new FileSlice(result.bytes, result.view, result.offset, start, end);
+ }
+ }
+
+ requestSliceRange(start: number, minLength: number, maxLength: number): MaybePromise {
+ return this.requestSlice(
start,
- x => x.start,
+ clamp(this.fileSize - start, minLength, maxLength),
);
- if (index !== -1) {
- for (let i = index; i < this.loadedSegments.length; i++) {
- const segment = this.loadedSegments[i]!;
- if (segment.start > start) {
- break;
- }
-
- const segmentEncasesRequestedRange = segment.end >= end;
- if (segmentEncasesRequestedRange) {
- // Nothing to load
- return;
- }
- }
- }
-
- this.source.onread?.(start, end);
- const bytesPromise = this.source._read(start, end);
- const loadingSegment: LoadingSegment = { start, end, promise: bytesPromise };
- this.loadingSegments.push(loadingSegment);
-
- const bytes = await bytesPromise;
- removeItem(this.loadingSegments, loadingSegment);
-
- this.insertIntoLoadedSegments(start, bytes);
- }
-
- rangeIsLoaded(start: number, end: number) {
- if (end <= start) {
- return true;
- }
-
- const index = binarySearchLessOrEqual(this.loadedSegments, start, x => x.start);
- if (index === -1) {
- return false;
- }
-
- for (let i = index; i < this.loadedSegments.length; i++) {
- const segment = this.loadedSegments[i]!;
- if (segment.start > start) {
- break;
- }
-
- const segmentEncasesRequestedRange = segment.end >= end;
- if (segmentEncasesRequestedRange) {
- return true;
- }
- }
-
- return false;
- }
-
- private insertIntoLoadedSegments(start: number, bytes: Uint8Array) {
- const segment: ReadSegment = {
- start,
- end: start + bytes.byteLength,
- bytes,
- view: new DataView(bytes.buffer),
- age: this.nextAge++,
- };
-
- let index = binarySearchLessOrEqual(this.loadedSegments, start, x => x.start);
- if (index === -1 || this.loadedSegments[index]!.start < segment.start) {
- index++;
- }
-
- // Insert the segment at the right place so that the array remains sorted by start offset
- this.loadedSegments.splice(index, 0, segment);
- this.totalStoredBytes += bytes.byteLength;
-
- // Remove all other segments from the array that are completely covered by the newly-inserted segment
- for (let i = index + 1; i < this.loadedSegments.length; i++) {
- const otherSegment = this.loadedSegments[i]!;
- if (otherSegment.start >= segment.end) {
- break;
- }
-
- if (segment.start <= otherSegment.start && otherSegment.end <= segment.end) {
- this.loadedSegments.splice(i, 1);
- i--;
- }
- }
-
- // If we overshoot the max amount of permitted bytes, let's start evicting the oldest segments
- while (this.totalStoredBytes > this.maxStorableBytes && this.loadedSegments.length > 1) {
- let oldestSegment: ReadSegment | null = null;
- let oldestSegmentIndex = -1;
-
- for (let i = 0; i < this.loadedSegments.length; i++) {
- const candidate = this.loadedSegments[i]!;
- if (!oldestSegment || candidate.age < oldestSegment.age) {
- oldestSegment = candidate;
- oldestSegmentIndex = i;
- }
- }
-
- assert(oldestSegment);
-
- this.totalStoredBytes -= oldestSegment.bytes.byteLength;
- this.loadedSegments.splice(oldestSegmentIndex, 1);
- }
- }
-
- getViewAndOffset(start: number, end: number) {
- const startIndex = binarySearchLessOrEqual(this.loadedSegments, start, x => x.start);
- let segment: ReadSegment | null = null;
-
- if (startIndex !== -1) {
- for (let i = startIndex; i < this.loadedSegments.length; i++) {
- const candidate = this.loadedSegments[i]!;
-
- if (candidate.start > start) {
- break;
- }
-
- if (end <= candidate.end) {
- segment = candidate;
- break;
- }
- }
- }
-
- if (!segment) {
- throw new Error(`No segment loaded for range [${start}, ${end}).`);
- }
-
- segment.age = this.nextAge++;
-
- return {
- view: segment.view,
- offset: segment.bytes.byteOffset + start - segment.start,
- };
- }
-
- forgetRange(start: number, end: number) {
- if (end <= start) {
- return;
- }
-
- const startIndex = binarySearchLessOrEqual(this.loadedSegments, start, x => x.start);
- if (startIndex === -1) {
- return;
- }
-
- const segment = this.loadedSegments[startIndex]!;
- if (segment.start !== start || segment.end !== end) {
- return;
- }
-
- this.loadedSegments.splice(startIndex, 1);
- this.totalStoredBytes -= segment.bytes.byteLength;
}
}
+
+export class FileSlice {
+ bufferPos: number;
+
+ constructor(
+ public readonly bytes: Uint8Array,
+ public readonly view: DataView,
+ private readonly offset: number,
+ public readonly start: number,
+ public readonly end: number,
+ ) {
+ this.bufferPos = start - offset;
+ }
+
+ static tempFromBytes(bytes: Uint8Array) {
+ return new FileSlice(
+ bytes,
+ toDataView(bytes),
+ 0,
+ 0,
+ bytes.length,
+ );
+ }
+
+ get length() {
+ return this.end - this.start;
+ }
+
+ get filePos() {
+ return this.offset + this.bufferPos;
+ }
+
+ set filePos(value: number) {
+ this.bufferPos = value - this.offset;
+ }
+
+ skip(byteCount: number) {
+ this.bufferPos += byteCount;
+ }
+
+ slice(filePos: number, length = this.end - filePos) {
+ if (filePos < this.start || filePos + length > this.end) {
+ throw new RangeError('Slicing outside of original slice.');
+ }
+
+ return new FileSlice(
+ this.bytes,
+ this.view,
+ this.offset,
+ filePos,
+ filePos + length,
+ );
+ }
+}
+
+export const readBytes = (slice: FileSlice, length: number) => {
+ const bytes = slice.bytes.subarray(slice.bufferPos, slice.bufferPos + length);
+ slice.bufferPos += length;
+
+ return bytes;
+};
+
+export const readU8 = (slice: FileSlice) => slice.view.getUint8(slice.bufferPos++);
+
+export const readU16 = (slice: FileSlice, littleEndian: boolean) => {
+ const value = slice.view.getUint16(slice.bufferPos, littleEndian);
+ slice.bufferPos += 2;
+
+ return value;
+};
+
+export const readU16Be = (slice: FileSlice) => {
+ const value = slice.view.getUint16(slice.bufferPos, false);
+ slice.bufferPos += 2;
+
+ return value;
+};
+
+export const readU24Be = (slice: FileSlice) => {
+ const high = readU16Be(slice);
+ const low = readU8(slice);
+ return high * 0x100 + low;
+};
+
+export const readI16Be = (slice: FileSlice) => {
+ const value = slice.view.getInt16(slice.bufferPos, false);
+ slice.bufferPos += 2;
+
+ return value;
+};
+
+export const readU32 = (slice: FileSlice, littleEndian: boolean) => {
+ const value = slice.view.getUint32(slice.bufferPos, littleEndian);
+ slice.bufferPos += 4;
+
+ return value;
+};
+
+export const readU32Be = (slice: FileSlice) => {
+ const value = slice.view.getUint32(slice.bufferPos, false);
+ slice.bufferPos += 4;
+
+ return value;
+};
+
+export const readU32Le = (slice: FileSlice) => {
+ const value = slice.view.getUint32(slice.bufferPos, true);
+ slice.bufferPos += 4;
+
+ return value;
+};
+
+export const readI32Be = (slice: FileSlice) => {
+ const value = slice.view.getInt32(slice.bufferPos, false);
+ slice.bufferPos += 4;
+
+ return value;
+};
+
+export const readI32Le = (slice: FileSlice) => {
+ const value = slice.view.getInt32(slice.bufferPos, true);
+ slice.bufferPos += 4;
+
+ return value;
+};
+
+export const readU64 = (slice: FileSlice, littleEndian: boolean) => {
+ let low: number;
+ let high: number;
+
+ if (littleEndian) {
+ low = readU32(slice, true);
+ high = readU32(slice, true);
+ } else {
+ high = readU32(slice, false);
+ low = readU32(slice, false);
+ }
+
+ return high * 0x100000000 + low;
+};
+
+export const readU64Be = (slice: FileSlice) => {
+ const high = readU32Be(slice);
+ const low = readU32Be(slice);
+ return high * 0x100000000 + low;
+};
+
+export const readI64Be = (slice: FileSlice) => {
+ const high = readI32Be(slice);
+ const low = readU32Be(slice);
+ return high * 0x100000000 + low;
+};
+
+export const readI64Le = (slice: FileSlice) => {
+ const low = readU32Le(slice);
+ const high = readI32Le(slice);
+ return high * 0x100000000 + low;
+};
+
+export const readF32Be = (slice: FileSlice) => {
+ const value = slice.view.getFloat32(slice.bufferPos, false);
+ slice.bufferPos += 4;
+
+ return value;
+};
+
+export const readF64Be = (slice: FileSlice) => {
+ const value = slice.view.getFloat64(slice.bufferPos, false);
+ slice.bufferPos += 8;
+
+ return value;
+};
+
+export const readAscii = (slice: FileSlice, length: number) => {
+ if (slice.bufferPos + length > slice.bytes.length) {
+ throw new RangeError('Reading past end of slice.');
+ }
+
+ let str = '';
+
+ for (let i = 0; i < length; i++) {
+ str += String.fromCharCode(slice.bytes[slice.bufferPos++]!);
+ }
+
+ return str;
+};
diff --git a/src/sample.ts b/src/sample.ts
index 2ccf78d..b59b332 100644
--- a/src/sample.ts
+++ b/src/sample.ts
@@ -143,8 +143,11 @@ export class VideoSample {
this._data = data;
this.format = data.format;
- this.codedWidth = data.codedWidth;
- this.codedHeight = data.codedHeight;
+ // Copying the display dimensions here, assuming no innate VideoFrame rotation
+ this.codedWidth = data.displayWidth;
+ this.codedHeight = data.displayHeight;
+ // The VideoFrame's rotation is ignored here. It's still a new field, and I'm not sure of any application
+ // where the browser makes use of it. If a case gets found, I'll add it.
this.rotation = init?.rotation ?? 0;
this.timestamp = init?.timestamp ?? data.timestamp / 1e6;
this.duration = init?.duration ?? (data.duration ?? 0) / 1e6;
diff --git a/src/source.ts b/src/source.ts
index 20c662c..d0c2e00 100644
--- a/src/source.ts
+++ b/src/source.ts
@@ -6,7 +6,24 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
-import { mergeObjectsDeeply, retriedFetch } from './misc';
+import type { FileHandle } from 'node:fs/promises';
+import {
+ assert,
+ binarySearchLessOrEqual,
+ closedIntervalsOverlap,
+ MaybePromise,
+ mergeObjectsDeeply,
+ promiseWithResolvers,
+ retriedFetch,
+ toDataView,
+} from './misc';
+
+export type ReadResult = {
+ bytes: Uint8Array;
+ view: DataView;
+ /** The offset of the bytes in the file. */
+ offset: number;
+};
/**
* The source base class, representing a resource from which bytes can be read.
@@ -14,22 +31,22 @@ import { mergeObjectsDeeply, retriedFetch } from './misc';
*/
export abstract class Source {
/** @internal */
- abstract _read(start: number, end: number): Promise;
+ abstract _retrieveSize(): MaybePromise;
/** @internal */
- abstract _retrieveSize(): Promise;
+ abstract _read(start: number, end: number): MaybePromise;
/** @internal */
- _sizePromise: Promise | null = null;
+ private _sizePromise: Promise | null = null;
/**
* Resolves with the total size of the file in bytes. This function is memoized, meaning only the first call
* will retrieve the size.
*/
- getSize() {
- return this._sizePromise ??= this._retrieveSize();
+ async getSize() {
+ return this._sizePromise ??= Promise.resolve(this._retrieveSize());
}
- /** Called each time data is requested from the source. */
+ /** Called each time data is retrieved from the source. Will be called with the retrieved range. */
onread: ((start: number, end: number) => unknown) | null = null;
}
@@ -40,6 +57,10 @@ export abstract class Source {
export class BufferSource extends Source {
/** @internal */
_bytes: Uint8Array;
+ /** @internal */
+ _view: DataView;
+ /** @internal */
+ _onreadCalled = false;
constructor(buffer: ArrayBuffer | Uint8Array) {
if (!(buffer instanceof ArrayBuffer) && !(buffer instanceof Uint8Array)) {
@@ -49,65 +70,39 @@ export class BufferSource extends Source {
super();
this._bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
+ this._view = toDataView(this._bytes);
}
/** @internal */
- async _read(start: number, end: number) {
- return this._bytes.subarray(start, end);
- }
-
- /** @internal */
- async _retrieveSize() {
+ _retrieveSize(): number {
return this._bytes.byteLength;
}
+
+ /** @internal */
+ _read(): ReadResult {
+ if (!this._onreadCalled) {
+ // We just say the first read retrives all bytes from the source (which, I mean, it does)
+ this.onread?.(0, this._bytes.byteLength);
+ this._onreadCalled = true;
+ }
+
+ return {
+ bytes: this._bytes,
+ view: this._view,
+ offset: 0,
+ };
+ }
}
/**
- * Options for defining a StreamSource.
+ * Options for BlobSource.
* @public
*/
-export type StreamSourceOptions = {
- /** Called when data is requested. Should return or resolve to the bytes from the specified byte range. */
- read: (start: number, end: number) => Uint8Array | Promise;
- /** Called when the size of the entire file is requested. Should return or resolve to the size in bytes. */
- getSize: () => number | Promise;
+export type BlobSourceOptions = {
+ /** The maximum number of bytes the cache is allowed to hold in memory. Defaults to 8 MiB. */
+ maxCacheSize?: number;
};
-/**
- * A general-purpose, callback-driven source that can get its data from anywhere.
- * @public
- */
-export class StreamSource extends Source {
- /** @internal */
- _options: StreamSourceOptions;
-
- constructor(options: StreamSourceOptions) {
- if (!options || typeof options !== 'object') {
- throw new TypeError('options must be an object.');
- }
- if (typeof options.read !== 'function') {
- throw new TypeError('options.read must be a function.');
- }
- if (typeof options.getSize !== 'function') {
- throw new TypeError('options.getSize must be a function.');
- }
-
- super();
-
- this._options = options;
- }
-
- /** @internal */
- async _read(start: number, end: number) {
- return this._options.read(start, end);
- }
-
- /** @internal */
- async _retrieveSize() {
- return this._options.getSize();
- }
-}
-
/**
* A source backed by a Blob. Since Files are also Blobs, this is the source to use when reading files off the disk.
* @public
@@ -115,30 +110,79 @@ export class StreamSource extends Source {
export class BlobSource extends Source {
/** @internal */
_blob: Blob;
+ /** @internal */
+ _orchestrator: ReadOrchestrator;
- constructor(blob: Blob) {
+ constructor(blob: Blob, options: BlobSourceOptions = {}) {
if (!(blob instanceof Blob)) {
throw new TypeError('blob must be a Blob.');
}
+ if (!options || typeof options !== 'object') {
+ throw new TypeError('options must be an object.');
+ }
+ if (
+ options.maxCacheSize !== undefined
+ && (!Number.isInteger(options.maxCacheSize) || options.maxCacheSize < 0)
+ ) {
+ throw new TypeError('options.maxCacheSize, when provided, must be a non-negative integer.');
+ }
super();
this._blob = blob;
+ this._orchestrator = new ReadOrchestrator({
+ maxCacheSize: options.maxCacheSize ?? (8 * 2 ** 20 /* 8 MiB */),
+ maxWorkerCount: 4,
+ runWorker: this._runWorker.bind(this),
+ prefetchProfile: PREFETCH_PROFILES.fileSystem,
+ });
}
/** @internal */
- async _read(start: number, end: number) {
- const slice = this._blob.slice(start, end);
- const buffer = await slice.arrayBuffer();
- return new Uint8Array(buffer);
+ _retrieveSize(): number {
+ const size = this._blob.size;
+ this._orchestrator.fileSize = size;
+
+ return size;
}
/** @internal */
- async _retrieveSize() {
- return this._blob.size;
+ _read(start: number, end: number): MaybePromise {
+ return this._orchestrator.read(start, end);
+ }
+
+ /** @internal */
+ _readers = new WeakMap>();
+
+ /** @internal */
+ private async _runWorker(worker: ReadWorker) {
+ let reader = this._readers.get(worker);
+ if (!reader) {
+ // Get a reader of the blob starting at the required offset, and then keep it around
+ reader = this._blob.slice(worker.currentPos).stream().getReader();
+ this._readers.set(worker, reader);
+ }
+
+ while (worker.currentPos < worker.targetPos && !worker.aborted) {
+ 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;
+ }
+
+ this.onread?.(worker.currentPos, worker.currentPos + value.length);
+ this._orchestrator.supplyWorkerData(worker, value);
+ }
}
}
+const URL_SOURCE_MIN_LOAD_AMOUNT = 0.5 * 2 ** 20; // 0.5 MiB
+
/**
* Options for UrlSource.
* @public
@@ -153,24 +197,34 @@ export type UrlSourceOptions = {
/**
* A function that returns the delay (in seconds) before retrying a failed request. The function is called
* with the number of previous, unsuccessful attempts. If the function returns `null`, no more retries will be made.
+ *
+ * By default, it uses an exponential backoff algorithm that never fully gives up.
*/
getRetryDelay?: (previousAttempts: number) => number | null;
+
+ /** The maximum number of bytes the cache is allowed to hold in memory. Defaults to 64 MiB. */
+ maxCacheSize?: number;
};
/**
- * A source backed by a URL. This is useful for reading data from the network. Be careful using this source however,
- * as it typically comes with increased latency.
- * @beta
+ * A source backed by a URL. This is useful for reading data from the network. Requests will be made using an optimized
+ * reading and prefetching pattern to minimize request count and latency.
+ * @public
*/
export class UrlSource extends Source {
/** @internal */
- private _url: URL;
+ _url: URL;
/** @internal */
- private _options: UrlSourceOptions;
+ _getRetryDelay: (previousAttempts: number) => number | null;
/** @internal */
- private _fullData: ArrayBuffer | null = null;
+ _options: UrlSourceOptions;
/** @internal */
- private _nextUrlVersion: number | null = null;
+ _orchestrator: ReadOrchestrator;
+ /** @internal */
+ _existingResponses = new WeakMap();
constructor(
url: string | URL,
@@ -188,141 +242,972 @@ export class UrlSource extends Source {
if (options.getRetryDelay !== undefined && typeof options.getRetryDelay !== 'function') {
throw new TypeError('options.getRetryDelay, when provided, must be a function.');
}
+ if (
+ options.maxCacheSize !== undefined
+ && (!Number.isInteger(options.maxCacheSize) || options.maxCacheSize < 0)
+ ) {
+ throw new TypeError('options.maxCacheSize, when provided, must be a non-negative integer.');
+ }
super();
- this._url = url instanceof URL ? url : new URL(url, location.href);
+ this._url = url instanceof URL
+ ? url
+ : new URL(url, typeof location !== 'undefined' ? location.href : undefined);
this._options = options;
+ this._getRetryDelay = options.getRetryDelay ?? (previousAttempts => Math.min(2 ** (previousAttempts - 2), 8));
+
+ this._orchestrator = new ReadOrchestrator({
+ maxCacheSize: options.maxCacheSize ?? (64 * 2 ** 20 /* 64 MiB */),
+ // Most files in the real-world have a single sequential access pattern, but having two in parallel can
+ // also happen
+ maxWorkerCount: 2,
+ runWorker: this._runWorker.bind(this),
+ prefetchProfile: PREFETCH_PROFILES.network,
+ });
}
/** @internal */
- private async _makeRequest(
- range?: { start: number; end: number },
- ): Promise<{ response: ArrayBuffer; statusCode: number }> {
- const headers: HeadersInit = {};
-
- if (range) {
- headers['Range'] = `bytes=${range.start}-${range.end - 1}`;
- }
-
- if (this._nextUrlVersion !== null) {
- this._url.searchParams.set('mediabunny_version', this._nextUrlVersion.toString());
- this._nextUrlVersion++;
- }
+ async _retrieveSize(): Promise {
+ // Retrieving the resource size for UrlSource is optimized: Almost always (= always), the first bytes we have to
+ // read are the start of the file. This means it's smart to combine size fetching with fetching the start of the
+ // file. We additionally use this step to probe if the server supports range requests, killing three birds with
+ // one stone.
+ const abortController = new AbortController();
const response = await retriedFetch(
this._url,
mergeObjectsDeeply(this._options.requestInit ?? {}, {
- method: 'GET',
- headers,
+ headers: {
+ // We could also send a non-range request to request the same bytes (all of them), but doing it like
+ // this is an easy way to check if the server supports range requests in the first place
+ Range: 'bytes=0-',
+ },
+ signal: abortController.signal,
}),
- this._options.getRetryDelay ?? (() => null),
+ this._getRetryDelay,
);
if (!response.ok) {
throw new Error(`Error fetching ${this._url}: ${response.status} ${response.statusText}`);
}
- const buffer = await response.arrayBuffer();
+ let worker: ReadWorker;
+ let fileSize: number;
- if (
- response.status === 206
- && range
- && buffer.byteLength !== range.end - range.start
- && this._nextUrlVersion === null
- ) {
- // We did a range request but it resolved with the wrong range; in Chromium, this can be due to a caching
- // bug (https://issues.chromium.org/issues/436025873). Let's circumvent the cache for the rest of the
- // session by appending a version to the URL.
- this._nextUrlVersion = 1;
- return this._makeRequest(range);
- }
+ if (response.status === 206) {
+ fileSize = this._getPartialLengthFromRangeResponse(response);
+ worker = this._orchestrator.createWorker(0, URL_SOURCE_MIN_LOAD_AMOUNT);
+ } else {
+ // Server probably returned a 200.
- if (response.status === 200) {
- // The server didn't return 206 Partial Content, so it's not a range response
- this._fullData = buffer;
- }
+ const contentLength = response.headers.get('Content-Length');
+ if (contentLength) {
+ fileSize = Number(contentLength);
+ worker = this._orchestrator.createWorker(0, fileSize);
+ this._orchestrator.options.maxCacheSize = Infinity; // 🤷
- return {
- response: buffer,
- statusCode: response.status,
- };
- }
-
- /** @internal */
- async _read(start: number, end: number): Promise {
- if (this._fullData) {
- return new Uint8Array(this._fullData, start, end - start);
- }
-
- const { response, statusCode } = await this._makeRequest({ start, end });
-
- // If server doesn't support range requests, it will return 200 instead of 206. In that case, let's manually
- // slice the response.
- if (statusCode === 200) {
- const fullData = new Uint8Array(response);
- return fullData.subarray(start, end);
- }
-
- return new Uint8Array(response);
- }
-
- /** @internal */
- async _retrieveSize(): Promise {
- if (this._fullData) {
- return this._fullData.byteLength;
- }
-
- // First, try a HEAD request to get the size
- try {
- const headResponse = await retriedFetch(
- this._url,
- mergeObjectsDeeply(this._options.requestInit ?? {}, {
- method: 'HEAD',
- }),
- this._options.getRetryDelay ?? (() => null),
- );
-
- if (headResponse.ok) {
- const contentLength = headResponse.headers.get('Content-Length');
- if (contentLength) {
- return parseInt(contentLength);
- }
- }
- } catch {
- // We tried
- }
-
- // Try a range request to get the Content-Range header
- const rangeResponse = await retriedFetch(
- this._url,
- mergeObjectsDeeply(this._options.requestInit ?? {}, {
- method: 'GET',
- headers: { Range: 'bytes=0-0' },
- }),
- this._options.getRetryDelay ?? (() => null),
- );
-
- if (rangeResponse.status === 206) {
- const contentRange = rangeResponse.headers.get('Content-Range');
- if (contentRange) {
- const match = contentRange.match(/bytes \d+-\d+\/(\d+)/);
- if (match && match[1]) {
- return parseInt(match[1]);
- }
- }
- } else if (rangeResponse.status === 200) {
- // The server just returned the whole thing
- this._fullData = await rangeResponse.arrayBuffer();
- if (this._fullData.byteLength !== 1) {
- return this._fullData.byteLength;
+ console.warn(
+ 'HTTP server did not respond with 206 Partial Content, meaning the entire remote resource now has'
+ + ' to be downloaded. For efficient media file streaming across a network, please make sure your'
+ + ' server supports range requests.',
+ );
} else {
- // The server responded with 200, but returned only the requested range, so skip the response
+ throw new Error(`HTTP response (status ${response.status}) must surface Content-Length header.`);
}
}
- // If the range request didn't provide the size, make a full GET request
- const { response } = await this._makeRequest();
- return response.byteLength;
+ this._orchestrator.fileSize = fileSize;
+
+ this._existingResponses.set(worker, { response, abortController });
+ this._orchestrator.runWorker(worker);
+
+ return fileSize;
+ }
+
+ /** @internal */
+ _read(start: number, end: number): MaybePromise {
+ return this._orchestrator.read(start, end);
+ }
+
+ /** @internal */
+ private async _runWorker(worker: ReadWorker) {
+ // The outer loop is for resuming a request if it dies mid-response
+ while (!worker.aborted) {
+ const existing = this._existingResponses.get(worker);
+ this._existingResponses.delete(worker);
+
+ let abortController = existing?.abortController;
+ let response = existing?.response;
+
+ if (!abortController) {
+ abortController = new AbortController();
+ response = await retriedFetch(
+ this._url,
+ mergeObjectsDeeply(this._options.requestInit ?? {}, {
+ headers: {
+ Range: `bytes=${worker.currentPos}-`,
+ },
+ signal: abortController.signal,
+ }),
+ this._getRetryDelay,
+ );
+ }
+
+ assert(response);
+
+ if (!response.ok) {
+ throw new Error(`Error fetching ${this._url}: ${response.status} ${response.statusText}`);
+ }
+
+ if (worker.currentPos > 0 && response.status !== 206) {
+ throw new Error(
+ 'HTTP server did not respond with 206 Partial Content to a range request. To enable efficient media'
+ + ' file streaming across a network, please make sure your server supports range requests.',
+ );
+ }
+
+ 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.');
+ }
+
+ const reader = response.body.getReader();
+
+ while (true) {
+ let readResult: ReadableStreamReadResult;
+
+ try {
+ readResult = await reader.read();
+ } catch (error) {
+ const retryDelayInSeconds = this._getRetryDelay(1);
+ if (retryDelayInSeconds !== null) {
+ console.error('Error while reading response stream. Attempting to resume.', error);
+ await new Promise(resolve => setTimeout(resolve, 1000 * retryDelayInSeconds));
+
+ break;
+ } else {
+ throw error;
+ }
+ }
+
+ 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.',
+ );
+ }
+
+ return;
+ }
+
+ this.onread?.(worker.currentPos, worker.currentPos + value.length);
+ this._orchestrator.supplyWorkerData(worker, value);
+
+ if (worker.currentPos >= worker.targetPos || worker.aborted) {
+ abortController.abort();
+
+ return;
+ }
+ }
+ }
+
+ // The previous UrlSource had logic for circumventing https://issues.chromium.org/issues/436025873; I haven't
+ // been able to observe this bug with the new UrlSource (maybe because we're using response streaming), so the
+ // logic for that has vanished for now. Leaving a comment here if this becomes relevant again.
+ }
+
+ /** @internal */
+ private _getPartialLengthFromRangeResponse(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}`);
+ }
+ } 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.',
+ );
+ }
+ }
+ }
+}
+
+/**
+ * Options for FilePathSource.
+ * @public
+ */
+export type FilePathSourceOptions = {
+ /** The maximum number of bytes the cache is allowed to hold in memory. Defaults to 8 MiB. */
+ maxCacheSize?: number;
+};
+
+/**
+ * A source backed by a path to a file. Intended for server-side usage in Node, Bun, or Deno.
+ * @public
+ */
+export class FilePathSource extends Source {
+ /** @internal */
+ _streamSource: StreamSource;
+
+ constructor(filePath: string, options: BlobSourceOptions = {}) {
+ if (typeof filePath !== 'string') {
+ throw new TypeError('filePath must be a string.');
+ }
+ if (!options || typeof options !== 'object') {
+ throw new TypeError('options must be an object.');
+ }
+ if (
+ options.maxCacheSize !== undefined
+ && (!Number.isInteger(options.maxCacheSize) || options.maxCacheSize < 0)
+ ) {
+ throw new TypeError('options.maxCacheSize, when provided, must be a non-negative integer.');
+ }
+
+ super();
+
+ let fileHandle: FileHandle | null = null;
+
+ // Let's back this source with a StreamSource, makes the implementation very simple
+ this._streamSource = new StreamSource({
+ getSize: async () => {
+ const FS_MODULE_NAME = 'node:fs/promises';
+ const fs = await import(/* @vite-ignore */ FS_MODULE_NAME) as typeof import('node:fs/promises');
+ fileHandle = await fs.open(filePath, 'r');
+
+ const stats = await fileHandle.stat();
+ return stats.size;
+ },
+ read: async (start, end) => {
+ assert(fileHandle);
+
+ const buffer = Buffer.alloc(end - start);
+ await fileHandle.read(buffer, 0, end - start, start);
+ return buffer;
+ },
+ maxCacheSize: options.maxCacheSize,
+ prefetchProfile: 'fileSystem',
+ });
+ }
+
+ /** @internal */
+ _read(start: number, end: number): MaybePromise {
+ return this._streamSource._read(start, end);
+ }
+
+ /** @internal */
+ _retrieveSize(): MaybePromise {
+ return this._streamSource._retrieveSize();
+ }
+}
+
+/**
+ * Options for defining a StreamSource.
+ * @public
+ */
+export type StreamSourceOptions = {
+ /**
+ * 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`.
+ */
+ getSize: () => MaybePromise;
+
+ /**
+ * Called when data is requested. Must return or resolve to the bytes from the specified byte range, or a stream
+ * that yields these bytes.
+ */
+ read: (start: number, end: number) => MaybePromise>;
+
+ /** The maximum number of bytes the cache is allowed to hold in memory. Defaults to 8 MiB. */
+ maxCacheSize?: number;
+
+ /**
+ * Specifies the prefetch profile that the reader should use with this source. A prefetch propfile specifies the
+ * pattern with which bytes outside of the requested range are preloaded to reduce latency for future reads.
+ *
+ * - `'none'` (default): No prefetching; only the data needed in the moment is requested.
+ * - `'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.
+ */
+ prefetchProfile?: 'none' | 'fileSystem' | 'network';
+};
+
+/**
+ * A general-purpose, callback-driven source that can get its data from anywhere.
+ * @public
+ */
+export class StreamSource extends Source {
+ /** @internal */
+ _options: StreamSourceOptions;
+ /** @internal */
+ _orchestrator: ReadOrchestrator;
+
+ constructor(options: StreamSourceOptions) {
+ if (!options || typeof options !== 'object') {
+ throw new TypeError('options must be an object.');
+ }
+ if (typeof options.read !== 'function') {
+ throw new TypeError('options.read must be a function.');
+ }
+ if (typeof options.getSize !== 'function') {
+ throw new TypeError('options.getSize must be a function.');
+ }
+ if (
+ options.maxCacheSize !== undefined
+ && (!Number.isInteger(options.maxCacheSize) || options.maxCacheSize < 0)
+ ) {
+ throw new TypeError('options.maxCacheSize, when provided, must be a non-negative integer.');
+ }
+ if (options.prefetchProfile && !['none', 'fileSystem', 'network'].includes(options.prefetchProfile)) {
+ throw new TypeError(
+ 'options.prefetchProfile, when provided, must be one of \'none\', \'fileSystem\' or \'network\'.',
+ );
+ }
+
+ super();
+
+ this._options = options;
+
+ this._orchestrator = new ReadOrchestrator({
+ maxCacheSize: options.maxCacheSize ?? (8 * 2 ** 20 /* 8 MiB */),
+ maxWorkerCount: 2, // Fixed for now, *should* be fine
+ prefetchProfile: PREFETCH_PROFILES[options.prefetchProfile ?? 'none'],
+ runWorker: this._runWorker.bind(this),
+ });
+ }
+
+ /** @internal */
+ _retrieveSize(): MaybePromise {
+ const result = this._options.getSize();
+
+ if (result instanceof Promise) {
+ return result.then((size) => {
+ if (!Number.isInteger(size) || size < 0) {
+ throw new TypeError('options.getSize must return or resolve to a non-negative integer.');
+ }
+
+ this._orchestrator.fileSize = size;
+ return size;
+ });
+ } else {
+ if (!Number.isInteger(result) || result < 0) {
+ throw new TypeError('options.getSize must return or resolve to a non-negative integer.');
+ }
+
+ this._orchestrator.fileSize = result;
+ return result;
+ }
+ }
+
+ /** @internal */
+ _read(start: number, end: number): MaybePromise {
+ return this._orchestrator.read(start, end);
+ }
+
+ /** @internal */
+ private async _runWorker(worker: ReadWorker) {
+ while (worker.currentPos < worker.targetPos && !worker.aborted) {
+ const originalCurrentPos = worker.currentPos;
+ const originalTargetPos = worker.targetPos;
+
+ let data = this._options.read(worker.currentPos, originalTargetPos);
+ if (data instanceof Promise) data = await data;
+
+ if (data instanceof Uint8Array) {
+ if (data.length !== originalTargetPos - worker.currentPos) {
+ // Yes, we're that strict
+ throw new Error(
+ `options.read returned a Uint8Array with unexpected length: Requested ${
+ originalTargetPos - worker.currentPos
+ } bytes, but got ${data.length}.`,
+ );
+ }
+
+ this.onread?.(worker.currentPos, worker.currentPos + data.length);
+ this._orchestrator.supplyWorkerData(worker, data);
+ } else if (data instanceof ReadableStream) {
+ const reader = data.getReader();
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) {
+ if (worker.currentPos < originalTargetPos) {
+ // Yes, we're *that* strict
+ throw new Error(
+ `ReadableStream returned by options.read ended before supplying enough data.`
+ + ` Requested ${originalTargetPos - originalCurrentPos} bytes, but got ${
+ worker.currentPos - originalCurrentPos
+ }`,
+ );
+ }
+
+ break;
+ }
+
+ if (!(value instanceof Uint8Array)) {
+ throw new TypeError('ReadableStream returned by options.read must yield Uint8Array chunks.');
+ }
+
+ this.onread?.(worker.currentPos, worker.currentPos + value.length);
+ this._orchestrator.supplyWorkerData(worker, value);
+
+ if (worker.currentPos >= originalTargetPos || worker.aborted) {
+ break;
+ }
+ }
+ } else {
+ throw new TypeError('options.read must return or resolve to a Uint8Array or a ReadableStream.');
+ }
+ }
+ }
+}
+
+type PrefetchProfile = (start: number, end: number, workers: ReadWorker[]) => {
+ start: number;
+ end: number;
+};
+
+const PREFETCH_PROFILES = {
+ none: (start, end) => ({ start, end }),
+ fileSystem: (start, end) => {
+ const padding = 2 ** 16;
+
+ start = Math.floor((start - padding) / padding) * padding;
+ end = Math.ceil((end + padding) / padding) * padding;
+
+ return { start, end };
+ },
+ network: (start, end, workers) => {
+ // Add a slight bit of start padding because backwards reading is painful
+ const paddingStart = 2 ** 16;
+ start = Math.max(0, Math.floor((start - paddingStart) / paddingStart) * paddingStart);
+
+ // Remote resources have extreme latency (relatively speaking), so the benefit from intelligent
+ // prefetching is great. The network prefetch strategy is as follows: When we notice
+ // successive reads to a worker's read region, we prefetch more data at the end of that region,
+ // growing exponentially (up to a cap). This performs well for real-world use cases: Either we read a
+ // small part of the file once and then never need it again, in which case the requested about of data
+ // is small. Or, we're repeatedly doing a sequential access pattern (common in media files), in which
+ // case we can become more and more confident to prefetch more and more data.
+ for (const worker of workers) {
+ const maxExtensionAmount = 8 * 2 ** 20; // 8 MiB
+
+ // When the read region cross the threshold point, we trigger a prefetch. This point is typically
+ // in the middle of the worker's read region, or a fixed offset from the end if the region has grown
+ // really large.
+ const thresholdPoint = Math.max(
+ (worker.startPos + worker.targetPos) / 2,
+ worker.targetPos - maxExtensionAmount,
+ );
+
+ if (closedIntervalsOverlap(
+ start, end,
+ thresholdPoint, worker.targetPos,
+ )) {
+ const size = worker.targetPos - worker.startPos;
+
+ // If we extend by maxExtensionAmount
+ const a = Math.ceil((size + 1) / maxExtensionAmount) * maxExtensionAmount;
+ // If we extend to the next power of 2
+ const b = 2 ** Math.ceil(Math.log2(size + 1));
+
+ const extent = Math.min(b, a);
+ end = Math.max(end, worker.startPos + extent);
+ }
+ }
+
+ end = Math.max(end, start + URL_SOURCE_MIN_LOAD_AMOUNT);
+
+ return {
+ start,
+ end,
+ };
+ },
+} satisfies Record;
+
+type PendingSlice = {
+ start: number;
+ bytes: Uint8Array;
+ holes: {
+ start: number;
+ end: number;
+ }[];
+ resolve: (bytes: Uint8Array) => void;
+ reject: (error: unknown) => void;
+};
+
+type CacheEntry = {
+ start: number;
+ end: number;
+ bytes: Uint8Array;
+ view: DataView;
+ age: number;
+};
+
+type ReadWorker = {
+ startPos: number;
+ currentPos: number;
+ targetPos: number;
+ running: boolean;
+ aborted: boolean;
+ pendingSlices: PendingSlice[];
+ age: number;
+};
+
+/**
+ * Godclass for orchestrating complex, cached read operations. The reading model is as follows: Any reading task is
+ * delegated to a *worker*, which is a sequential reader positioned somewhere along the file. All workers run in
+ * parallel and can be stopped and resumed in their forward movement. When read requests come in, this orchestrator will
+ * first try to satisfy the request with only the cached data. If this isn't possible, workers are spun up for all
+ * missing parts (or existing workers are repurposed), and these workers will then fill the holes in the data as they
+ * march along the file.
+ */
+class ReadOrchestrator {
+ fileSize: number | null = null;
+ nextAge = 0; // Used for LRU eviction of both cache entries and workers
+ workers: ReadWorker[] = [];
+ cache: CacheEntry[] = [];
+ currentCacheSize = 0;
+
+ constructor(public options: {
+ maxCacheSize: number;
+ runWorker: (worker: ReadWorker) => Promise;
+ prefetchProfile: PrefetchProfile;
+ maxWorkerCount: number;
+ }) {}
+
+ read(innerStart: number, innerEnd: number): MaybePromise {
+ assert(this.fileSize !== null);
+
+ const prefetchRange = this.options.prefetchProfile(innerStart, innerEnd, this.workers);
+ const outerStart = Math.max(prefetchRange.start, 0);
+ const outerEnd = Math.min(prefetchRange.end, this.fileSize);
+ assert(outerStart <= innerStart && innerEnd <= outerEnd);
+
+ let result: MaybePromise<{
+ bytes: Uint8Array;
+ view: DataView;
+ offset: number;
+ }> | null = null;
+
+ const innerCacheStartIndex = binarySearchLessOrEqual(this.cache, innerStart, x => x.start);
+ const innerStartEntry = innerCacheStartIndex !== -1 ? this.cache[innerCacheStartIndex] : null;
+
+ // See if the read request can be satisfied by a single cache entry
+ if (innerStartEntry && innerStartEntry.start <= innerStart && innerEnd <= innerStartEntry.end) {
+ innerStartEntry.age = this.nextAge++;
+
+ result = {
+ bytes: innerStartEntry.bytes,
+ view: innerStartEntry.view,
+ offset: innerStartEntry.start,
+ };
+ // Can't return yet though, still need to check if the prefetch range might lie outside the cached area
+ }
+
+ const outerCacheStartIndex = binarySearchLessOrEqual(this.cache, outerStart, x => x.start);
+
+ const bytes = result ? null : new Uint8Array(innerEnd - innerStart);
+ let contiguousBytesWriteEnd = 0; // Used to track if the cache is able to completely cover the bytes
+
+ let lastEnd = outerStart;
+ // The "holes" in the cache (the parts we need to load)
+ const outerHoles: {
+ start: number;
+ end: number;
+ }[] = [];
+
+ // Loop over the cache and build up the list of holes
+ if (outerCacheStartIndex !== -1) {
+ for (let i = outerCacheStartIndex; i < this.cache.length; i++) {
+ const entry = this.cache[i]!;
+ if (entry.start >= outerEnd) {
+ break;
+ }
+ if (entry.end <= outerStart) {
+ continue;
+ }
+
+ const cappedOuterStart = Math.max(outerStart, entry.start);
+ const cappedOuterEnd = Math.min(outerEnd, entry.end);
+ assert(cappedOuterStart <= cappedOuterEnd);
+
+ if (lastEnd < cappedOuterStart) {
+ outerHoles.push({ start: lastEnd, end: cappedOuterStart });
+ }
+ lastEnd = cappedOuterEnd;
+
+ if (bytes) {
+ const cappedInnerStart = Math.max(innerStart, entry.start);
+ const cappedInnerEnd = Math.min(innerEnd, entry.end);
+
+ if (cappedInnerStart < cappedInnerEnd) {
+ const relativeOffset = cappedInnerStart - innerStart;
+
+ // Fill the relevant section of the bytes with the cached data
+ bytes.set(
+ entry.bytes.subarray(cappedInnerStart - entry.start, cappedInnerEnd - entry.start),
+ relativeOffset,
+ );
+
+ if (relativeOffset === contiguousBytesWriteEnd) {
+ contiguousBytesWriteEnd = cappedInnerEnd - innerStart;
+ }
+ }
+ }
+ entry.age = this.nextAge++;
+ }
+
+ if (lastEnd < outerEnd) {
+ outerHoles.push({ start: lastEnd, end: outerEnd });
+ }
+ } else {
+ outerHoles.push({ start: outerStart, end: outerEnd });
+ }
+
+ if (bytes && contiguousBytesWriteEnd >= bytes.length) {
+ // Multiple cache entries were able to completely cover the requested bytes!
+ result = {
+ bytes,
+ view: toDataView(bytes),
+ offset: innerStart,
+ };
+ }
+
+ if (outerHoles.length === 0) {
+ assert(result);
+ return result;
+ }
+
+ // We need to read more data, so now we're in async land
+ const { promise, resolve, reject } = promiseWithResolvers();
+
+ const innerHoles: typeof outerHoles = [];
+ for (const outerHole of outerHoles) {
+ const cappedStart = Math.max(innerStart, outerHole.start);
+ const cappedEnd = Math.min(innerEnd, outerHole.end);
+
+ if (cappedStart === outerHole.start && cappedEnd === outerHole.end) {
+ innerHoles.push(outerHole); // Can reuse without allocating a new object
+ } else if (cappedStart < cappedEnd) {
+ innerHoles.push({ start: cappedStart, end: cappedEnd });
+ }
+ }
+
+ // Fire off workers to take care of patching the holes
+ for (const outerHole of outerHoles) {
+ const pendingSlice: PendingSlice | null = bytes && {
+ start: innerStart,
+ bytes,
+ holes: innerHoles,
+ resolve,
+ reject,
+ };
+
+ let workerFound = false;
+ for (const worker of this.workers) {
+ // A small tolerance in the case that the requested region is *just* after the target position of an
+ // existing worker. In that case, it's probably more efficient to repurpose that worker than to spawn
+ // another one so close to it
+ const gapTolerance = 2 ** 17;
+
+ if (closedIntervalsOverlap(
+ outerHole.start - gapTolerance, outerHole.start,
+ worker.currentPos, worker.targetPos,
+ )) {
+ worker.targetPos = Math.max(worker.targetPos, outerHole.end); // Update the worker's target position
+ workerFound = true;
+
+ if (pendingSlice && !worker.pendingSlices.includes(pendingSlice)) {
+ worker.pendingSlices.push(pendingSlice);
+ }
+
+ if (!worker.running) {
+ // Kick it off if it's idle
+ this.runWorker(worker);
+ }
+
+ break;
+ }
+ }
+
+ if (!workerFound) {
+ // We need to spawn a new worker
+ const newWorker = this.createWorker(outerHole.start, outerHole.end);
+ if (pendingSlice) {
+ newWorker.pendingSlices = [pendingSlice];
+ }
+
+ this.runWorker(newWorker);
+ }
+ }
+
+ if (!result) {
+ assert(bytes);
+ result = promise.then(bytes => ({
+ bytes,
+ view: toDataView(bytes),
+ offset: innerStart,
+ }));
+ } else {
+ // The requested region was satisfied by the cache, but the entire prefetch region was not
+ }
+
+ return result;
+ }
+
+ createWorker(startPos: number, targetPos: number) {
+ const worker: ReadWorker = {
+ startPos,
+ currentPos: startPos,
+ targetPos,
+ running: false,
+ aborted: false,
+ pendingSlices: [],
+ age: this.nextAge++,
+ };
+ this.workers.push(worker);
+
+ // LRU eviction of the other workers
+ while (this.workers.length > this.options.maxWorkerCount) {
+ let oldestIndex = 0;
+ let oldestWorker = this.workers[0]!;
+
+ for (let i = 1; i < this.workers.length; i++) {
+ const worker = this.workers[i]!;
+
+ if (worker.age < oldestWorker.age) {
+ oldestIndex = i;
+ oldestWorker = worker;
+ }
+ }
+
+ if (oldestWorker.running && oldestWorker.pendingSlices.length > 0) {
+ break;
+ }
+
+ oldestWorker.aborted = true;
+ this.workers.splice(oldestIndex, 1);
+ }
+
+ return worker;
+ }
+
+ runWorker(worker: ReadWorker) {
+ assert(!worker.running);
+ assert(worker.currentPos < worker.targetPos);
+
+ worker.running = true;
+ worker.age = this.nextAge++;
+
+ void this.options.runWorker(worker)
+ .catch((error) => {
+ if (worker.pendingSlices.length > 0) {
+ worker.pendingSlices.forEach(x => x.reject(error)); // Make sure to propagate any errors
+ worker.pendingSlices.length = 0;
+ } else {
+ throw error; // So it doesn't get swallowed
+ }
+ })
+ .finally(() => {
+ worker.running = false;
+ });
+ }
+
+ /** Called by a worker when it has read some data. */
+ supplyWorkerData(worker: ReadWorker, bytes: Uint8Array) {
+ const start = worker.currentPos;
+ const end = start + bytes.length;
+
+ this.insertIntoCache({
+ start,
+ end,
+ bytes,
+ view: toDataView(bytes),
+ age: this.nextAge++,
+ });
+ worker.currentPos += bytes.length;
+ worker.targetPos = Math.max(worker.targetPos, worker.currentPos); // In case it overshoots
+
+ // Now, let's see if we can use the read bytes to fill any pending slice
+ for (let i = 0; i < worker.pendingSlices.length; i++) {
+ const pendingSlice = worker.pendingSlices[i]!;
+
+ const clampedStart = Math.max(start, pendingSlice.start);
+ const clampedEnd = Math.min(end, pendingSlice.start + pendingSlice.bytes.length);
+
+ if (clampedStart < clampedEnd) {
+ pendingSlice.bytes.set(
+ bytes.subarray(clampedStart - start, clampedEnd - start),
+ clampedStart - pendingSlice.start,
+ );
+ }
+
+ for (let j = 0; j < pendingSlice.holes.length; j++) {
+ // The hole is intentionally not modified here if the read section starts somewhere in the middle of
+ // the hole. We don't need to do "hole splitting", since the workers are spawned *by* the holes,
+ // meaning there's always a worker which will consume the hole left to right.
+ const hole = pendingSlice.holes[j]!;
+ if (start <= hole.start && end > hole.start) {
+ hole.start = end;
+ }
+
+ if (hole.end <= hole.start) {
+ pendingSlice.holes.splice(j, 1);
+ j--;
+ }
+ }
+
+ if (pendingSlice.holes.length === 0) {
+ // The slice has been fulfilled, everything has been read. Let's resolve the promise
+ pendingSlice.resolve(pendingSlice.bytes);
+ worker.pendingSlices.splice(i, 1);
+ i--;
+ }
+ }
+
+ // Remove other idle workers if we "ate" into their territory
+ for (let i = 0; i < this.workers.length; i++) {
+ const otherWorker = this.workers[i]!;
+ if (worker === otherWorker || otherWorker.running) {
+ continue;
+ }
+
+ if (closedIntervalsOverlap(
+ start, end,
+ otherWorker.currentPos, otherWorker.targetPos, // These should typically be equal when the worker's idle
+ )) {
+ this.workers.splice(i, 1);
+ i--;
+ }
+ }
+ }
+
+ forgetWorker(worker: ReadWorker) {
+ const index = this.workers.indexOf(worker);
+ assert(index !== -1);
+
+ this.workers.splice(index, 1);
+ }
+
+ insertIntoCache(entry: CacheEntry) {
+ if (this.options.maxCacheSize === 0) {
+ return; // No caching
+ }
+
+ let insertionIndex = binarySearchLessOrEqual(this.cache, entry.start, x => x.start) + 1;
+
+ if (insertionIndex > 0) {
+ const previous = this.cache[insertionIndex - 1]!;
+ if (previous.end >= entry.end) {
+ // Previous entry swallows the one to be inserted; we don't need to do anything
+ return;
+ }
+
+ if (previous.end > entry.start) {
+ // Partial overlap with the previous entry, let's join
+ const joined = new Uint8Array(entry.end - previous.start);
+ joined.set(previous.bytes, 0);
+ joined.set(entry.bytes, entry.start - previous.start);
+
+ this.currentCacheSize += entry.end - previous.end;
+
+ previous.bytes = joined;
+ previous.view = toDataView(joined);
+ previous.end = entry.end;
+
+ // Do the rest of the logic with the previous entry instead
+ insertionIndex--;
+ entry = previous;
+ } else {
+ this.cache.splice(insertionIndex, 0, entry);
+ this.currentCacheSize += entry.bytes.length;
+ }
+ } else {
+ this.cache.splice(insertionIndex, 0, entry);
+ this.currentCacheSize += entry.bytes.length;
+ }
+
+ for (let i = insertionIndex + 1; i < this.cache.length; i++) {
+ const next = this.cache[i]!;
+ if (entry.end <= next.start) {
+ // Even if they touch, we don't wanna merge them, no need
+ break;
+ }
+
+ if (entry.end >= next.end) {
+ // The inserted entry completely swallows the next entry
+ this.cache.splice(i, 1);
+ this.currentCacheSize -= next.bytes.length;
+ i--;
+ continue;
+ }
+
+ // Partial overlap, let's join
+ const joined = new Uint8Array(next.end - entry.start);
+ joined.set(entry.bytes, 0);
+ joined.set(next.bytes, next.start - entry.start);
+
+ this.currentCacheSize -= entry.end - next.start; // Subtract the overlap
+
+ entry.bytes = joined;
+ entry.view = toDataView(joined);
+ entry.end = next.end;
+ this.cache.splice(i, 1);
+
+ break; // After the join case, we're done: the next entry cannot possibly overlap with the inserted one.
+ }
+
+ // LRU eviction of cache entries
+ while (this.currentCacheSize > this.options.maxCacheSize) {
+ if (this.cache.length > 1) {
+ let oldestIndex = 0;
+ let oldestEntry = this.cache[0]!;
+
+ for (let i = 1; i < this.cache.length; i++) {
+ const entry = this.cache[i]!;
+
+ if (entry.age < oldestEntry.age) {
+ oldestIndex = i;
+ oldestEntry = entry;
+ }
+ }
+
+ this.cache.splice(oldestIndex, 1);
+ this.currentCacheSize -= oldestEntry.bytes.length;
+ } else {
+ // The single entry that's left is too big for the cache, let's trim it
+ const entry = this.cache[0]!;
+ assert(entry.bytes.length > this.options.maxCacheSize);
+
+ entry.bytes = entry.bytes.slice(0, this.options.maxCacheSize);
+ entry.view = toDataView(entry.bytes);
+ entry.end = entry.start + entry.bytes.length;
+
+ this.currentCacheSize = entry.bytes.length;
+ }
+ }
}
}
diff --git a/src/wave/riff-reader.ts b/src/wave/riff-reader.ts
deleted file mode 100644
index 21ccee4..0000000
--- a/src/wave/riff-reader.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-/*!
- * Copyright (c) 2025-present, Vanilagy and contributors
- *
- * This Source Code Form is subject to the terms of the Mozilla Public
- * License, v. 2.0. If a copy of the MPL was not distributed with this
- * file, You can obtain one at https://mozilla.org/MPL/2.0/.
- */
-
-import { Reader } from '../reader';
-
-export class RiffReader {
- pos = 0;
- littleEndian = true;
-
- constructor(public reader: Reader) {}
-
- readBytes(length: number) {
- const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + length);
- this.pos += length;
-
- return new Uint8Array(view.buffer, offset, length);
- }
-
- readU16() {
- const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + 2);
- this.pos += 2;
-
- return view.getUint16(offset, this.littleEndian);
- }
-
- readU32() {
- const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + 4);
- this.pos += 4;
-
- return view.getUint32(offset, this.littleEndian);
- }
-
- readU64() {
- let low: number;
- let high: number;
-
- if (this.littleEndian) {
- low = this.readU32();
- high = this.readU32();
- } else {
- high = this.readU32();
- low = this.readU32();
- }
-
- return high * 0x100000000 + low;
- }
-
- readAscii(length: number) {
- const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + length);
- this.pos += length;
-
- let str = '';
- for (let i = 0; i < length; i++) {
- str += String.fromCharCode(view.getUint8(offset + i));
- }
- return str;
- }
-}
diff --git a/src/wave/wave-demuxer.ts b/src/wave/wave-demuxer.ts
index 1c3aa7d..bfbd669 100644
--- a/src/wave/wave-demuxer.ts
+++ b/src/wave/wave-demuxer.ts
@@ -13,8 +13,7 @@ import { InputAudioTrack, InputAudioTrackBacking } from '../input-track';
import { PacketRetrievalOptions } from '../media-sink';
import { assert, UNDETERMINED_LANGUAGE } from '../misc';
import { EncodedPacket, PLACEHOLDER_DATA } from '../packet';
-import { Reader } from '../reader';
-import { RiffReader } from './riff-reader';
+import { readAscii, readBytes, Reader, readU16, readU32, readU64 } from '../reader';
export enum WaveFormat {
PCM = 0x0001,
@@ -25,8 +24,7 @@ export enum WaveFormat {
}
export class WaveDemuxer extends Demuxer {
- metadataReader: RiffReader;
- chunkReader: RiffReader;
+ reader: Reader;
metadataPromise: Promise | null = null;
dataStart = -1;
@@ -44,60 +42,63 @@ export class WaveDemuxer extends Demuxer {
constructor(input: Input) {
super(input);
- this.metadataReader = new RiffReader(input._mainReader);
- this.chunkReader = new RiffReader(new Reader(input.source, 64 * 2 ** 20));
+ this.reader = input._reader;
}
async readMetadata() {
return this.metadataPromise ??= (async () => {
- const actualFileSize = await this.metadataReader.reader.source.getSize();
+ let slice = this.reader.requestSlice(0, 12);
+ if (slice instanceof Promise) slice = await slice;
+ assert(slice);
- const riffType = this.metadataReader.readAscii(4);
- this.metadataReader.littleEndian = riffType !== 'RIFX';
+ const riffType = readAscii(slice, 4);
+ const littleEndian = riffType !== 'RIFX';
const isRf64 = riffType === 'RF64';
- const outerChunkSize = this.metadataReader.readU32();
+ const outerChunkSize = readU32(slice, littleEndian);
- let totalFileSize = isRf64 ? actualFileSize : Math.min(outerChunkSize + 8, actualFileSize);
- const format = this.metadataReader.readAscii(4);
+ let totalFileSize = isRf64 ? this.reader.fileSize : Math.min(outerChunkSize + 8, this.reader.fileSize);
+ const format = readAscii(slice, 4);
if (format !== 'WAVE') {
throw new Error('Invalid WAVE file - wrong format');
}
- this.metadataReader.pos = 12;
let chunksRead = 0;
let dataChunkSize: number | null = null;
+ let currentPos = slice.filePos;
- while (this.metadataReader.pos < totalFileSize) {
- await this.metadataReader.reader.loadRange(this.metadataReader.pos, this.metadataReader.pos + 8);
+ while (currentPos < totalFileSize) {
+ let slice = this.reader.requestSlice(currentPos, 8);
+ if (slice instanceof Promise) slice = await slice;
+ if (!slice) break;
- const chunkId = this.metadataReader.readAscii(4);
- const chunkSize = this.metadataReader.readU32();
- const startPos = this.metadataReader.pos;
+ const chunkId = readAscii(slice, 4);
+ const chunkSize = readU32(slice, littleEndian);
+ const startPos = slice.filePos;
if (isRf64 && chunksRead === 0 && chunkId !== 'ds64') {
throw new Error('Invalid RF64 file: First chunk must be "ds64".');
}
if (chunkId === 'fmt ') {
- await this.parseFmtChunk(chunkSize);
+ await this.parseFmtChunk(startPos, chunkSize, littleEndian);
} else if (chunkId === 'data') {
dataChunkSize ??= chunkSize;
- this.dataStart = this.metadataReader.pos;
+ this.dataStart = slice.filePos;
this.dataSize = Math.min(dataChunkSize, totalFileSize - this.dataStart);
} else if (chunkId === 'ds64') {
// File and data chunk sizes are defined in here instead
- const riffChunkSize = this.metadataReader.readU64();
- dataChunkSize = this.metadataReader.readU64();
+ const riffChunkSize = readU64(slice, littleEndian);
+ dataChunkSize = readU64(slice, littleEndian);
- totalFileSize = Math.min(riffChunkSize + 8, actualFileSize);
+ totalFileSize = Math.min(riffChunkSize + 8, this.reader.fileSize);
}
- this.metadataReader.pos = startPos + chunkSize + (chunkSize & 1); // Handle padding
+ currentPos = startPos + chunkSize + (chunkSize & 1); // Handle padding
chunksRead++;
}
@@ -115,33 +116,35 @@ export class WaveDemuxer extends Demuxer {
})();
}
- private async parseFmtChunk(size: number) {
- await this.metadataReader.reader.loadRange(this.metadataReader.pos, this.metadataReader.pos + size);
+ private async parseFmtChunk(startPos: number, size: number, littleEndian: boolean) {
+ let slice = this.reader.requestSlice(startPos, size);
+ if (slice instanceof Promise) slice = await slice;
+ if (!slice) return; // File too short
- let formatTag = this.metadataReader.readU16();
- const numChannels = this.metadataReader.readU16();
- const sampleRate = this.metadataReader.readU32();
- this.metadataReader.pos += 4; // Bytes per second
- const blockAlign = this.metadataReader.readU16();
+ let formatTag = readU16(slice, littleEndian);
+ const numChannels = readU16(slice, littleEndian);
+ const sampleRate = readU32(slice, littleEndian);
+ slice.skip(4); // Bytes per second
+ const blockAlign = readU16(slice, littleEndian);
let bitsPerSample: number;
if (size === 14) { // Plain WAVEFORMAT
bitsPerSample = 8;
} else {
- bitsPerSample = this.metadataReader.readU16();
+ bitsPerSample = readU16(slice, littleEndian);
}
// Handle WAVEFORMATEXTENSIBLE
if (size >= 18 && formatTag !== 0x0165) {
- const cbSize = this.metadataReader.readU16();
+ const cbSize = readU16(slice, littleEndian);
const remainingSize = size - 18;
const extensionSize = Math.min(remainingSize, cbSize);
if (extensionSize >= 22 && formatTag === WaveFormat.EXTENSIBLE) {
// Parse WAVEFORMATEXTENSIBLE
- this.metadataReader.pos += 2 + 4;
- const subFormat = this.metadataReader.readBytes(16);
+ slice.skip(2 + 4);
+ const subFormat = readBytes(slice, 16);
// Get actual format from subFormat GUID
formatTag = subFormat[0]! | (subFormat[1]! << 8);
@@ -291,19 +294,11 @@ class WaveAudioTrackBacking implements InputAudioTrackBacking {
if (options.metadataOnly) {
data = PLACEHOLDER_DATA;
} else {
- const sizeOfOnePacket = PACKET_SIZE_IN_FRAMES * this.demuxer.audioInfo.blockSizeInBytes;
- const chunkSize = Math.ceil(2 ** 19 / sizeOfOnePacket) * sizeOfOnePacket;
- const chunkStart = Math.floor(startOffset / chunkSize) * chunkSize;
- const chunkEnd = chunkStart + chunkSize;
+ let slice = this.demuxer.reader.requestSlice(this.demuxer.dataStart + startOffset, sizeInBytes);
+ if (slice instanceof Promise) slice = await slice;
+ assert(slice);
- // Always load large 0.5 MiB chunks instead of just the required packet
- await this.demuxer.chunkReader.reader.loadRange(
- this.demuxer.dataStart + chunkStart,
- this.demuxer.dataStart + chunkEnd,
- );
-
- this.demuxer.chunkReader.pos = this.demuxer.dataStart + startOffset;
- data = this.demuxer.chunkReader.readBytes(sizeInBytes);
+ data = readBytes(slice, sizeInBytes);
}
const timestamp = packetIndex * PACKET_SIZE_IN_FRAMES / this.demuxer.audioInfo.sampleRate;
diff --git a/test/files/video.mp4 b/test/files/video.mp4
new file mode 100644
index 0000000..75b02b4
Binary files /dev/null and b/test/files/video.mp4 differ
diff --git a/test/read-mp4.test.ts b/test/read-mp4.test.ts
new file mode 100644
index 0000000..b5d0407
--- /dev/null
+++ b/test/read-mp4.test.ts
@@ -0,0 +1,33 @@
+import { expect, test } from 'vitest';
+import { ALL_FORMATS, MP4, EncodedPacketSink, Input, FilePathSource } from '../src/index.js';
+
+const __dirname = new URL('.', import.meta.url).pathname;
+
+test('Should be able to get packets from a .MP4 file', async () => {
+ const input = new Input({
+ source: new FilePathSource(`${__dirname}/files/video.mp4`),
+ formats: ALL_FORMATS,
+ });
+
+ expect(await input.getFormat()).toBe(MP4);
+ expect(await input.getMimeType()).toBe('video/mp4; codecs="avc1.640028, mp4a.40.2"');
+ expect(await input.computeDuration()).toBe(5.056);
+
+ const track = await input.getPrimaryVideoTrack();
+ if (!track) throw new Error('No video track found');
+
+ const sink = new EncodedPacketSink(track);
+
+ let samples = 0;
+ const timestamps: number[] = [];
+
+ for await (const packet of sink.packets()) {
+ timestamps.push(packet.timestamp);
+ samples++;
+ }
+
+ expect(samples).toBe(125);
+ expect(timestamps.slice(0, 10)).toEqual([
+ 0, 0.16, 0.08, 0.04, 0.12, 0.32, 0.24, 0.2, 0.28, 0.48,
+ ]);
+});
diff --git a/test/tsconfig.json b/test/tsconfig.json
new file mode 100644
index 0000000..461d735
--- /dev/null
+++ b/test/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "extends": "../tsconfig.json",
+ "compilerOptions": {
+ "noEmit": true,
+ "moduleResolution": "nodenext",
+ "module": "NodeNext",
+ "declaration": true,
+ "declarationMap": true,
+ "stripInternal": true
+ },
+ "include": ["**/*"],
+ "references": [{ "path": "../src" }]
+}
diff --git a/tsconfig.json b/tsconfig.json
index 16b32ec..9f6de94 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,6 +1,7 @@
{
"compilerOptions": {
"target": "ES2021",
+ "module": "esnext",
"strict": true,
"noImplicitAny": true,
"noImplicitOverride": true,
@@ -8,9 +9,7 @@
"noPropertyAccessFromIndexSignature": true,
"skipLibCheck": true,
"allowJs": true,
- "noEmit": true
+ "noEmit": true,
},
- "references": [
- { "path": "./tsconfig.vite.json" }
- ]
-}
\ No newline at end of file
+ "references": [{ "path": "./tsconfig.vite.json" }]
+}