diff --git a/examples/metadata-extraction/metadata-extraction.ts b/examples/metadata-extraction/metadata-extraction.ts
index f2d3fa8..8cef42c 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 be HTTPS and support cross-origin requests, so have the'
+ + ' right CORS headers set.',
+ 'https://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..b71b973 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 be HTTPS and support cross-origin requests, so have the'
+ + ' right CORS headers set.',
+ 'https://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 dcbe926..be2fc0f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "mediabunny",
- "version": "1.11.0",
+ "version": "1.13.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mediabunny",
- "version": "1.11.0",
+ "version": "1.13.1",
"license": "MPL-2.0",
"workspaces": [
"packages/*"
@@ -24,6 +24,7 @@
"@tailwindcss/vite": "^4.1.7",
"@types/markdown-it-footnote": "^3.0.4",
"@types/node": "^22.13.10",
+ "@vitest/browser": "3.2.4",
"esbuild": "^0.25.1",
"esbuild-plugin-external-global": "^1.0.1",
"eslint": "^9.22.0",
@@ -38,7 +39,9 @@
"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",
+ "webdriverio": "9.19.2"
},
"funding": {
"type": "individual",
@@ -323,6 +326,21 @@
"url": "https://github.com/sponsors/antfu"
}
},
+ "node_modules/@babel/code-frame": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
+ "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.27.1",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
@@ -359,6 +377,16 @@
"node": ">=6.0.0"
}
},
+ "node_modules/@babel/runtime": {
+ "version": "7.28.3",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.3.tgz",
+ "integrity": "sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/types": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.0.tgz",
@@ -1229,6 +1257,109 @@
"node": "20 || >=22"
}
},
+ "node_modules/@isaacs/cliui": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
+ "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^5.1.2",
+ "string-width-cjs": "npm:string-width@^4.2.0",
+ "strip-ansi": "^7.0.1",
+ "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
+ "wrap-ansi": "^8.1.0",
+ "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@isaacs/cliui/node_modules/ansi-regex": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz",
+ "integrity": "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/@isaacs/cliui/node_modules/ansi-styles": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
+ "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/@isaacs/cliui/node_modules/emoji-regex": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
+ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@isaacs/cliui/node_modules/string-width": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
+ "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eastasianwidth": "^0.2.0",
+ "emoji-regex": "^9.2.2",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@isaacs/cliui/node_modules/strip-ansi": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
+ "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/@isaacs/cliui/node_modules/wrap-ansi": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
+ "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.1.0",
+ "string-width": "^5.0.1",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
@@ -1412,6 +1543,79 @@
"node": ">= 8"
}
},
+ "node_modules/@pkgjs/parseargs": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
+ "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@polka/url": {
+ "version": "1.0.0-next.29",
+ "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
+ "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@promptbook/utils": {
+ "version": "0.69.5",
+ "resolved": "https://registry.npmjs.org/@promptbook/utils/-/utils-0.69.5.tgz",
+ "integrity": "sha512-xm5Ti/Hp3o4xHrsK9Yy3MS6KbDxYbq485hDsFvxqaNA7equHLPdo8H8faTitTeb14QCDfLW4iwCxdVYu5sn6YQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://buymeacoffee.com/hejny"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/webgptorg/promptbook/blob/main/README.md#%EF%B8%8F-contributing"
+ }
+ ],
+ "license": "CC-BY-4.0",
+ "dependencies": {
+ "spacetrim": "0.11.59"
+ }
+ },
+ "node_modules/@puppeteer/browsers": {
+ "version": "2.10.8",
+ "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.8.tgz",
+ "integrity": "sha512-f02QYEnBDE0p8cteNoPYHHjbDuwyfbe4cCIVlNi8/MRicIxFW4w4CfgU0LNgWEID6s06P+hRJ1qjpBLMhPRCiQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "debug": "^4.4.1",
+ "extract-zip": "^2.0.1",
+ "progress": "^2.0.3",
+ "proxy-agent": "^6.5.0",
+ "semver": "^7.7.2",
+ "tar-fs": "^3.1.0",
+ "yargs": "^17.7.2"
+ },
+ "bin": {
+ "browsers": "lib/cjs/main-cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@puppeteer/browsers/node_modules/semver": {
+ "version": "7.7.2",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
+ "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.37.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.37.0.tgz",
@@ -2172,6 +2376,47 @@
"vite": "^5.2.0 || ^6"
}
},
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.3.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "picocolors": "1.1.1",
+ "pretty-format": "^27.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@testing-library/user-event": {
+ "version": "14.6.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
+ "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=7.21.4"
+ }
+ },
+ "node_modules/@tootallnate/quickjs-emscripten": {
+ "version": "0.23.0",
+ "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz",
+ "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/argparse": {
"version": "1.0.38",
"resolved": "https://registry.npmjs.org/@types/argparse/-/argparse-1.0.38.tgz",
@@ -2179,6 +2424,23 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "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 +2704,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,15 +2814,44 @@
"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/sinonjs__fake-timers": {
+ "version": "8.1.5",
+ "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz",
+ "integrity": "sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -2575,6 +2873,34 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/@types/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-113D3mDkZDjo+EeUEHCFy0qniNc1ZpecGiAU7WSo7YDoSzolZIQKpYFHrPpjkB2nuyahcKfrmLXeQlh7gqJYdw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/ws": {
+ "version": "8.18.1",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
+ "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/yauzl": {
+ "version": "2.10.3",
+ "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
+ "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.26.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.26.1.tgz",
@@ -2815,6 +3141,167 @@
"vue": "^3.2.25"
}
},
+ "node_modules/@vitest/browser": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-3.2.4.tgz",
+ "integrity": "sha512-tJxiPrWmzH8a+w9nLKlQMzAKX/7VjFs50MWgcAj7p9XQ7AQ9/35fByFYptgPELyLw+0aixTnC4pUWV+APcZ/kw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@testing-library/dom": "^10.4.0",
+ "@testing-library/user-event": "^14.6.1",
+ "@vitest/mocker": "3.2.4",
+ "@vitest/utils": "3.2.4",
+ "magic-string": "^0.30.17",
+ "sirv": "^3.0.1",
+ "tinyrainbow": "^2.0.0",
+ "ws": "^8.18.2"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "playwright": "*",
+ "vitest": "3.2.4",
+ "webdriverio": "^7.0.0 || ^8.0.0 || ^9.0.0"
+ },
+ "peerDependenciesMeta": {
+ "playwright": {
+ "optional": true
+ },
+ "safaridriver": {
+ "optional": true
+ },
+ "webdriverio": {
+ "optional": true
+ }
+ }
+ },
+ "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",
@@ -3066,6 +3553,187 @@
"url": "https://github.com/sponsors/antfu"
}
},
+ "node_modules/@wdio/config": {
+ "version": "9.19.2",
+ "resolved": "https://registry.npmjs.org/@wdio/config/-/config-9.19.2.tgz",
+ "integrity": "sha512-OVCzPQxav0QDk5rktQ6LYARZ5ueUuJXIqTXUpS3A9Jt6PF+ZUI5sbO/y+z+qHQXqDq+LkscmFsmkzgnoHzHcfg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@wdio/logger": "9.18.0",
+ "@wdio/types": "9.19.2",
+ "@wdio/utils": "9.19.2",
+ "deepmerge-ts": "^7.0.3",
+ "glob": "^10.2.2",
+ "import-meta-resolve": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=18.20.0"
+ }
+ },
+ "node_modules/@wdio/logger": {
+ "version": "9.18.0",
+ "resolved": "https://registry.npmjs.org/@wdio/logger/-/logger-9.18.0.tgz",
+ "integrity": "sha512-HdzDrRs+ywAqbXGKqe1i/bLtCv47plz4TvsHFH3j729OooT5VH38ctFn5aLXgECmiAKDkmH/A6kOq2Zh5DIxww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chalk": "^5.1.2",
+ "loglevel": "^1.6.0",
+ "loglevel-plugin-prefix": "^0.8.4",
+ "safe-regex2": "^5.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18.20.0"
+ }
+ },
+ "node_modules/@wdio/logger/node_modules/ansi-regex": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz",
+ "integrity": "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/@wdio/logger/node_modules/chalk": {
+ "version": "5.6.0",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.0.tgz",
+ "integrity": "sha512-46QrSQFyVSEyYAgQ22hQ+zDa60YHA4fBstHmtSApj1Y5vKtG27fWowW03jCk5KcbXEWPZUIR894aARCA/G1kfQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.17.0 || ^14.13 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/@wdio/logger/node_modules/strip-ansi": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
+ "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/@wdio/protocols": {
+ "version": "9.16.2",
+ "resolved": "https://registry.npmjs.org/@wdio/protocols/-/protocols-9.16.2.tgz",
+ "integrity": "sha512-h3k97/lzmyw5MowqceAuY3HX/wGJojXHkiPXA3WlhGPCaa2h4+GovV2nJtRvknCKsE7UHA1xB5SWeI8MzloBew==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@wdio/repl": {
+ "version": "9.16.2",
+ "resolved": "https://registry.npmjs.org/@wdio/repl/-/repl-9.16.2.tgz",
+ "integrity": "sha512-FLTF0VL6+o5BSTCO7yLSXocm3kUnu31zYwzdsz4n9s5YWt83sCtzGZlZpt7TaTzb3jVUfxuHNQDTb8UMkCu0lQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "^20.1.0"
+ },
+ "engines": {
+ "node": ">=18.20.0"
+ }
+ },
+ "node_modules/@wdio/repl/node_modules/@types/node": {
+ "version": "20.19.11",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.11.tgz",
+ "integrity": "sha512-uug3FEEGv0r+jrecvUUpbY8lLisvIjg6AAic6a2bSP5OEOLeJsDSnvhCDov7ipFFMXS3orMpzlmi0ZcuGkBbow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@wdio/types": {
+ "version": "9.19.2",
+ "resolved": "https://registry.npmjs.org/@wdio/types/-/types-9.19.2.tgz",
+ "integrity": "sha512-fBI7ljL+YcPXSXUhdk2+zVuz7IYP1aDMTq1eVmMme9GY0y67t0dCNPOt6xkCAEdL5dOcV6D2L1r6Cf/M2ifTvQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "^20.1.0"
+ },
+ "engines": {
+ "node": ">=18.20.0"
+ }
+ },
+ "node_modules/@wdio/types/node_modules/@types/node": {
+ "version": "20.19.11",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.11.tgz",
+ "integrity": "sha512-uug3FEEGv0r+jrecvUUpbY8lLisvIjg6AAic6a2bSP5OEOLeJsDSnvhCDov7ipFFMXS3orMpzlmi0ZcuGkBbow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@wdio/utils": {
+ "version": "9.19.2",
+ "resolved": "https://registry.npmjs.org/@wdio/utils/-/utils-9.19.2.tgz",
+ "integrity": "sha512-caimJiTsxDUfXn/gRAzcYTO3RydSl7XzD+QpjfWZYJjzr8a2XfNnj+Vdmr8gG4BSkiVHirW9mFCZeQp2eTD7rA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@puppeteer/browsers": "^2.2.0",
+ "@wdio/logger": "9.18.0",
+ "@wdio/types": "9.19.2",
+ "decamelize": "^6.0.0",
+ "deepmerge-ts": "^7.0.3",
+ "edgedriver": "^6.1.2",
+ "geckodriver": "^5.0.0",
+ "get-port": "^7.0.0",
+ "import-meta-resolve": "^4.0.0",
+ "locate-app": "^2.2.24",
+ "mitt": "^3.0.1",
+ "safaridriver": "^1.0.0",
+ "split2": "^4.2.0",
+ "wait-port": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=18.20.0"
+ }
+ },
+ "node_modules/@zip.js/zip.js": {
+ "version": "2.7.73",
+ "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.7.73.tgz",
+ "integrity": "sha512-I2UP8/rdQE5hTtVVL08B7P8XuwXiKuuMUPjNuFOVL/9b+8IsExR9S5jz2H58u0rJjU4M1BikLgqEMG8gZJZVBw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "bun": ">=0.7.0",
+ "deno": ">=1.0.0",
+ "node": ">=16.5.0"
+ }
+ },
+ "node_modules/abort-controller": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
+ "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "event-target-shim": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=6.5"
+ }
+ },
"node_modules/acorn": {
"version": "8.14.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
@@ -3087,6 +3755,16 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
+ "node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/ajv": {
"version": "8.12.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz",
@@ -3196,6 +3874,44 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
+ "node_modules/archiver": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz",
+ "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "archiver-utils": "^5.0.2",
+ "async": "^3.2.4",
+ "buffer-crc32": "^1.0.0",
+ "readable-stream": "^4.0.0",
+ "readdir-glob": "^1.1.2",
+ "tar-stream": "^3.0.0",
+ "zip-stream": "^6.0.1"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/archiver-utils": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz",
+ "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "glob": "^10.0.0",
+ "graceful-fs": "^4.2.0",
+ "is-stream": "^2.0.1",
+ "lazystream": "^1.0.0",
+ "lodash": "^4.17.15",
+ "normalize-path": "^3.0.0",
+ "readable-stream": "^4.0.0"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
@@ -3206,6 +3922,53 @@
"sprintf-js": "~1.0.2"
}
},
+ "node_modules/aria-query": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
+ "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/ast-types": {
+ "version": "0.13.4",
+ "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz",
+ "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/async": {
+ "version": "3.2.6",
+ "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
+ "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/b4a": {
+ "version": "1.6.7",
+ "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz",
+ "integrity": "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
"node_modules/bail": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
@@ -3223,6 +3986,114 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true
},
+ "node_modules/bare-events": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.6.1.tgz",
+ "integrity": "sha512-AuTJkq9XmE6Vk0FJVNq5QxETrSA/vKHarWVBG5l/JbdCL1prJemiyJqUS0jrlXO0MftuPq4m3YVYhoNc5+aE/g==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true
+ },
+ "node_modules/bare-fs": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.2.2.tgz",
+ "integrity": "sha512-5vn+bdnlCYMwETIm1FqQXDP6TYPbxr2uJd88ve40kr4oPbiTZJVrTNzqA3/4sfWZeWKuQR/RkboBt7qEEDtfMA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "bare-events": "^2.5.4",
+ "bare-path": "^3.0.0",
+ "bare-stream": "^2.6.4"
+ },
+ "engines": {
+ "bare": ">=1.16.0"
+ },
+ "peerDependencies": {
+ "bare-buffer": "*"
+ },
+ "peerDependenciesMeta": {
+ "bare-buffer": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/bare-os": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz",
+ "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "engines": {
+ "bare": ">=1.14.0"
+ }
+ },
+ "node_modules/bare-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz",
+ "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "bare-os": "^3.0.1"
+ }
+ },
+ "node_modules/bare-stream": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz",
+ "integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "streamx": "^2.21.0"
+ },
+ "peerDependencies": {
+ "bare-buffer": "*",
+ "bare-events": "*"
+ },
+ "peerDependenciesMeta": {
+ "bare-buffer": {
+ "optional": true
+ },
+ "bare-events": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/basic-ftp": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz",
+ "integrity": "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
"node_modules/birpc": {
"version": "0.2.19",
"resolved": "https://registry.npmjs.org/birpc/-/birpc-0.2.19.tgz",
@@ -3262,6 +4133,41 @@
"node": ">=8"
}
},
+ "node_modules/buffer": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
+ "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.2.1"
+ }
+ },
+ "node_modules/buffer-crc32": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz",
+ "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
"node_modules/byte-size": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/byte-size/-/byte-size-9.0.1.tgz",
@@ -3280,6 +4186,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 +4217,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 +4295,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",
@@ -3495,6 +4438,23 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/compress-commons": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz",
+ "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "crc-32": "^1.2.0",
+ "crc32-stream": "^6.0.0",
+ "is-stream": "^2.0.1",
+ "normalize-path": "^3.0.0",
+ "readable-stream": "^4.0.0"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -3523,6 +4483,13 @@
"url": "https://github.com/sponsors/mesqueeb"
}
},
+ "node_modules/core-util-is": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/cose-base": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz",
@@ -3532,6 +4499,33 @@
"layout-base": "^1.0.0"
}
},
+ "node_modules/crc-32": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
+ "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "crc32": "bin/crc32.njs"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/crc32-stream": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz",
+ "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "crc-32": "^1.2.0",
+ "readable-stream": "^4.0.0"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3562,6 +4556,19 @@
"url": "https://github.com/sponsors/fb55"
}
},
+ "node_modules/css-shorthand-properties": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/css-shorthand-properties/-/css-shorthand-properties-1.1.2.tgz",
+ "integrity": "sha512-C2AugXIpRGQTxaCW0N7n5jD/p5irUmCrwl03TrnMFBHDbdq44CFWR2zO7rK9xPN4Eo3pUxC4vQzQgbIpzrD1PQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/css-value": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/css-value/-/css-value-0.0.1.tgz",
+ "integrity": "sha512-FUV3xaJ63buRLgHrLQVlVgQnQdR4yqdLGaDu7g8CQcWjInDfM9plBTPI9FRfpahju1UBSaMckeb2/46ApS/V1Q==",
+ "dev": true
+ },
"node_modules/css-what": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz",
@@ -4089,6 +5096,16 @@
"lodash-es": "^4.17.21"
}
},
+ "node_modules/data-uri-to-buffer": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz",
+ "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/dayjs": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
@@ -4096,10 +5113,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"
},
@@ -4112,6 +5130,19 @@
}
}
},
+ "node_modules/decamelize": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-6.0.1.tgz",
+ "integrity": "sha512-G7Cqgaelq68XHJNGlZ7lrNQyhZGsFqpwtGFexqUv4IQdjKoSYF7ipZ9UuTJZUSQXFj/XaoBLuEVIVqr8EJngEQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/decode-named-character-reference": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz",
@@ -4126,12 +5157,47 @@
"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",
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"dev": true
},
+ "node_modules/deepmerge-ts": {
+ "version": "7.1.5",
+ "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
+ "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/degenerator": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz",
+ "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ast-types": "^0.13.4",
+ "escodegen": "^2.1.0",
+ "esprima": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/delaunator": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz",
@@ -4175,6 +5241,13 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/dom-serializer": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz",
@@ -4248,6 +5321,110 @@
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
+ "node_modules/eastasianwidth": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
+ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/edge-paths": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/edge-paths/-/edge-paths-3.0.5.tgz",
+ "integrity": "sha512-sB7vSrDnFa4ezWQk9nZ/n0FdpdUuC6R1EOrlU3DL+bovcNFK28rqu2emmAUjujYEJTWIgQGqgVVWUZXMnc8iWg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/which": "^2.0.1",
+ "which": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/shirshak55"
+ }
+ },
+ "node_modules/edgedriver": {
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/edgedriver/-/edgedriver-6.1.2.tgz",
+ "integrity": "sha512-UvFqd/IR81iPyWMcxXbUNi+xKWR7JjfoHjfuwjqsj9UHQKn80RpQmS0jf+U25IPi+gKVPcpOSKm0XkqgGMq4zQ==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "@wdio/logger": "^9.1.3",
+ "@zip.js/zip.js": "^2.7.53",
+ "decamelize": "^6.0.0",
+ "edge-paths": "^3.0.5",
+ "fast-xml-parser": "^5.0.8",
+ "http-proxy-agent": "^7.0.2",
+ "https-proxy-agent": "^7.0.5",
+ "node-fetch": "^3.3.2",
+ "which": "^5.0.0"
+ },
+ "bin": {
+ "edgedriver": "bin/edgedriver.js"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/edgedriver/node_modules/data-uri-to-buffer": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
+ "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/edgedriver/node_modules/isexe": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz",
+ "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/edgedriver/node_modules/node-fetch": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
+ "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "data-uri-to-buffer": "^4.0.0",
+ "fetch-blob": "^3.1.4",
+ "formdata-polyfill": "^4.0.10"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/node-fetch"
+ }
+ },
+ "node_modules/edgedriver/node_modules/which": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz",
+ "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^3.1.1"
+ },
+ "bin": {
+ "node-which": "bin/which.js"
+ },
+ "engines": {
+ "node": "^18.17.0 || >=20.5.0"
+ }
+ },
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@@ -4262,6 +5439,30 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/encoding-sniffer": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
+ "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "iconv-lite": "^0.6.3",
+ "whatwg-encoding": "^3.1.1"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
+ }
+ },
+ "node_modules/end-of-stream": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
+ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
"node_modules/enhanced-resolve": {
"version": "5.18.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz",
@@ -4289,6 +5490,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",
@@ -4371,6 +5579,28 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/escodegen": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz",
+ "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "esprima": "^4.0.1",
+ "estraverse": "^5.2.0",
+ "esutils": "^2.0.2"
+ },
+ "bin": {
+ "escodegen": "bin/escodegen.js",
+ "esgenerate": "bin/esgenerate.js"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "optionalDependencies": {
+ "source-map": "~0.6.1"
+ }
+ },
"node_modules/eslint": {
"version": "9.22.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.22.0.tgz",
@@ -4585,6 +5815,36 @@
"node": ">=0.10.0"
}
},
+ "node_modules/event-target-shim": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
+ "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/events": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
+ "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.x"
+ }
+ },
+ "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",
@@ -4611,12 +5871,40 @@
"node": ">=0.10.0"
}
},
+ "node_modules/extract-zip": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
+ "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "debug": "^4.1.1",
+ "get-stream": "^5.1.0",
+ "yauzl": "^2.10.0"
+ },
+ "bin": {
+ "extract-zip": "cli.js"
+ },
+ "engines": {
+ "node": ">= 10.17.0"
+ },
+ "optionalDependencies": {
+ "@types/yauzl": "^2.9.1"
+ }
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true
},
+ "node_modules/fast-fifo": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
+ "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -4659,6 +5947,25 @@
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"dev": true
},
+ "node_modules/fast-xml-parser": {
+ "version": "5.2.5",
+ "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz",
+ "integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "strnum": "^2.1.0"
+ },
+ "bin": {
+ "fxparser": "src/cli/cli.js"
+ }
+ },
"node_modules/fastq": {
"version": "1.19.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
@@ -4683,6 +5990,40 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/fd-slicer": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
+ "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pend": "~1.2.0"
+ }
+ },
+ "node_modules/fetch-blob": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
+ "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/jimmywarting"
+ },
+ {
+ "type": "paypal",
+ "url": "https://paypal.me/jimmywarting"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "node-domexception": "^1.0.0",
+ "web-streams-polyfill": "^3.0.3"
+ },
+ "engines": {
+ "node": "^12.20 || >= 14.13"
+ }
+ },
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -4770,6 +6111,23 @@
"tabbable": "^6.2.0"
}
},
+ "node_modules/foreground-child": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
+ "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "cross-spawn": "^7.0.6",
+ "signal-exit": "^4.0.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/format": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz",
@@ -4779,6 +6137,19 @@
"node": ">=0.4.x"
}
},
+ "node_modules/formdata-polyfill": {
+ "version": "4.0.10",
+ "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
+ "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fetch-blob": "^3.1.2"
+ },
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
"node_modules/fs-extra": {
"version": "11.3.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz",
@@ -4818,6 +6189,85 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/geckodriver": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/geckodriver/-/geckodriver-5.0.0.tgz",
+ "integrity": "sha512-vn7TtQ3b9VMJtVXsyWtQQl1fyBVFhQy7UvJF96kPuuJ0or5THH496AD3eUyaDD11+EqCxH9t6V+EP9soZQk4YQ==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "@wdio/logger": "^9.1.3",
+ "@zip.js/zip.js": "^2.7.53",
+ "decamelize": "^6.0.0",
+ "http-proxy-agent": "^7.0.2",
+ "https-proxy-agent": "^7.0.5",
+ "node-fetch": "^3.3.2",
+ "tar-fs": "^3.0.6",
+ "which": "^5.0.0"
+ },
+ "bin": {
+ "geckodriver": "bin/geckodriver.js"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/geckodriver/node_modules/data-uri-to-buffer": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
+ "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/geckodriver/node_modules/isexe": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz",
+ "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/geckodriver/node_modules/node-fetch": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
+ "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "data-uri-to-buffer": "^4.0.0",
+ "fetch-blob": "^3.1.4",
+ "formdata-polyfill": "^4.0.10"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/node-fetch"
+ }
+ },
+ "node_modules/geckodriver/node_modules/which": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz",
+ "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^3.1.1"
+ },
+ "bin": {
+ "node-which": "bin/which.js"
+ },
+ "engines": {
+ "node": "^18.17.0 || >=20.5.0"
+ }
+ },
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@@ -4828,6 +6278,35 @@
"node": "6.* || 8.* || >= 10.*"
}
},
+ "node_modules/get-port": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.1.0.tgz",
+ "integrity": "sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/get-stream": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
+ "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pump": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/get-tsconfig": {
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.0.tgz",
@@ -4841,6 +6320,42 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
+ "node_modules/get-uri": {
+ "version": "6.0.5",
+ "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz",
+ "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "basic-ftp": "^5.0.2",
+ "data-uri-to-buffer": "^6.0.2",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/glob": {
+ "version": "10.4.5",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
+ "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "foreground-child": "^3.1.0",
+ "jackspeak": "^3.1.2",
+ "minimatch": "^9.0.4",
+ "minipass": "^7.1.2",
+ "package-json-from-dist": "^1.0.0",
+ "path-scurry": "^1.11.1"
+ },
+ "bin": {
+ "glob": "dist/esm/bin.mjs"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -4853,6 +6368,32 @@
"node": ">=10.13.0"
}
},
+ "node_modules/glob/node_modules/brace-expansion": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+ "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/glob/node_modules/minimatch": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+ "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/globals": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
@@ -4873,6 +6414,13 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/grapheme-splitter": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz",
+ "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/graphemer": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
@@ -4993,6 +6541,13 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/htmlfy": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/htmlfy/-/htmlfy-0.8.1.tgz",
+ "integrity": "sha512-xWROBw9+MEGwxpotll0h672KCaLrKKiCYzsyN8ZgL9cQbVumFnyvsk2JqiB9ELAV1GLj1GG/jxZUjV9OZZi/yQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/htmlparser2": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz",
@@ -5021,6 +6576,34 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
+ "node_modules/http-proxy-agent": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -5033,6 +6616,27 @@
"node": ">=0.10.0"
}
},
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -5042,6 +6646,13 @@
"node": ">= 4"
}
},
+ "node_modules/immediate": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
+ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -5069,6 +6680,17 @@
"node": ">=8"
}
},
+ "node_modules/import-meta-resolve": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz",
+ "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/imurmurhash": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
@@ -5078,6 +6700,13 @@
"node": ">=0.8.19"
}
},
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
@@ -5087,6 +6716,16 @@
"node": ">=12"
}
},
+ "node_modules/ip-address": {
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
+ "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
"node_modules/is-core-module": {
"version": "2.15.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz",
@@ -5166,6 +6805,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/is-stream": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
+ "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/is-what": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.16.tgz",
@@ -5179,12 +6831,35 @@
"url": "https://github.com/sponsors/mesqueeb"
}
},
+ "node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true
},
+ "node_modules/jackspeak": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
+ "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "@isaacs/cliui": "^8.0.2"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ },
+ "optionalDependencies": {
+ "@pkgjs/parseargs": "^0.11.0"
+ }
+ },
"node_modules/jiti": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
@@ -5207,9 +6882,7 @@
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true
+ "license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.0",
@@ -5263,6 +6936,52 @@
"graceful-fs": "^4.1.6"
}
},
+ "node_modules/jszip": {
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
+ "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
+ "dev": true,
+ "license": "(MIT OR GPL-3.0-or-later)",
+ "dependencies": {
+ "lie": "~3.3.0",
+ "pako": "~1.0.2",
+ "readable-stream": "~2.3.6",
+ "setimmediate": "^1.0.5"
+ }
+ },
+ "node_modules/jszip/node_modules/readable-stream": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/jszip/node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/jszip/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
"node_modules/juice": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/juice/-/juice-8.1.0.tgz",
@@ -5360,6 +7079,52 @@
"integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==",
"dev": true
},
+ "node_modules/lazystream": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
+ "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "readable-stream": "^2.0.5"
+ },
+ "engines": {
+ "node": ">= 0.6.3"
+ }
+ },
+ "node_modules/lazystream/node_modules/readable-stream": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/lazystream/node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lazystream/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -5373,6 +7138,16 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/lie": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
+ "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "immediate": "~3.0.5"
+ }
+ },
"node_modules/lightningcss": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
@@ -5629,6 +7404,28 @@
"url": "https://github.com/sponsors/antfu"
}
},
+ "node_modules/locate-app": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/locate-app/-/locate-app-2.5.0.tgz",
+ "integrity": "sha512-xIqbzPMBYArJRmPGUZD9CzV9wOqmVtQnaAn3wrj3s6WYW0bQvPI7x+sPYUGmDTYMHefVK//zc6HEYZ1qnxIK+Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://buymeacoffee.com/hejny"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/hejny/locate-app/blob/main/README.md#%EF%B8%8F-contributing"
+ }
+ ],
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@promptbook/utils": "0.69.5",
+ "type-fest": "4.26.0",
+ "userhome": "1.0.1"
+ }
+ },
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -5656,12 +7453,47 @@
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
"dev": true
},
+ "node_modules/lodash.clonedeep": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
+ "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
"dev": true
},
+ "node_modules/lodash.zip": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/lodash.zip/-/lodash.zip-4.2.0.tgz",
+ "integrity": "sha512-C7IOaBBK/0gMORRBd8OETNx3kmOkgIWIPvyDpZSCTwUrpYmgZwJkjZeOD8ww4xbOUOs4/attY+pciKvadNfFbg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/loglevel": {
+ "version": "1.9.2",
+ "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
+ "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6.0"
+ },
+ "funding": {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/loglevel"
+ }
+ },
+ "node_modules/loglevel-plugin-prefix": {
+ "version": "0.8.4",
+ "resolved": "https://registry.npmjs.org/loglevel-plugin-prefix/-/loglevel-plugin-prefix-0.8.4.tgz",
+ "integrity": "sha512-WpG9CcFAOjz/FtNht+QJeGpvVl/cdR6P0z6OcXSkr8wFJOsV2GRj2j10JLfjuA4aYkcKCNIEqRGCyTife9R8/g==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/longest-streak": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
@@ -5688,6 +7520,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",
@@ -5701,6 +7540,16 @@
"node": ">=10"
}
},
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
"node_modules/magic-string": {
"version": "0.30.17",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
@@ -5900,9 +7749,9 @@
}
},
"node_modules/mediabunny": {
- "version": "1.10.0",
- "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.10.0.tgz",
- "integrity": "sha512-M2Fe9jZJ1VEjp8i7CmwBY38UDiA3V0qWIv1hbFBfL8rh27ZVQyL9f1u3FtrerlpaKT0Q+c1LHjcjVmhwp9N5kA==",
+ "version": "1.13.0",
+ "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.13.0.tgz",
+ "integrity": "sha512-eAtCVOzceN3VkExQmYrDB+3bM6d98PfB6AhgueojYG6T5SNzc4cRR2gWm0oZ1HjhNN0J4hjtvtEpJ+AAMXF/dQ==",
"license": "MPL-2.0",
"peer": true,
"workspaces": [
@@ -6586,6 +8435,16 @@
"pathe": "^2.0.1"
}
},
+ "node_modules/mrmime": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
+ "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -6617,6 +8476,37 @@
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"dev": true
},
+ "node_modules/netmask": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz",
+ "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/node-domexception": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
+ "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
+ "deprecated": "Use your platform's native DOMException instead",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/jimmywarting"
+ },
+ {
+ "type": "github",
+ "url": "https://paypal.me/jimmywarting"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.5.0"
+ }
+ },
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
@@ -6644,6 +8534,16 @@
"dev": true,
"optional": true
},
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
@@ -6656,6 +8556,16 @@
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
"node_modules/oniguruma-to-es": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-3.1.1.tgz",
@@ -6715,6 +8625,47 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/pac-proxy-agent": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz",
+ "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@tootallnate/quickjs-emscripten": "^0.23.0",
+ "agent-base": "^7.1.2",
+ "debug": "^4.3.4",
+ "get-uri": "^6.0.1",
+ "http-proxy-agent": "^7.0.0",
+ "https-proxy-agent": "^7.0.6",
+ "pac-resolver": "^7.0.1",
+ "socks-proxy-agent": "^8.0.5"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/pac-resolver": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz",
+ "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "degenerator": "^5.0.0",
+ "netmask": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/package-json-from-dist": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
+ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0"
+ },
"node_modules/package-manager-detector": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-0.2.11.tgz",
@@ -6724,6 +8675,13 @@
"quansync": "^0.2.7"
}
},
+ "node_modules/pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+ "dev": true,
+ "license": "(MIT AND Zlib)"
+ },
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -6752,6 +8710,45 @@
"parse5": "^6.0.1"
}
},
+ "node_modules/parse5-parser-stream": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
+ "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "parse5": "^7.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/parse5-parser-stream/node_modules/entities": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/parse5-parser-stream/node_modules/parse5": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
"node_modules/path-data-parser": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz",
@@ -6782,6 +8779,30 @@
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"dev": true
},
+ "node_modules/path-scurry": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
+ "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "lru-cache": "^10.2.0",
+ "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/path-scurry/node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/path-to-regexp": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz",
@@ -6798,6 +8819,23 @@
"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/pend": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
+ "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
@@ -7005,6 +9043,61 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/pretty-format": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+ }
+ },
+ "node_modules/pretty-format/node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/process": {
+ "version": "0.11.10",
+ "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
+ "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6.0"
+ }
+ },
+ "node_modules/process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/progress": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
+ "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
"node_modules/property-information": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.0.0.tgz",
@@ -7016,6 +9109,54 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/proxy-agent": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz",
+ "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "^4.3.4",
+ "http-proxy-agent": "^7.0.1",
+ "https-proxy-agent": "^7.0.6",
+ "lru-cache": "^7.14.1",
+ "pac-proxy-agent": "^7.1.0",
+ "proxy-from-env": "^1.1.0",
+ "socks-proxy-agent": "^8.0.5"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/proxy-agent/node_modules/lru-cache": {
+ "version": "7.18.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
+ "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/proxy-from-env": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pump": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
+ "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -7041,6 +9182,13 @@
}
]
},
+ "node_modules/query-selector-shadow-dom": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz",
+ "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -7077,6 +9225,63 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-is": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/readable-stream": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
+ "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "abort-controller": "^3.0.0",
+ "buffer": "^6.0.3",
+ "events": "^3.3.0",
+ "process": "^0.11.10",
+ "string_decoder": "^1.3.0"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ }
+ },
+ "node_modules/readdir-glob": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz",
+ "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "minimatch": "^5.1.0"
+ }
+ },
+ "node_modules/readdir-glob/node_modules/brace-expansion": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+ "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/readdir-glob/node_modules/minimatch": {
+ "version": "5.1.6",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
+ "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/regex": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/regex/-/regex-6.0.1.tgz",
@@ -7228,6 +9433,33 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
+ "node_modules/resq": {
+ "version": "1.11.0",
+ "resolved": "https://registry.npmjs.org/resq/-/resq-1.11.0.tgz",
+ "integrity": "sha512-G10EBz+zAAy3zUd/CDoBbXRL6ia9kOo3xRHrMDsHljI0GDkhYlyjwoCx5+3eCC4swi1uCoZQhskuJkj7Gp57Bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^2.0.1"
+ }
+ },
+ "node_modules/resq/node_modules/fast-deep-equal": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz",
+ "integrity": "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/ret": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz",
+ "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
@@ -7246,6 +9478,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/rgb2hex": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/rgb2hex/-/rgb2hex-0.2.5.tgz",
+ "integrity": "sha512-22MOP1Rh7sAo1BZpDG6R5RFYzR2lYEgwq7HEmyW2qcsOqR2lQKmn+O//xV3YG/0rrhMC6KVX2hU+ZXuaw9a5bw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/robust-predicates": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
@@ -7334,6 +9573,57 @@
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
"dev": true
},
+ "node_modules/safaridriver": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/safaridriver/-/safaridriver-1.0.0.tgz",
+ "integrity": "sha512-J92IFbskyo7OYB3Dt4aTdyhag1GlInrfbPCmMteb7aBK7PwlnGz1HI0+oyNN97j7pV9DqUAVoVgkNRMrfY47mQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safe-regex2": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz",
+ "integrity": "sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "ret": "~0.5.0"
+ }
+ },
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -7378,6 +9668,42 @@
"node": ">=10"
}
},
+ "node_modules/serialize-error": {
+ "version": "12.0.0",
+ "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-12.0.0.tgz",
+ "integrity": "sha512-ZYkZLAvKTKQXWuh5XpBw7CdbSzagarX39WyZ2H07CDLC5/KfsRGlIXV8d4+tfqX1M7916mRqR1QfNHSij+c9Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "type-fest": "^4.31.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/serialize-error/node_modules/type-fest": {
+ "version": "4.41.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
+ "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/setimmediate": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
+ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -7416,6 +9742,41 @@
"@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/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/sirv": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.1.tgz",
+ "integrity": "sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@polka/url": "^1.0.0-next.24",
+ "mrmime": "^2.0.0",
+ "totalist": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/slick": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/slick/-/slick-1.12.2.tgz",
@@ -7425,6 +9786,47 @@
"node": "*"
}
},
+ "node_modules/smart-buffer": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
+ "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6.0.0",
+ "npm": ">= 3.0.0"
+ }
+ },
+ "node_modules/socks": {
+ "version": "2.8.7",
+ "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
+ "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ip-address": "^10.0.1",
+ "smart-buffer": "^4.2.0"
+ },
+ "engines": {
+ "node": ">= 10.0.0",
+ "npm": ">= 3.0.0"
+ }
+ },
+ "node_modules/socks-proxy-agent": {
+ "version": "8.0.5",
+ "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",
+ "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "^4.3.4",
+ "socks": "^2.8.3"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -7455,6 +9857,23 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/spacetrim": {
+ "version": "0.11.59",
+ "resolved": "https://registry.npmjs.org/spacetrim/-/spacetrim-0.11.59.tgz",
+ "integrity": "sha512-lLYsktklSRKprreOm7NXReW8YiX2VBjbgmXYEziOoGf/qsJqAEACaDvoTtUOycwjpaSh+bT8eu0KrJn7UNxiCg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://buymeacoffee.com/hejny"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/hejny/spacetrim/blob/main/README.md#%EF%B8%8F-contributing"
+ }
+ ],
+ "license": "Apache-2.0"
+ },
"node_modules/speakingurl": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
@@ -7488,6 +9907,16 @@
"node": "^12.20.0 || >=14"
}
},
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
@@ -7495,6 +9924,44 @@
"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/streamx": {
+ "version": "2.22.1",
+ "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.1.tgz",
+ "integrity": "sha512-znKXEBxfatz2GBNK02kRnCXjV+AA4kjZIUxeWSr3UGirZMJfTE9uiwKHobnbgxWyL/JWro8tTq+vOqAK1/qbSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-fifo": "^1.3.2",
+ "text-decoder": "^1.1.0"
+ },
+ "optionalDependencies": {
+ "bare-events": "^2.2.0"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
"node_modules/string-argv": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz",
@@ -7520,6 +9987,22 @@
"node": ">=8"
}
},
+ "node_modules/string-width-cjs": {
+ "name": "string-width",
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/stringify-entities": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
@@ -7548,6 +10031,20 @@
"node": ">=8"
}
},
+ "node_modules/strip-ansi-cjs": {
+ "name": "strip-ansi",
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/strip-bom-string": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz",
@@ -7570,6 +10067,39 @@
"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/strnum": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz",
+ "integrity": "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/stylis": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz",
@@ -7659,6 +10189,33 @@
"node": ">=18"
}
},
+ "node_modules/tar-fs": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.0.tgz",
+ "integrity": "sha512-5Mty5y/sOF1YWj1J6GiBodjlDc05CUR8PKXrsnFAiSG0xA+GHeWLovaZPYUDXkH/1iKRf2+M5+OrRgzC7O9b7w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pump": "^3.0.0",
+ "tar-stream": "^3.1.5"
+ },
+ "optionalDependencies": {
+ "bare-fs": "^4.0.1",
+ "bare-path": "^3.0.0"
+ }
+ },
+ "node_modules/tar-stream": {
+ "version": "3.1.7",
+ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz",
+ "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "b4a": "^1.6.4",
+ "fast-fifo": "^1.2.0",
+ "streamx": "^2.15.0"
+ }
+ },
"node_modules/tar/node_modules/yallist": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
@@ -7669,6 +10226,23 @@
"node": ">=18"
}
},
+ "node_modules/text-decoder": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz",
+ "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "b4a": "^1.6.4"
+ }
+ },
+ "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 +10250,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 +10294,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",
@@ -7740,6 +10344,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/totalist": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
+ "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
@@ -7828,6 +10442,19 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/type-fest": {
+ "version": "4.26.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.26.0.tgz",
+ "integrity": "sha512-OduNjVJsFbifKb57UqZ2EMP1i4u64Xwow3NYXUtBbD4vIwJdQd4+xl8YDou1dlm4DVrtwT/7Ky8z8WyCULVfxw==",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/typescript": {
"version": "5.8.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz",
@@ -7871,10 +10498,20 @@
"integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==",
"dev": true
},
+ "node_modules/undici": {
+ "version": "6.21.3",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz",
+ "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.17"
+ }
+ },
"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"
},
@@ -8006,6 +10643,30 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/urlpattern-polyfill": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz",
+ "integrity": "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/userhome": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/userhome/-/userhome-1.0.1.tgz",
+ "integrity": "sha512-5cnLm4gseXjAclKowC4IjByaGsjtAoV6PrOQOljplNB54ReUYJP8HdAFq2muHinSDAh09PPX/uXDPfdxRHvuSA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/uuid": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
@@ -8133,6 +10794,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 +11430,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",
@@ -8817,6 +11587,34 @@
}
}
},
+ "node_modules/wait-port": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/wait-port/-/wait-port-1.1.0.tgz",
+ "integrity": "sha512-3e04qkoN3LxTMLakdqeWth8nih8usyg+sf1Bgdf9wwUkp05iuK1eSY/QpLvscT/+F/gA89+LpUmmgBtesbqI2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chalk": "^4.1.2",
+ "commander": "^9.3.0",
+ "debug": "^4.3.4"
+ },
+ "bin": {
+ "wait-port": "bin/wait-port.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/wait-port/node_modules/commander": {
+ "version": "9.5.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
+ "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.20.0 || >=14"
+ }
+ },
"node_modules/web-resource-inliner": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/web-resource-inliner/-/web-resource-inliner-6.0.1.tgz",
@@ -8873,12 +11671,323 @@
"url": "https://github.com/fb55/htmlparser2?sponsor=1"
}
},
+ "node_modules/web-streams-polyfill": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
+ "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/webdriver": {
+ "version": "9.19.2",
+ "resolved": "https://registry.npmjs.org/webdriver/-/webdriver-9.19.2.tgz",
+ "integrity": "sha512-kw6dSwNzimU8/CkGVlM36pqWHZ7BhCwV4/d8fu6rpIYGeQbPwcNc4M90TfJuzYMA7Au3NdrwT/EVQgVLQ9Ju8Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "^20.1.0",
+ "@types/ws": "^8.5.3",
+ "@wdio/config": "9.19.2",
+ "@wdio/logger": "9.18.0",
+ "@wdio/protocols": "9.16.2",
+ "@wdio/types": "9.19.2",
+ "@wdio/utils": "9.19.2",
+ "deepmerge-ts": "^7.0.3",
+ "https-proxy-agent": "^7.0.6",
+ "undici": "^6.21.3",
+ "ws": "^8.8.0"
+ },
+ "engines": {
+ "node": ">=18.20.0"
+ }
+ },
+ "node_modules/webdriver/node_modules/@types/node": {
+ "version": "20.19.11",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.11.tgz",
+ "integrity": "sha512-uug3FEEGv0r+jrecvUUpbY8lLisvIjg6AAic6a2bSP5OEOLeJsDSnvhCDov7ipFFMXS3orMpzlmi0ZcuGkBbow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/webdriverio": {
+ "version": "9.19.2",
+ "resolved": "https://registry.npmjs.org/webdriverio/-/webdriverio-9.19.2.tgz",
+ "integrity": "sha512-xP/9odQ9tt2pEuMgo0Oobklhu1lObgL1KmejZeyxVStwnrSTbFmn1AAqPq5pfXizUsyv2PR5+id9frrarx/c4w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "^20.11.30",
+ "@types/sinonjs__fake-timers": "^8.1.5",
+ "@wdio/config": "9.19.2",
+ "@wdio/logger": "9.18.0",
+ "@wdio/protocols": "9.16.2",
+ "@wdio/repl": "9.16.2",
+ "@wdio/types": "9.19.2",
+ "@wdio/utils": "9.19.2",
+ "archiver": "^7.0.1",
+ "aria-query": "^5.3.0",
+ "cheerio": "^1.0.0-rc.12",
+ "css-shorthand-properties": "^1.1.1",
+ "css-value": "^0.0.1",
+ "grapheme-splitter": "^1.0.4",
+ "htmlfy": "^0.8.1",
+ "is-plain-obj": "^4.1.0",
+ "jszip": "^3.10.1",
+ "lodash.clonedeep": "^4.5.0",
+ "lodash.zip": "^4.2.0",
+ "query-selector-shadow-dom": "^1.0.1",
+ "resq": "^1.11.0",
+ "rgb2hex": "0.2.5",
+ "serialize-error": "^12.0.0",
+ "urlpattern-polyfill": "^10.0.0",
+ "webdriver": "9.19.2"
+ },
+ "engines": {
+ "node": ">=18.20.0"
+ },
+ "peerDependencies": {
+ "puppeteer-core": ">=22.x || <=24.x"
+ },
+ "peerDependenciesMeta": {
+ "puppeteer-core": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/webdriverio/node_modules/@types/node": {
+ "version": "20.19.11",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.11.tgz",
+ "integrity": "sha512-uug3FEEGv0r+jrecvUUpbY8lLisvIjg6AAic6a2bSP5OEOLeJsDSnvhCDov7ipFFMXS3orMpzlmi0ZcuGkBbow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/webdriverio/node_modules/cheerio": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.1.2.tgz",
+ "integrity": "sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cheerio-select": "^2.1.0",
+ "dom-serializer": "^2.0.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.2.2",
+ "encoding-sniffer": "^0.2.1",
+ "htmlparser2": "^10.0.0",
+ "parse5": "^7.3.0",
+ "parse5-htmlparser2-tree-adapter": "^7.1.0",
+ "parse5-parser-stream": "^7.1.2",
+ "undici": "^7.12.0",
+ "whatwg-mimetype": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=20.18.1"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/cheerio?sponsor=1"
+ }
+ },
+ "node_modules/webdriverio/node_modules/cheerio-select": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
+ "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^1.0.0",
+ "css-select": "^5.1.0",
+ "css-what": "^6.1.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/webdriverio/node_modules/css-select": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
+ "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^1.0.0",
+ "css-what": "^6.1.0",
+ "domhandler": "^5.0.2",
+ "domutils": "^3.0.1",
+ "nth-check": "^2.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/webdriverio/node_modules/dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/webdriverio/node_modules/domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^2.3.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/webdriverio/node_modules/domutils": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
+ "node_modules/webdriverio/node_modules/htmlparser2": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz",
+ "integrity": "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==",
+ "dev": true,
+ "funding": [
+ "https://github.com/fb55/htmlparser2?sponsor=1",
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.2.1",
+ "entities": "^6.0.0"
+ }
+ },
+ "node_modules/webdriverio/node_modules/htmlparser2/node_modules/entities": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/webdriverio/node_modules/parse5": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/webdriverio/node_modules/parse5-htmlparser2-tree-adapter": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
+ "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "domhandler": "^5.0.3",
+ "parse5": "^7.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/webdriverio/node_modules/parse5/node_modules/entities": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/webdriverio/node_modules/undici": {
+ "version": "7.15.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.15.0.tgz",
+ "integrity": "sha512-7oZJCPvvMvTd0OlqWsIxTuItTpJBpU1tcbVl24FMn3xt3+VSunwUasmfPJRE57oNO1KsZ4PgA1xTdAX4hq8NyQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"dev": true
},
+ "node_modules/whatwg-encoding": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
+ "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "iconv-lite": "0.6.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+ "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
@@ -8904,6 +12013,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",
@@ -8937,6 +12063,54 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
+ "node_modules/wrap-ansi-cjs": {
+ "name": "wrap-ansi",
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/ws": {
+ "version": "8.18.3",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
+ "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
"node_modules/xmldom-sre": {
"version": "0.1.31",
"resolved": "https://registry.npmjs.org/xmldom-sre/-/xmldom-sre-0.1.31.tgz",
@@ -8963,6 +12137,21 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/yaml": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
+ "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
+ "dev": true,
+ "license": "ISC",
+ "optional": true,
+ "peer": true,
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14.6"
+ }
+ },
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
@@ -8992,6 +12181,27 @@
"node": ">=12"
}
},
+ "node_modules/yauzl": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
+ "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "buffer-crc32": "~0.2.3",
+ "fd-slicer": "~1.1.0"
+ }
+ },
+ "node_modules/yauzl/node_modules/buffer-crc32": {
+ "version": "0.2.13",
+ "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
+ "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
@@ -9004,6 +12214,21 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/zip-stream": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz",
+ "integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "archiver-utils": "^5.0.0",
+ "compress-commons": "^6.0.2",
+ "readable-stream": "^4.0.0"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
@@ -9017,7 +12242,7 @@
},
"packages/mp3-encoder": {
"name": "@mediabunny/mp3-encoder",
- "version": "1.11.0",
+ "version": "1.13.1",
"license": "MPL-2.0",
"devDependencies": {
"@types/emscripten": "^1.40.1"
diff --git a/package.json b/package.json
index 817c9c4..dd572ba 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "mediabunny",
"author": "Vanilagy",
- "version": "1.11.0",
+ "version": "1.13.1",
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
"type": "module",
"workspaces": [
@@ -27,11 +27,15 @@
"build": "./build.sh",
"watch": "tsx scripts/bundle.ts --watch",
"lint": "eslint .",
+ "test-node": "cd test && vitest node --run",
+ "test-web": "cd test && vitest browser --run --browser",
+ "test": "npm run test-node && npm run test-web",
"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",
- "docs:build": "vitepress build docs && npm run examples:build",
+ "docs:build": "npm run build && npm run docs:generate && vitepress build docs && npm run examples:build && cp dist/mediabunny.d.ts dist-docs/",
"docs:preview": "vitepress preview docs",
+ "docs:generate": "tsx scripts/generate-api-docs.ts src/index.ts packages/mp3-encoder/src/index.ts docs/api-config.json",
"dev": "vite",
"examples:build": "vite build",
"fix-build-import-paths": "tsx scripts/add-import-extensions.ts",
@@ -80,7 +84,10 @@
"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",
+ "@vitest/browser": "3.2.4",
+ "webdriverio": "9.19.2"
},
"keywords": [
"media",
diff --git a/packages/mp3-encoder/package.json b/packages/mp3-encoder/package.json
index 80ebafc..70c7ec4 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.0",
+ "version": "1.13.1",
"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..7319e2d 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;
}
@@ -200,6 +200,7 @@ class Mp3Encoder extends CustomAudioEncoder {
* }
* ```
*
+ * @group \@mediabunny/mp3-encoder
* @public
*/
export const registerMp3Encoder = () => {
diff --git a/scripts/check-docblocks.ts b/scripts/check-docblocks.ts
index 493d121..5589a9c 100644
--- a/scripts/check-docblocks.ts
+++ b/scripts/check-docblocks.ts
@@ -16,6 +16,7 @@ const checkDocblocks = (filePath: string) => {
if (
ts.isInterfaceDeclaration(node)
|| ts.isClassDeclaration(node)
+ || ts.isConstructorDeclaration(node)
|| ts.isMethodDeclaration(node)
|| ts.isGetAccessorDeclaration(node)
|| ts.isSetAccessorDeclaration(node)
@@ -60,7 +61,13 @@ const checkDocblocks = (filePath: string) => {
let name = 'anonymous';
const kind = ts.SyntaxKind[node.kind].replace(/Declaration|Statement/g, '').toLowerCase();
- if ('name' in node && node.name) {
+ if (ts.isConstructorDeclaration(node)) {
+ // For constructors, use the parent class name
+ const parent = node.parent;
+ if (ts.isClassDeclaration(parent) && parent.name) {
+ name = parent.name.text;
+ }
+ } else if ('name' in node && node.name) {
if (ts.isIdentifier(node.name)) {
name = node.name.text;
} else if ('getText' in node.name) {
@@ -137,9 +144,22 @@ const checkDocblocks = (filePath: string) => {
for (const node of jsDocNodes) {
if (ts.isJSDoc(node)) {
- const commentText = node.comment ?? '';
- if (typeof commentText !== 'string') {
- throw new Error('Can\'t handle this yet!');
+ let commentText = '';
+
+ if (typeof node.comment === 'string') {
+ commentText = node.comment;
+ } else if (Array.isArray(node.comment)) {
+ // Handle JSDoc comment parts (including @link tags)
+ commentText = node.comment
+ .map((part) => {
+ if (typeof part === 'string') {
+ return part;
+ } else if (part && typeof part === 'object' && 'text' in part) {
+ return part.text || '';
+ }
+ return '';
+ })
+ .join('');
}
// Remove all @tags with regex
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/scripts/generate-api-docs.ts b/scripts/generate-api-docs.ts
new file mode 100644
index 0000000..27af5a2
--- /dev/null
+++ b/scripts/generate-api-docs.ts
@@ -0,0 +1,1445 @@
+// This script has been 100% vibe-coded with Claude, meaning I literally haven't looked at any of the code. It's
+// probably a mess, but it solves a one-off problem where only the output matters, and the output is indeed good, which
+// is the point of a custom script for this: full, precise control.
+
+/* eslint-disable @typescript-eslint/restrict-template-expressions */
+/* eslint-disable @typescript-eslint/no-unused-vars */
+/* eslint-disable @stylistic/max-len */
+/* eslint-disable @typescript-eslint/no-explicit-any */
+/* eslint-disable @typescript-eslint/no-unsafe-member-access */
+/* eslint-disable @typescript-eslint/no-unsafe-call */
+/* eslint-disable @typescript-eslint/no-unsafe-argument */
+/* eslint-disable @stylistic/brace-style */
+/* eslint-disable @typescript-eslint/no-unsafe-assignment */
+
+import * as ts from 'typescript';
+import * as fs from 'fs';
+import * as path from 'path';
+
+const generateDocs = (entryFiles: string[], apiConfigFile: string) => {
+ const program = ts.createProgram(entryFiles, {
+ target: ts.ScriptTarget.ES2020,
+ module: ts.ModuleKind.ESNext,
+ moduleResolution: ts.ModuleResolutionKind.Node10,
+ allowJs: false,
+ declaration: true,
+ esModuleInterop: true,
+ skipLibCheck: true,
+ strict: true,
+ });
+
+ const sourceFiles = entryFiles.map((entryFile) => {
+ const sourceFile = program.getSourceFile(entryFile);
+ if (!sourceFile) {
+ throw new Error(`Could not find source file: ${entryFile}`);
+ }
+ return sourceFile;
+ });
+
+ const typeChecker = program.getTypeChecker();
+ const outputDir = path.resolve(process.cwd(), 'docs/api');
+
+ // Load API config
+ const apiConfigPath = path.resolve(process.cwd(), apiConfigFile);
+ if (!fs.existsSync(apiConfigPath)) {
+ throw new Error(`API config file not found: ${apiConfigPath}`);
+ }
+ const apiConfig: Record
= JSON.parse(fs.readFileSync(apiConfigPath, 'utf-8'));
+
+ // Extract special fields
+ const headingText = apiConfig['heading'] || 'API Reference';
+ const introText = apiConfig['intro'];
+
+ // Create a copy without the special fields for group processing
+ const groupConfig = { ...apiConfig };
+ delete groupConfig['heading'];
+ delete groupConfig['intro'];
+
+ // Clear and recreate output directory
+ if (fs.existsSync(outputDir)) {
+ fs.rmSync(outputDir, { recursive: true });
+ }
+ fs.mkdirSync(outputDir, { recursive: true });
+
+ // Collect all exported types for cross-referencing
+ const exportedTypes = new Set();
+ const classHierarchy = new Map(); // Maps parent class to array of subclasses
+ const classInstances = new Map(); // Maps class name to array of instance variable names
+
+ const collectExportedTypes = (module: ts.Symbol, visited = new Set()): void => {
+ if (visited.has(module)) return;
+ visited.add(module);
+
+ const exports = typeChecker.getExportsOfModule(module);
+ exports.forEach((exportSymbol) => {
+ const declaration = exportSymbol.valueDeclaration || exportSymbol.declarations?.[0];
+ if (!declaration) return;
+
+ // Collect classes, interfaces, types, enums, variables (only if @public)
+ if (ts.isClassDeclaration(declaration) || ts.isInterfaceDeclaration(declaration)
+ || ts.isTypeAliasDeclaration(declaration) || ts.isEnumDeclaration(declaration)
+ || ts.isVariableDeclaration(declaration)) {
+ const hasPublicTag = ts.getJSDocTags(declaration).some(tag => tag.tagName.text === 'public');
+ if (hasPublicTag) {
+ exportedTypes.add(exportSymbol.getName());
+ }
+ }
+
+ // Follow reexports
+ else if (exportSymbol.flags & ts.SymbolFlags.Alias) {
+ const aliasedSymbol = typeChecker.getAliasedSymbol(exportSymbol);
+ const aliasedDeclaration = aliasedSymbol.valueDeclaration || aliasedSymbol.declarations?.[0];
+ if (aliasedDeclaration) {
+ // Check if the aliased symbol has @public tag
+ const hasPublicTag = ts.getJSDocTags(aliasedDeclaration).some(tag => tag.tagName.text === 'public');
+ if (hasPublicTag) {
+ exportedTypes.add(exportSymbol.getName());
+ }
+
+ // Also recursively collect from the source module
+ const sourceFile = aliasedDeclaration.getSourceFile();
+ const moduleSymbol = typeChecker.getSymbolAtLocation(sourceFile);
+ if (moduleSymbol) {
+ collectExportedTypes(moduleSymbol, visited);
+ }
+ }
+ }
+ });
+ };
+
+ // Get all exported symbols recursively
+ const getAllExportedSymbols = (module: ts.Symbol, visited = new Set()): ts.Symbol[] => {
+ if (visited.has(module)) return [];
+ visited.add(module);
+
+ const exports = typeChecker.getExportsOfModule(module);
+ const symbols: ts.Symbol[] = [];
+
+ exports.forEach((exportSymbol) => {
+ const declaration = exportSymbol.valueDeclaration || exportSymbol.declarations?.[0];
+ if (!declaration) return;
+
+ // If it's a reexport, follow it recursively
+ if (exportSymbol.flags & ts.SymbolFlags.Alias) {
+ const aliasedSymbol = typeChecker.getAliasedSymbol(exportSymbol);
+ const aliasedDeclaration = aliasedSymbol.valueDeclaration || aliasedSymbol.declarations?.[0];
+ if (aliasedDeclaration) {
+ const sourceFile = aliasedDeclaration.getSourceFile();
+ const moduleSymbol = typeChecker.getSymbolAtLocation(sourceFile);
+ if (moduleSymbol) {
+ symbols.push(...getAllExportedSymbols(moduleSymbol, visited));
+ }
+ }
+ }
+ // Otherwise, add any symbol with @public tag (we'll filter by type later)
+ else {
+ const hasPublicTag = ts.getJSDocTags(declaration).some(tag => tag.tagName.text === 'public');
+ if (hasPublicTag) {
+ symbols.push(exportSymbol);
+ }
+ }
+ });
+
+ return symbols;
+ };
+
+ // Collect all exported types from all source files
+ const allModuleSymbols: ts.Symbol[] = [];
+ sourceFiles.forEach((sourceFile) => {
+ const moduleSymbol = typeChecker.getSymbolAtLocation(sourceFile);
+ if (moduleSymbol) {
+ allModuleSymbols.push(moduleSymbol);
+ collectExportedTypes(moduleSymbol);
+ }
+ });
+
+ // Helper to find all potential type references in a type string
+ const findAllTypeReferences = (typeString: string): string[] => {
+ // Match PascalCase identifiers that could be type names
+ const matches = typeString.match(/\b[A-Z][a-zA-Z0-9_]*\b/g) || [];
+ return [...new Set(matches)]; // Remove duplicates
+ };
+
+ // Helper to filter references to only exported types (excluding current type)
+ const filterToExportedTypes = (references: string[], currentTypeName?: string): string[] => {
+ return references.filter(ref => exportedTypes.has(ref) && ref !== currentTypeName);
+ };
+
+ // Helper to process {@link} tags in JSDoc comments
+ const processLinkTags = (text: string, currentTypeName?: string): string => {
+ // Replace {@link TypeName} with [TypeName](./TypeName.md) if TypeName is exported
+ // or just TypeName if not exported
+ // If TypeName is the current type, just use code formatting without link
+ return text.replace(/\{@link\s+([^}]+)\}/g, (_, typeName) => {
+ const cleanTypeName = typeName.trim();
+ if (cleanTypeName === currentTypeName) {
+ return `\`${cleanTypeName}\``;
+ }
+ if (exportedTypes.has(cleanTypeName)) {
+ return `[\`${cleanTypeName}\`](./${cleanTypeName}.md)`;
+ }
+ return `\`${cleanTypeName}\``;
+ });
+ };
+
+ // Helper to extract linked types from {@link} tags in text
+ const extractLinkedTypes = (text: string): string[] => {
+ if (!text) return [];
+ const linkMatches = text.match(/\{@link\s+([^}]+)\}/g) || [];
+ return linkMatches.map((match) => {
+ const typeName = match.replace(/\{@link\s+([^}]+)\}/, '$1').trim();
+ return typeName;
+ });
+ };
+
+ // Helper to format references with proper "and" and period
+ // Optionally filters out references that were already mentioned in @link tags
+ const formatReferences = (references: string[], linkedTypes: string[] = []): string => {
+ if (references.length === 0) return '';
+
+ // Filter out references that were already linked in the description
+ const filteredReferences = references.filter(ref => !linkedTypes.includes(ref));
+
+ if (filteredReferences.length === 0) return '';
+
+ const refLinks = filteredReferences.map(ref => `[\`${ref}\`](./${ref}.md)`);
+ const formatter = new Intl.ListFormat('en', { style: 'long', type: 'conjunction' });
+ return `\n\nSee ${formatter.format(refLinks)}.`;
+ };
+
+ // Helper to format object types with proper indentation
+ const formatObjectType = (typeText: string): string => {
+ // Remove JSDoc comments but preserve original structure
+ let lines = typeText.split('\n');
+ const result: string[] = [];
+
+ // First remove all JSDoc comments from the entire text
+ const cleanText = typeText.replace(/\/\*\*[\s\S]*?\*\//g, '');
+ lines = cleanText.split('\n');
+
+ for (const line of lines) {
+ // Skip empty lines
+ if (line.trim() === '') continue;
+
+ result.push(line);
+ }
+
+ return result.join('\n');
+ };
+
+ // Helper to get better type string representation
+ const getTypeString = (type: ts.Type): string => {
+ // Handle array types specially
+ if (typeChecker.isArrayType(type)) {
+ const elementType = typeChecker.getTypeArguments(type as ts.TypeReference)[0];
+ if (elementType) {
+ return `${getTypeString(elementType)}[]`;
+ }
+ }
+
+ // Check if it's an array-like type by checking the symbol name
+ const typeString = typeChecker.typeToString(type);
+ if (typeString === 'Array' && type.symbol && type.symbol.getName() === 'Array') {
+ // Try to get type arguments from the type reference
+ if ((type as any).typeArguments && (type as any).typeArguments.length > 0) {
+ const elementType = (type as any).typeArguments[0];
+ return `${getTypeString(elementType)}[]`;
+ }
+ // If we can't determine the element type, try looking at the declaration
+ if (type.symbol.declarations && type.symbol.declarations[0]) {
+ const declaration = type.symbol.declarations[0];
+ if (
+ ts.isTypeReferenceNode(declaration)
+ && declaration.typeArguments
+ && declaration.typeArguments.length > 0
+ ) {
+ return `${declaration.typeArguments[0]!.getText()}[]`;
+ }
+ }
+ }
+
+ return typeString;
+ };
+
+ // Helper to clean up optional parameter types
+ const cleanOptionalType = (type: string, isOptional: boolean) => {
+ let cleanedType = type;
+ if (isOptional) {
+ // Remove "| undefined" from union types for optional parameters
+ cleanedType = cleanedType.replace(/\s*\|\s*undefined$/, '').replace(/^undefined\s*\|\s*/, '');
+ }
+ // Convert string literals from double quotes to single quotes
+ cleanedType = cleanedType.replace(/"([^"]*)"/g, '\'$1\'');
+
+ // Format long union types with line breaks if they exceed 80 characters
+ // Only apply to top-level unions, not unions nested within intersections, object types, or generic types
+ if (cleanedType.includes(' | ') && cleanedType.length > 80 && !cleanedType.includes('&') && !cleanedType.includes('{') && !cleanedType.includes('<')) {
+ const unionMembers = cleanedType.split(' | ');
+ cleanedType = '\n\t| ' + unionMembers.join('\n\t| ');
+ }
+
+ return cleanedType;
+ };
+
+ // Get all exported symbols from all modules
+ const allSymbols: ts.Symbol[] = [];
+ allModuleSymbols.forEach((moduleSymbol) => {
+ allSymbols.push(...getAllExportedSymbols(moduleSymbol));
+ });
+ const indexEntries: Array<{ name: string; type: string; group: string; order: number }> = [];
+
+ // Create a map to track the order of symbols based on their appearance in the entry file
+ const symbolOrderMap = new Map();
+ let orderIndex = 0;
+
+ // Walk through all source files to establish order based on declaration/export order
+ const establishSymbolOrder = (node: ts.Node): void => {
+ if (ts.isExportDeclaration(node)) {
+ // Handle export declarations like "export { Foo } from './foo'"
+ if (node.exportClause && ts.isNamedExports(node.exportClause)) {
+ node.exportClause.elements.forEach((element) => {
+ const exportName = (element.propertyName || element.name).getText();
+ if (!symbolOrderMap.has(exportName)) {
+ symbolOrderMap.set(exportName, orderIndex++);
+ }
+ });
+ }
+ } else if (ts.isVariableStatement(node)) {
+ // Handle variable statements like "export const foo = ..."
+ if (node.modifiers?.some(mod => mod.kind === ts.SyntaxKind.ExportKeyword)) {
+ node.declarationList.declarations.forEach((declaration) => {
+ const name = declaration.name.getText();
+ if (name && !symbolOrderMap.has(name)) {
+ symbolOrderMap.set(name, orderIndex++);
+ }
+ });
+ }
+ } else if (ts.isClassDeclaration(node) || ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node) || ts.isFunctionDeclaration(node)) {
+ // Handle direct declarations
+ if (node.modifiers?.some(mod => mod.kind === ts.SyntaxKind.ExportKeyword)) {
+ const name = (node as any).name?.getText();
+ if (name && !symbolOrderMap.has(name)) {
+ symbolOrderMap.set(name, orderIndex++);
+ }
+ }
+ }
+
+ ts.forEachChild(node, establishSymbolOrder);
+ };
+
+ // Establish symbol order from all source files
+ sourceFiles.forEach((sourceFile) => {
+ establishSymbolOrder(sourceFile);
+ });
+
+ // Phase 1: Collect class hierarchy information
+ allSymbols.forEach((exportSymbol) => {
+ const declaration = exportSymbol.valueDeclaration || exportSymbol.declarations?.[0];
+ if (!declaration) return;
+
+ const hasPublicTag = ts.getJSDocTags(declaration).some(tag => tag.tagName.text === 'public');
+ if (!hasPublicTag) return;
+
+ // Collect inheritance information for classes
+ if (ts.isClassDeclaration(declaration) && declaration.heritageClauses) {
+ const symbolName = declaration.name?.getText();
+ if (!symbolName) return;
+
+ const extendsClauseNode = declaration.heritageClauses.find(
+ clause => clause.token === ts.SyntaxKind.ExtendsKeyword,
+ );
+ if (extendsClauseNode && extendsClauseNode.types[0]) {
+ const superClassName = extendsClauseNode.types[0].expression.getText();
+ if (!classHierarchy.has(superClassName)) {
+ classHierarchy.set(superClassName, []);
+ }
+ classHierarchy.get(superClassName)!.push(symbolName);
+ }
+ }
+ });
+
+ // Collect class instances
+ allSymbols.forEach((exportSymbol) => {
+ const declaration = exportSymbol.valueDeclaration || exportSymbol.declarations?.[0];
+ if (!declaration) return;
+
+ const hasPublicTag = ts.getJSDocTags(declaration).some(tag => tag.tagName.text === 'public');
+ if (!hasPublicTag) return;
+
+ if (ts.isVariableDeclaration(declaration)) {
+ const variableName = declaration.name.getText();
+ let className: string | undefined;
+ // Check explicit type annotation
+ if (declaration.type && ts.isTypeReferenceNode(declaration.type)) {
+ className = declaration.type.typeName.getText();
+ }
+ // Check initializer for constructor calls
+ else if (declaration.initializer && ts.isNewExpression(declaration.initializer)) {
+ className = declaration.initializer.expression.getText();
+ }
+ if (className && exportedTypes.has(className)) {
+ if (!classInstances.has(className)) {
+ classInstances.set(className, []);
+ }
+ classInstances.get(className)!.push(variableName);
+ }
+ }
+ });
+
+ // Phase 2: Generate documentation for each symbol
+ allSymbols.forEach((exportSymbol) => {
+ const declaration = exportSymbol.valueDeclaration || exportSymbol.declarations?.[0];
+ if (!declaration) return;
+
+ // Check if it's a supported symbol type
+ const nodeKind = ts.SyntaxKind[declaration.kind];
+ const symbolName = (declaration as any).name?.getText() || exportSymbol.getName();
+
+ // Only process symbols with @public tag
+ const hasPublicTag = ts.getJSDocTags(declaration).some(tag => tag.tagName.text === 'public');
+ if (!hasPublicTag) return;
+
+ // Check for @group tag (handle re-exports by looking at the original declaration)
+ let targetDeclaration = declaration;
+ if (exportSymbol.flags & ts.SymbolFlags.Alias) {
+ const aliasedSymbol = typeChecker.getAliasedSymbol(exportSymbol);
+ const aliasedDeclaration = aliasedSymbol.valueDeclaration || aliasedSymbol.declarations?.[0];
+ if (aliasedDeclaration) {
+ targetDeclaration = aliasedDeclaration;
+ }
+ }
+
+ const groupTag = ts.getJSDocTags(targetDeclaration).find(tag => tag.tagName.text === 'group');
+ if (!groupTag || typeof groupTag.comment !== 'string') {
+ throw new Error(`Symbol '${symbolName}' is missing @group JSDoc tag`);
+ }
+ const groupName = groupTag.comment.trim().replace(/\\(.)/g, '$1');
+
+ // Validate that the group exists in the API config
+ if (!Object.prototype.hasOwnProperty.call(groupConfig, groupName)) {
+ throw new Error(`Symbol '${symbolName}' has @group '${groupName}' which is not defined in API config`);
+ }
+
+ // Check if it's a supported type
+ if (ts.isClassDeclaration(declaration) || ts.isInterfaceDeclaration(declaration) || ts.isTypeAliasDeclaration(declaration) || ts.isVariableDeclaration(declaration)) {
+ // Supported types - continue processing
+ } else {
+ // Unsupported type - throw error with type info
+ throw new Error(`Unsupported symbol type: ${nodeKind} for symbol '${symbolName}'`);
+ }
+
+ if (!declaration.name) return;
+
+ // Handle variable declarations separately
+ if (ts.isVariableDeclaration(declaration)) {
+ const variableName = declaration.name.getText();
+
+ // Get variable description from JSDoc
+ const jsDocComment = ts.getJSDocCommentsAndTags(declaration)[0];
+ let description = '';
+ if (jsDocComment && ts.isJSDoc(jsDocComment)) {
+ const commentText = jsDocComment.comment;
+ if (typeof commentText === 'string') {
+ description = processLinkTags(commentText.trim(), variableName);
+ }
+ }
+
+ // Check if it's a function type
+ const variableType = typeChecker.getTypeAtLocation(declaration);
+ const isFunctionType = variableType.getCallSignatures().length > 0;
+
+ // Add to index
+ const order = symbolOrderMap.get(variableName);
+ if (order === undefined) {
+ throw new Error(`Symbol '${variableName}' not found in entry files export order`);
+ }
+ indexEntries.push({ name: variableName, type: isFunctionType ? 'Function' : 'Constant', group: groupName, order });
+
+ if (isFunctionType) {
+ // Handle function variables like methods
+ const signature = variableType.getCallSignatures()[0];
+ if (signature) {
+ const parameters = signature.getParameters();
+ const params = parameters.map((param) => {
+ const paramDecl = param.valueDeclaration;
+ if (paramDecl && ts.isParameter(paramDecl)) {
+ const paramName = param.getName();
+ const hasQuestionToken = paramDecl.questionToken !== undefined;
+ const hasDefault = paramDecl.initializer !== undefined;
+ const isRest = paramDecl.dotDotDotToken !== undefined;
+ const rawParamType = paramDecl.type ? paramDecl.type.getText() : typeChecker.typeToString(typeChecker.getTypeOfSymbolAtLocation(param, paramDecl));
+ const paramType = cleanOptionalType(rawParamType, hasQuestionToken);
+
+ if (hasDefault) {
+ const defaultValue = paramDecl.initializer.getText();
+ return `\t${isRest ? '...' : ''}${paramName}: ${paramType} = ${defaultValue}`;
+ } else {
+ return `\t${isRest ? '...' : ''}${paramName}${hasQuestionToken ? '?' : ''}: ${paramType}`;
+ }
+ }
+ return `\t${param.getName()}: unknown`;
+ });
+
+ const returnType = getTypeString(signature.getReturnType());
+ const functionSig = params.length > 0
+ ? `${variableName}(\n${params.join(',\n')},\n): ${returnType};`
+ : `${variableName}(): ${returnType};`;
+
+ let markdown = `\n\n \n\n# ${variableName}\n\n\`\`\`ts\n${functionSig}\n\`\`\`${description ? `\n\n${description}` : ''}`;
+
+ // Find referenced types in all parameters and return type
+ const allTypeStrings = params.map(p => p.replace(/\t.*?:\s*/, '')).concat([returnType]);
+ const allReferences = filterToExportedTypes([...new Set(allTypeStrings.flatMap(findAllTypeReferences))], variableName);
+ markdown += formatReferences(allReferences);
+
+ const outputPath = path.join(outputDir, `${variableName}.md`);
+ fs.writeFileSync(outputPath, markdown);
+ console.log(`Generated: ${outputPath}`);
+ }
+ } else {
+ // Handle regular variables
+ let markdown = `\n\n \n\n# ${variableName}\n\n${description ? `${description}\n\n` : ''}`;
+ const variableValue = declaration.initializer ? declaration.initializer.getText() : 'undefined';
+ const variableDefinition = `const ${variableName} = ${variableValue};`;
+ markdown += `\`\`\`ts\n${variableDefinition}\n\`\`\``;
+
+ // Find referenced types in the variable value
+ const references = filterToExportedTypes(findAllTypeReferences(variableValue), variableName);
+ markdown += formatReferences(references);
+
+ const outputPath = path.join(outputDir, `${variableName}.md`);
+ fs.writeFileSync(outputPath, markdown);
+ console.log(`Generated: ${outputPath}`);
+ }
+
+ return;
+ }
+
+ {
+ const className = declaration.name.text;
+ const isAbstract = ts.isClassDeclaration(declaration) && declaration.modifiers?.some(mod => mod.kind === ts.SyntaxKind.AbstractKeyword);
+
+ // Add to index
+ const order = symbolOrderMap.get(className);
+ if (order === undefined) {
+ throw new Error(`Symbol '${className}' not found in entry files export order`);
+ }
+ if (isAbstract) {
+ indexEntries.push({ name: className, type: 'Abstract class', group: groupName, order });
+ } else if (ts.isClassDeclaration(declaration)) {
+ indexEntries.push({ name: className, type: 'Class', group: groupName, order });
+ } else if (ts.isTypeAliasDeclaration(declaration)) {
+ indexEntries.push({ name: className, type: 'Type', group: groupName, order });
+ } else if (ts.isInterfaceDeclaration(declaration)) {
+ indexEntries.push({ name: className, type: 'Interface', group: groupName, order });
+ }
+
+ const properties: string[] = [];
+ const events: string[] = [];
+ const methods: string[] = [];
+ const staticMethods: string[] = [];
+ let constructor: string | null = null;
+ let extendsClause = '';
+ let typeParameters: string | null = null;
+
+ // Get class description from JSDoc (or from superclass if none)
+ let description = '';
+ const jsDocComment = ts.getJSDocCommentsAndTags(declaration)[0];
+
+ if (jsDocComment && ts.isJSDoc(jsDocComment)) {
+ // First try to get the comment from the parsed JSDoc
+ const commentText = jsDocComment.comment;
+ if (typeof commentText === 'string' && commentText.trim()) {
+ description = processLinkTags(commentText.trim(), className);
+ } else {
+ // If no comment text, extract from raw source text
+ const sourceFile = declaration.getSourceFile();
+ const sourceText = sourceFile.getFullText();
+ const start = jsDocComment.getStart();
+ const end = jsDocComment.getEnd();
+ const rawJsDoc = sourceText.substring(start, end);
+
+ // Extract the content between /** and */
+ const match = rawJsDoc.match(/\/\*\*(.*?)\*\//s);
+ if (match && match[1]) {
+ const content = match[1]
+ .split('\n')
+ .map(line => line.replace(/^\s*\*\s?/, '')) // Remove leading * and spaces
+ .join('\n')
+ .trim();
+
+ // Filter out @tags but keep the description
+ const lines = content.split('\n');
+ const descLines = lines.filter(line => !line.trim().startsWith('@'));
+ const rawDesc = descLines.join('\n').trim();
+
+ if (rawDesc) {
+ description = processLinkTags(rawDesc, className);
+ }
+ }
+ }
+ }
+
+ // If no description, check superclass (only for classes/interfaces)
+ if (!description && (ts.isClassDeclaration(declaration) || ts.isInterfaceDeclaration(declaration)) && declaration.heritageClauses) {
+ const classType = typeChecker.getTypeAtLocation(declaration);
+ const baseTypes = classType.getBaseTypes();
+ if (baseTypes && baseTypes.length > 0) {
+ const baseSymbol = baseTypes[0]!.getSymbol();
+ if (baseSymbol && baseSymbol.valueDeclaration) {
+ const baseJsDoc = ts.getJSDocCommentsAndTags(baseSymbol.valueDeclaration)[0];
+ if (baseJsDoc && ts.isJSDoc(baseJsDoc) && typeof baseJsDoc.comment === 'string') {
+ description = processLinkTags(baseJsDoc.comment.trim(), className);
+ }
+ }
+ }
+ }
+
+ // Check for type parameters (only for classes/interfaces)
+ if ((ts.isClassDeclaration(declaration) || ts.isInterfaceDeclaration(declaration)) && declaration.typeParameters && declaration.typeParameters.length > 0) {
+ const typeParamStrings = declaration.typeParameters.map((tp) => {
+ const name = tp.name.text;
+ const constraint = tp.constraint ? ` extends ${tp.constraint.getText()}` : '';
+ const defaultType = tp.default ? ` = ${tp.default.getText()}` : '';
+ return `\t${name}${constraint}${defaultType}`;
+ });
+
+ const typeParamSig = `${className}<\n${typeParamStrings.join(',\n')},\n>`;
+
+ // Get type parameter descriptions
+ const typeParamDocs: string[] = [];
+ const classJsDoc = ts.getJSDocCommentsAndTags(declaration)[0];
+ if (classJsDoc && ts.isJSDoc(classJsDoc)) {
+ const templateTags = classJsDoc.tags?.filter((tag: any) => tag.tagName.text === 'template') || [];
+ templateTags.forEach((tag: any) => {
+ if (typeof tag.comment === 'string') {
+ const parts = tag.comment.trim().split(/\s+/);
+ const paramName = parts[0];
+ const paramDesc = parts.slice(1).join(' ').replace(/^-\s*/, '');
+ if (paramDesc) {
+ typeParamDocs.push(`- **${paramName}**: ${paramDesc}`);
+ }
+ }
+ });
+ }
+
+ typeParameters = `## Type parameters\n\n\`\`\`ts\n${typeParamSig}\n\`\`\``;
+ if (typeParamDocs.length > 0) {
+ typeParameters += `\n\n${typeParamDocs.join('\n')}`;
+ }
+
+ // Find referenced types in type parameters for classes/interfaces
+ const typeParamRefs: string[] = [];
+ declaration.typeParameters.forEach((tp) => {
+ if (tp.constraint) {
+ typeParamRefs.push(...findAllTypeReferences(tp.constraint.getText()));
+ }
+ if (tp.default) {
+ typeParamRefs.push(...findAllTypeReferences(tp.default.getText()));
+ }
+ });
+ const typeParamReferences = filterToExportedTypes([...new Set(typeParamRefs)], className);
+ const typeParamReferencesText = formatReferences(typeParamReferences);
+ if (typeParamReferencesText) {
+ typeParameters += typeParamReferencesText;
+ }
+ }
+
+ // Check for extends clause (only for classes/interfaces)
+ if ((ts.isClassDeclaration(declaration) || ts.isInterfaceDeclaration(declaration)) && declaration.heritageClauses) {
+ const extendsClauseNode = declaration.heritageClauses.find(
+ clause => clause.token === ts.SyntaxKind.ExtendsKeyword,
+ );
+ if (extendsClauseNode && extendsClauseNode.types[0]) {
+ const superClassName = extendsClauseNode.types[0].expression.getText();
+ extendsClause = `\n\n**Extends:** [\`${superClassName}\`](./${superClassName}.md)\n`;
+ }
+ }
+
+ // Helper to get JSDoc description with superclass fallback (recursive)
+ const getDescriptionWithFallback = (member: ts.ClassElement | ts.TypeElement, memberName: string): string => {
+ const jsDoc = ts.getJSDocCommentsAndTags(member)[0];
+ if (jsDoc && ts.isJSDoc(jsDoc)) {
+ // First try the parsed comment
+ if (typeof jsDoc.comment === 'string' && jsDoc.comment.trim()) {
+ return processLinkTags(jsDoc.comment.trim(), className);
+ } else {
+ // If no parsed comment, extract from raw source (same logic as class descriptions)
+ const sourceFile = member.getSourceFile();
+ const sourceText = sourceFile.getFullText();
+ const start = jsDoc.getStart();
+ const end = jsDoc.getEnd();
+ const rawJsDoc = sourceText.substring(start, end);
+
+ const match = rawJsDoc.match(/\/\*\*(.*?)\*\//s);
+ if (match && match[1]) {
+ const content = match[1]
+ .split('\n')
+ .map(line => line.replace(/^\s*\*\s?/, '')) // Remove leading * and spaces
+ .join('\n')
+ .trim();
+
+ // Filter out @tags but keep the description
+ const lines = content.split('\n');
+ const descLines = lines.filter(line => !line.trim().startsWith('@'));
+ const rawDesc = descLines.join('\n').trim();
+
+ if (rawDesc) {
+ return processLinkTags(rawDesc, className);
+ }
+ }
+ }
+ }
+
+ // Recursively check superclass hierarchy for this member's documentation
+ const findInHierarchy = (currentDeclaration: ts.ClassDeclaration): string => {
+ const currentType = typeChecker.getTypeAtLocation(currentDeclaration);
+ const baseTypes = currentType.getBaseTypes();
+
+ if (baseTypes && baseTypes.length > 0) {
+ const baseSymbol = baseTypes[0]!.getSymbol();
+ if (baseSymbol && baseSymbol.valueDeclaration && ts.isClassDeclaration(baseSymbol.valueDeclaration)) {
+ const baseMember = baseSymbol.valueDeclaration.members.find(m =>
+ m.name && m.name.getText() === memberName,
+ );
+ if (baseMember) {
+ const baseJsDoc = ts.getJSDocCommentsAndTags(baseMember)[0];
+ if (baseJsDoc && ts.isJSDoc(baseJsDoc) && typeof baseJsDoc.comment === 'string') {
+ return processLinkTags(baseJsDoc.comment.trim(), className);
+ }
+ }
+ // Recursively check further up the hierarchy
+ return findInHierarchy(baseSymbol.valueDeclaration);
+ }
+ }
+ return '';
+ };
+
+ if (ts.isClassDeclaration(declaration)) {
+ return findInHierarchy(declaration);
+ }
+
+ return '';
+ };
+
+ // Helper to extract linked types from a member's JSDoc
+ const getLinkedTypesFromMember = (member: ts.ClassElement | ts.TypeElement): string[] => {
+ const jsDoc = ts.getJSDocCommentsAndTags(member)[0];
+ if (jsDoc && ts.isJSDoc(jsDoc)) {
+ // First try the parsed comment
+ if (typeof jsDoc.comment === 'string' && jsDoc.comment.trim()) {
+ return extractLinkedTypes(jsDoc.comment.trim());
+ } else {
+ // If no parsed comment, extract from raw source
+ const sourceFile = member.getSourceFile();
+ const sourceText = sourceFile.getFullText();
+ const start = jsDoc.getStart();
+ const end = jsDoc.getEnd();
+ const rawJsDoc = sourceText.substring(start, end);
+
+ const match = rawJsDoc.match(/\/\*\*(.*?)\*\//s);
+ if (match && match[1]) {
+ const content = match[1]
+ .split('\n')
+ .map(line => line.replace(/^\s*\*\s?/, '')) // Remove leading * and spaces
+ .join('\n')
+ .trim();
+
+ // Filter out @tags but keep the description
+ const lines = content.split('\n');
+ const descLines = lines.filter(line => !line.trim().startsWith('@'));
+ const rawDesc = descLines.join('\n').trim();
+
+ return extractLinkedTypes(rawDesc);
+ }
+ }
+ }
+ return [];
+ };
+
+ // Helper to process members (both own and inherited)
+ const processMember = (member: ts.ClassElement | ts.TypeElement, _isInherited = false) => {
+ // Check if member has @internal in JSDoc
+ const hasInternalTag = ts.getJSDocTags(member).some(tag => tag.tagName.text === 'internal');
+ if (hasInternalTag) return;
+
+ if (ts.isConstructorDeclaration(member) && !isAbstract) {
+ // Skip private constructors
+ const isPrivate = member.modifiers?.some(mod => mod.kind === ts.SyntaxKind.PrivateKeyword);
+ if (isPrivate) return;
+
+ // Only process the first constructor we encounter to build the constructor section
+ // We'll collect all overloads separately
+ if (!constructor) {
+ // Collect all constructor overloads from the class
+ const constructorOverloads: ts.ConstructorDeclaration[] = [];
+ if (ts.isClassDeclaration(declaration)) {
+ declaration.members.forEach((m) => {
+ if (ts.isConstructorDeclaration(m) && !m.modifiers?.some(mod => mod.kind === ts.SyntaxKind.PrivateKeyword)) {
+ constructorOverloads.push(m);
+ }
+ });
+ }
+
+ // Build individual constructor blocks with their own descriptions
+ const constructorBlocks: string[] = [];
+ const allReferencedTypes: string[] = [];
+
+ constructorOverloads.forEach((ctor) => {
+ // Skip the implementation (the one with a body) unless it's the only constructor
+ if (ctor.body && constructorOverloads.length > 1) {
+ // Process constructor parameters with visibility modifiers as properties (only from implementation)
+ ctor.parameters.forEach((param) => {
+ // Check if parameter has visibility modifier (public, private, protected, readonly)
+ const hasVisibilityModifier = param.modifiers?.some(mod =>
+ mod.kind === ts.SyntaxKind.PublicKeyword
+ || mod.kind === ts.SyntaxKind.PrivateKeyword
+ || mod.kind === ts.SyntaxKind.ProtectedKeyword
+ || mod.kind === ts.SyntaxKind.ReadonlyKeyword,
+ );
+
+ if (!hasVisibilityModifier) return;
+
+ // Skip private parameters
+ const isPrivate = param.modifiers?.some(mod => mod.kind === ts.SyntaxKind.PrivateKeyword);
+ if (isPrivate) return;
+
+ const paramName = param.name.getText();
+ const hasQuestionToken = param.questionToken !== undefined;
+ const isReadonly = param.modifiers?.some(mod => mod.kind === ts.SyntaxKind.ReadonlyKeyword);
+ const rawParamType = param.type ? param.type.getText() : typeChecker.typeToString(typeChecker.getTypeAtLocation(param));
+ const paramType = cleanOptionalType(rawParamType, hasQuestionToken);
+
+ const propertyDef = `${isReadonly ? 'readonly ' : ''}${paramName}${hasQuestionToken ? '?' : ''}: ${paramType};`;
+
+ // Get description from JSDoc comment on the parameter
+ const paramJsDoc = ts.getJSDocCommentsAndTags(param)[0];
+ let desc = '';
+ if (paramJsDoc && ts.isJSDoc(paramJsDoc) && typeof paramJsDoc.comment === 'string') {
+ desc = processLinkTags(paramJsDoc.comment.trim(), className);
+ }
+
+ // Find referenced types
+ const references = filterToExportedTypes(findAllTypeReferences(paramType), className);
+ const referencesText = formatReferences(references);
+
+ const propertyContent = `### \`${paramName}\`\n\n\`\`\`ts\n${propertyDef}\n\`\`\`${desc ? `\n\n${desc}` : ''}${referencesText}`;
+ properties.push(propertyContent);
+ });
+ return;
+ }
+
+ // Build parameter list for this overload
+ const params = ctor.parameters.map((param) => {
+ const paramName = param.name.getText();
+ const hasQuestionToken = param.questionToken !== undefined;
+ const hasDefault = param.initializer !== undefined;
+ const isRest = param.dotDotDotToken !== undefined;
+ const rawParamType = param.type ? param.type.getText() : typeChecker.typeToString(typeChecker.getTypeAtLocation(param));
+ const paramType = cleanOptionalType(rawParamType, hasQuestionToken);
+
+ if (hasDefault) {
+ const defaultValue = param.initializer.getText();
+ return `\t${isRest ? '...' : ''}${paramName}: ${paramType} = ${defaultValue}`;
+ } else {
+ return `\t${isRest ? '...' : ''}${paramName}${hasQuestionToken ? '?' : ''}: ${paramType}`;
+ }
+ });
+
+ const constructorSig = params.length > 0
+ ? `constructor(\n${params.join(',\n')},\n): ${className};`
+ : `constructor(): ${className};`;
+
+ // Collect referenced types from this overload
+ const ctorTypeStrings = params.map(p => p.replace(/\t.*?:\s*/, ''));
+ allReferencedTypes.push(...ctorTypeStrings.flatMap(findAllTypeReferences));
+
+ // Get description for this specific overload
+ const constructorDesc = getDescriptionWithFallback(ctor, 'constructor');
+ const linkedTypes = getLinkedTypesFromMember(ctor);
+
+ // Get parameter descriptions for this specific overload
+ const paramDocs: string[] = [];
+ const jsDoc = ts.getJSDocCommentsAndTags(ctor)[0];
+ if (jsDoc && ts.isJSDoc(jsDoc)) {
+ const paramTags = jsDoc.tags?.filter((tag: any) => tag.tagName.text === 'param') || [];
+ paramTags.forEach((tag: any) => {
+ if (ts.isJSDocParameterTag(tag) && tag.name && typeof tag.comment === 'string') {
+ const paramName = tag.name.getText();
+ const paramDesc = tag.comment.trim().replace(/^-\s*/, '');
+ paramDocs.push(`- **${paramName}**: ${paramDesc}`);
+ }
+ });
+ }
+
+ // Build this constructor block
+ let constructorBlock = `\`\`\`ts\n${constructorSig}\n\`\`\``;
+ if (constructorDesc) {
+ constructorBlock += `\n\n${constructorDesc}`;
+ }
+ if (paramDocs.length > 0) {
+ constructorBlock += `\n\n**Parameters:**\n\n${paramDocs.join('\n')}`;
+ }
+
+ // Find referenced types for this overload
+ const overloadReferences = filterToExportedTypes([...new Set(ctorTypeStrings.flatMap(findAllTypeReferences))], className);
+ constructorBlock += formatReferences(overloadReferences, linkedTypes);
+
+ constructorBlocks.push(constructorBlock);
+ });
+
+ // Build the constructor section
+ const headingText = constructorBlocks.length > 1 ? 'Constructors' : 'Constructor';
+ const separator = constructorBlocks.length > 1 ? '\n\n---\n\n' : '\n\n';
+ constructor = `## ${headingText}\n\n${constructorBlocks.join(separator)}`;
+ }
+ } else if ((ts.isPropertyDeclaration(member) || ts.isPropertySignature(member)) && member.name) {
+ const name = member.name.getText();
+ const isReadonly = member.modifiers?.some(mod => mod.kind === ts.SyntaxKind.ReadonlyKeyword);
+ const isOptional = member.questionToken !== undefined;
+ // Prefer explicit type annotation if available, otherwise use type checker
+ let rawType: string;
+ if (member.type) {
+ rawType = member.type.getText();
+ // For optional properties, check if the type is a reference to an exported type
+ // If so, don't apply undefined removal logic to preserve the type reference
+ if (isOptional && exportedTypes.has(rawType)) {
+ // Keep the original type reference for exported types
+ } else {
+ rawType = cleanOptionalType(rawType, isOptional);
+ }
+ } else {
+ const memberType = typeChecker.getTypeAtLocation(member);
+ const typeName = memberType.getSymbol()?.getName();
+ // If the type has a symbol name and it's in our exported types, use that instead of expanding
+ if (typeName && exportedTypes.has(typeName)) {
+ rawType = typeName;
+ } else {
+ rawType = getTypeString(memberType);
+ rawType = cleanOptionalType(rawType, isOptional);
+ }
+ }
+ const type = rawType;
+ const propertyDef = `${isReadonly ? 'readonly ' : ''}${name}${isOptional ? '?' : ''}: ${type};`;
+
+ // Get description from JSDoc
+ const desc = getDescriptionWithFallback(member, name);
+ const linkedTypes = getLinkedTypesFromMember(member);
+
+ // Find referenced types
+ const references = filterToExportedTypes(findAllTypeReferences(type), className);
+ const referencesText = formatReferences(references, linkedTypes);
+
+ // Check if this is an event handler (starts with "on" and can be a function)
+ const isEventHandler = name.startsWith('on') && (
+ type.includes('=>')
+ || type.includes('Function')
+ || type.includes('() =>')
+ || (type.includes('(') && type.includes(') =>'))
+ );
+
+ const inheritedBadge = '';
+ const propertyContent = `### \`${name}\`${inheritedBadge}\n\n\`\`\`ts\n${propertyDef}\n\`\`\`${desc ? `\n\n${desc}` : ''}${referencesText}`;
+
+ if (isEventHandler) {
+ events.push(propertyContent);
+ } else {
+ properties.push(propertyContent);
+ }
+ } else if (ts.isGetAccessorDeclaration(member) && member.name) {
+ const name = member.name.getText();
+ // For getters, prefer the explicit return type annotation if available
+ let rawType: string;
+ if (member.type) {
+ rawType = member.type.getText();
+ } else {
+ const memberType = typeChecker.getTypeAtLocation(member);
+ const typeName = memberType.getSymbol()?.getName();
+ // If the type has a symbol name and it's in our exported types, use that instead of expanding
+ if (typeName && exportedTypes.has(typeName)) {
+ rawType = typeName;
+ } else {
+ rawType = getTypeString(memberType);
+ }
+ }
+ const type = cleanOptionalType(rawType, false);
+ const accessorDef = `get ${name}(): ${type};`;
+
+ // Get description from JSDoc
+ const desc = getDescriptionWithFallback(member, name);
+ const linkedTypes = getLinkedTypesFromMember(member);
+
+ // Find referenced types
+ const references = filterToExportedTypes(findAllTypeReferences(type), className);
+ const referencesText = formatReferences(references, linkedTypes);
+
+ const inheritedBadge = '';
+ properties.push(`### \`${name}\`${inheritedBadge}\n\n\`\`\`ts\n${accessorDef}\n\`\`\`${desc ? `\n\n${desc}` : ''}${referencesText}`);
+ } else if (ts.isSetAccessorDeclaration(member) && member.name) {
+ const name = member.name.getText();
+ const param = member.parameters[0];
+ const isOptional = param?.questionToken !== undefined;
+ const rawParamType = param?.type
+ ? typeChecker.typeToString(typeChecker.getTypeAtLocation(param))
+ : 'any';
+ const paramType = cleanOptionalType(rawParamType, isOptional);
+ const accessorDef = `set ${name}(value: ${paramType});`;
+
+ // Get description from JSDoc
+ const desc = getDescriptionWithFallback(member, name);
+ const linkedTypes = getLinkedTypesFromMember(member);
+
+ // Find referenced types
+ const references = filterToExportedTypes(findAllTypeReferences(paramType), className);
+ const referencesText = formatReferences(references, linkedTypes);
+
+ const inheritedBadge = '';
+ properties.push(`### \`${name}\`${inheritedBadge}\n\n\`\`\`ts\n${accessorDef}\n\`\`\`${desc ? `\n\n${desc}` : ''}${referencesText}`);
+ } else if ((ts.isMethodDeclaration(member) || ts.isMethodSignature(member)) && member.name) {
+ const name = member.name.getText();
+ const isStatic = member.modifiers?.some(mod => mod.kind === ts.SyntaxKind.StaticKeyword);
+
+ // For method overloads, skip the implementation if it has no JSDoc
+ // (the overload signatures should have the documentation)
+ const methodJsDoc = ts.getJSDocCommentsAndTags(member)[0];
+ const hasJsDoc = methodJsDoc && ts.isJSDoc(methodJsDoc) && methodJsDoc.comment;
+
+ // If this is a method declaration (has a body) and has no JSDoc, check if there are overloads
+ if (ts.isMethodDeclaration(member) && member.body && !hasJsDoc && ts.isClassDeclaration(declaration)) {
+ // Check if there are other methods with the same name (overloads)
+ const sameNameMethods = declaration.members.filter(m =>
+ (ts.isMethodDeclaration(m) || ts.isMethodSignature(m))
+ && m.name && m.name.getText() === name,
+ );
+
+ // If there are multiple methods with the same name, this is likely an overloaded method
+ // Skip the implementation (the one with a body) if it has no JSDoc
+ if (sameNameMethods.length > 1) {
+ return;
+ }
+ }
+
+ // Build parameter list with each on its own line
+ const params = member.parameters.map((param) => {
+ const paramName = param.name.getText();
+ const hasQuestionToken = param.questionToken !== undefined;
+ const hasDefault = param.initializer !== undefined;
+ const isRest = param.dotDotDotToken !== undefined;
+ const rawParamType = param.type ? param.type.getText() : typeChecker.typeToString(typeChecker.getTypeAtLocation(param));
+ const paramType = cleanOptionalType(rawParamType, hasQuestionToken);
+
+ if (hasDefault) {
+ const defaultValue = param.initializer.getText();
+ return `\t${isRest ? '...' : ''}${paramName}: ${paramType} = ${defaultValue}`;
+ } else {
+ return `\t${isRest ? '...' : ''}${paramName}${hasQuestionToken ? '?' : ''}: ${paramType}`;
+ }
+ });
+
+ // Get return type
+ const signature = typeChecker.getSignatureFromDeclaration(member);
+ const returnType = signature ? getTypeString(signature.getReturnType()) : 'void';
+
+ // Format method signature
+ const methodSig = params.length > 0
+ ? `${isStatic ? 'static ' : ''}${name}(\n${params.join(',\n')},\n): ${returnType};`
+ : `${isStatic ? 'static ' : ''}${name}(): ${returnType};`;
+
+ // Get method description from JSDoc
+ const desc = getDescriptionWithFallback(member, name);
+ const linkedTypes = getLinkedTypesFromMember(member);
+
+ // Get parameter and return descriptions
+ const paramDocs: string[] = [];
+ let returnDoc = '';
+ const jsDoc = ts.getJSDocCommentsAndTags(member)[0];
+ if (jsDoc && ts.isJSDoc(jsDoc)) {
+ const paramTags = jsDoc.tags?.filter((tag: any) => tag.tagName.text === 'param') || [];
+ paramTags.forEach((tag: any) => {
+ if (ts.isJSDocParameterTag(tag) && tag.name && typeof tag.comment === 'string') {
+ const paramName = tag.name.getText();
+ const paramDesc = tag.comment.trim().replace(/^-\s*/, '');
+ paramDocs.push(`- **${paramName}**: ${paramDesc}`);
+ }
+ });
+
+ const returnTags = jsDoc.tags?.filter((tag: any) => tag.tagName.text === 'returns' || tag.tagName.text === 'return') || [];
+ if (returnTags.length > 0 && returnTags[0] && typeof returnTags[0].comment === 'string') {
+ returnDoc = returnTags[0].comment.trim().replace(/^-\s*/, '');
+ }
+ }
+
+ const inheritedBadge = '';
+ let methodContent = `### \`${name}()\`${inheritedBadge}\n\n\`\`\`ts\n${methodSig}\n\`\`\``;
+ if (desc) {
+ methodContent += `\n\n${desc}`;
+ }
+ if (paramDocs.length > 0) {
+ methodContent += `\n\n**Parameters:**\n\n${paramDocs.join('\n')}`;
+ }
+ if (returnDoc) {
+ methodContent += `\n\n**Returns:** ${returnDoc}`;
+ }
+
+ // Find referenced types in all parameters and return type
+ const allTypeStrings = params.map(p => p.replace(/\t.*?:\s*/, '')).concat([returnType]);
+ const allReferences = filterToExportedTypes([...new Set(allTypeStrings.flatMap(findAllTypeReferences))], className);
+ methodContent += formatReferences(allReferences, linkedTypes);
+
+ if (isStatic) {
+ staticMethods.push(methodContent);
+ } else {
+ methods.push(methodContent);
+ }
+ }
+ };
+
+ // Track names of own members to avoid duplicates with inherited
+ const ownMemberNames = new Set();
+
+ // Process own members first
+ if (ts.isClassDeclaration(declaration) || ts.isInterfaceDeclaration(declaration)) {
+ declaration.members.forEach((member) => {
+ if (member.name) {
+ ownMemberNames.add(member.name.getText());
+ }
+ processMember(member);
+ });
+ } else if (ts.isTypeAliasDeclaration(declaration)) {
+ // For type aliases, check if it's a simple union type (like string literals)
+ const resolvedType = typeChecker.getTypeAtLocation(declaration);
+
+ // If it's a primitive type or simple union, skip property processing
+ const isPrimitive = !!(resolvedType.flags & (ts.TypeFlags.String | ts.TypeFlags.Number | ts.TypeFlags.Boolean | ts.TypeFlags.StringLiteral | ts.TypeFlags.NumberLiteral | ts.TypeFlags.BooleanLiteral));
+ const isSimpleUnion = resolvedType.isUnion() && resolvedType.types.every(t =>
+ t.flags & (ts.TypeFlags.StringLiteral | ts.TypeFlags.NumberLiteral | ts.TypeFlags.BooleanLiteral | ts.TypeFlags.String | ts.TypeFlags.Number | ts.TypeFlags.Boolean),
+ );
+
+ if (!isPrimitive && !isSimpleUnion) {
+ const typeProperties = typeChecker.getPropertiesOfType(resolvedType);
+
+ typeProperties.forEach((prop) => {
+ // Create a synthetic property signature for each resolved property
+ const propName = prop.getName();
+ const propType = typeChecker.getTypeOfSymbolAtLocation(prop, declaration);
+ const propTypeString = getTypeString(propType);
+ const isOptional = (prop.flags & ts.SymbolFlags.Optional) !== 0;
+
+ // Get JSDoc from the original declaration
+ let desc = '';
+ const propDeclaration = prop.valueDeclaration || prop.declarations?.[0];
+ if (propDeclaration) {
+ const jsDoc = ts.getJSDocCommentsAndTags(propDeclaration)[0];
+ if (jsDoc && ts.isJSDoc(jsDoc) && typeof jsDoc.comment === 'string') {
+ desc = processLinkTags(jsDoc.comment.trim(), className);
+ }
+ }
+
+ // Check if this property's original type annotation references an exported type
+ let cleanedType = propTypeString;
+ if (propDeclaration && ts.isPropertySignature(propDeclaration) && propDeclaration.type) {
+ const originalType = propDeclaration.type.getText();
+ if (exportedTypes.has(originalType)) {
+ cleanedType = originalType;
+ } else {
+ cleanedType = cleanOptionalType(propTypeString, isOptional);
+ }
+ } else {
+ cleanedType = cleanOptionalType(propTypeString, isOptional);
+ }
+
+ const propertyDef = `${propName}${isOptional ? '?' : ''}: ${cleanedType};`;
+
+ // Find referenced types
+ const references = filterToExportedTypes(findAllTypeReferences(cleanedType), className);
+ const referencesText = formatReferences(references);
+
+ // Check if this is an event handler (starts with "on" and can be a function)
+ const isEventHandler = propName.startsWith('on') && (
+ cleanedType.includes('=>')
+ || cleanedType.includes('Function')
+ || cleanedType.includes('() =>')
+ || (cleanedType.includes('(') && cleanedType.includes(') =>'))
+ );
+
+ // Type alias properties are never inherited
+ const propertyContent = `### \`${propName}\`\n\n\`\`\`ts\n${propertyDef}\n\`\`\`${desc ? `\n\n${desc}` : ''}${referencesText}`;
+
+ if (isEventHandler) {
+ events.push(propertyContent);
+ } else {
+ properties.push(propertyContent);
+ }
+ });
+ }
+ }
+
+ // Process inherited members (skip if overridden, only for classes)
+ if (ts.isClassDeclaration(declaration)) {
+ const classType = typeChecker.getTypeAtLocation(declaration);
+ const baseTypes = classType.getBaseTypes();
+ if (baseTypes && baseTypes.length > 0) {
+ baseTypes.forEach((baseType) => {
+ const baseSymbol = baseType.getSymbol();
+ if (baseSymbol && baseSymbol.valueDeclaration && ts.isClassDeclaration(baseSymbol.valueDeclaration)) {
+ baseSymbol.valueDeclaration.members.forEach((member) => {
+ // Skip if this member is overridden in the derived class
+ if (member.name && ownMemberNames.has(member.name.getText())) {
+ return;
+ }
+ // Never process inherited constructors - always use the derived class constructor
+ if (ts.isConstructorDeclaration(member)) {
+ return;
+ }
+ processMember(member, true);
+ });
+ }
+ });
+ }
+ }
+
+ // Sort properties and methods alphabetically (but not for type aliases - keep source order)
+ if (!ts.isTypeAliasDeclaration(declaration)) {
+ properties.sort((a, b) => {
+ const nameA = a.match(/### (.+)/)?.[1] || '';
+ const nameB = b.match(/### (.+)/)?.[1] || '';
+ return nameA.localeCompare(nameB);
+ });
+ }
+
+ staticMethods.sort((a, b) => {
+ const nameA = a.match(/### (.+)/)?.[1] || '';
+ const nameB = b.match(/### (.+)/)?.[1] || '';
+ return nameA.localeCompare(nameB);
+ });
+
+ let markdown = '';
+
+ // Add VPBadge import and badge for all types
+ markdown += `\n\n`;
+
+ if (isAbstract) {
+ markdown += ` \n\n`;
+ } else if (ts.isClassDeclaration(declaration)) {
+ markdown += ` \n\n`;
+ } else if (ts.isTypeAliasDeclaration(declaration)) {
+ markdown += ` \n\n`;
+ } else if (ts.isInterfaceDeclaration(declaration)) {
+ markdown += ` \n\n`;
+ }
+
+ markdown += `# ${className}\n\n${description ? `${description}\n` : ''}${extendsClause}`;
+
+ // Add subclasses section for classes that have subclasses
+ if (ts.isClassDeclaration(declaration) && classHierarchy.has(className)) {
+ const subclasses = classHierarchy.get(className)!;
+ // Sort by definition order instead of alphabetically
+ subclasses.sort((a, b) => {
+ const orderA = symbolOrderMap.get(a);
+ const orderB = symbolOrderMap.get(b);
+ if (orderA === undefined) throw new Error(`Symbol '${a}' not found in entry files export order`);
+ if (orderB === undefined) throw new Error(`Symbol '${b}' not found in entry files export order`);
+ return orderA - orderB;
+ });
+ markdown += `\n## Subclasses\n\n`;
+ subclasses.forEach((sub) => {
+ markdown += `- [\`${sub}\`](./${sub}.md)\n`;
+ });
+ }
+
+ // Add instances section for classes that have instances
+ if (ts.isClassDeclaration(declaration) && classInstances.has(className)) {
+ const instances = classInstances.get(className)!;
+ // Sort by definition order instead of alphabetically
+ instances.sort((a, b) => {
+ const orderA = symbolOrderMap.get(a);
+ const orderB = symbolOrderMap.get(b);
+ if (orderA === undefined) throw new Error(`Symbol '${a}' not found in entry files export order`);
+ if (orderB === undefined) throw new Error(`Symbol '${b}' not found in entry files export order`);
+ return orderA - orderB;
+ });
+ markdown += `\n## Instances\n\n`;
+ instances.forEach((instance) => {
+ markdown += `- [\`${instance}\`](./${instance}.md)\n`;
+ });
+ }
+
+ // Add type definition for type aliases
+ if (ts.isTypeAliasDeclaration(declaration) && declaration.type) {
+ const resolvedType = typeChecker.getTypeAtLocation(declaration);
+ const isPrimitive = !!(resolvedType.flags & (ts.TypeFlags.String | ts.TypeFlags.Number | ts.TypeFlags.Boolean | ts.TypeFlags.StringLiteral | ts.TypeFlags.NumberLiteral | ts.TypeFlags.BooleanLiteral));
+ const isSimpleUnion = resolvedType.isUnion() && resolvedType.types.every(t =>
+ t.flags & (ts.TypeFlags.StringLiteral | ts.TypeFlags.NumberLiteral | ts.TypeFlags.BooleanLiteral | ts.TypeFlags.String | ts.TypeFlags.Number | ts.TypeFlags.Boolean),
+ );
+
+ // Build the type name with generic parameters if they exist
+ let typeName = className;
+ if (declaration.typeParameters && declaration.typeParameters.length > 0) {
+ const typeParamStrings = declaration.typeParameters.map((tp) => {
+ const name = tp.name.text;
+ const constraint = tp.constraint ? ` extends ${tp.constraint.getText()}` : '';
+ const defaultType = tp.default ? ` = ${tp.default.getText()}` : '';
+ return `${name}${constraint}${defaultType}`;
+ });
+ typeName = `${className}<${typeParamStrings.join(', ')}>`;
+ }
+
+ let typeText;
+ if (isPrimitive || isSimpleUnion) {
+ // For primitive types or simple unions, use resolved type string
+ const resolvedTypeString = typeChecker.typeToString(resolvedType);
+ if (resolvedTypeString === className) {
+ // If resolved type is just the alias name, use original text
+ typeText = declaration.type.getText();
+ } else {
+ typeText = resolvedTypeString;
+ }
+ // Convert string literals from double quotes to single quotes
+ typeText = typeText.replace(/"([^"]*)"/g, '\'$1\'');
+ if (typeText.includes(' | ')) {
+ const unionMembers = typeText.split(' | ');
+ typeText = '\n\t| ' + unionMembers.join('\n\t| ');
+ }
+ } else {
+ // For complex types, use the original text
+ typeText = declaration.type.getText();
+ // Format object types with proper line breaks
+ if (typeText.includes('{')) {
+ typeText = formatObjectType(typeText);
+ }
+ }
+ const typeDefinition = `type ${typeName} = ${typeText};`;
+
+ // Find referenced types in the type definition and generic parameters
+ const allTypeRefs = findAllTypeReferences(typeText);
+ // Also find references in type parameters (extends clauses and default types)
+ if (declaration.typeParameters && declaration.typeParameters.length > 0) {
+ declaration.typeParameters.forEach((tp) => {
+ if (tp.constraint) {
+ allTypeRefs.push(...findAllTypeReferences(tp.constraint.getText()));
+ }
+ if (tp.default) {
+ allTypeRefs.push(...findAllTypeReferences(tp.default.getText()));
+ }
+ });
+ }
+ const typeReferences = filterToExportedTypes([...new Set(allTypeRefs)], className);
+ const typeReferencesText = formatReferences(typeReferences);
+
+ markdown += `\n\`\`\`ts\n${typeDefinition}\n\`\`\`${typeReferencesText}`;
+ }
+
+ if (typeParameters) {
+ markdown += `\n${typeParameters}\n\n`;
+ }
+
+ if (constructor) {
+ markdown += `\n${constructor}\n\n`;
+ }
+
+ if (staticMethods.length > 0) {
+ markdown += `\n## Static methods\n\n${staticMethods.join('\n\n')}\n\n`;
+ }
+
+ if (properties.length > 0) {
+ markdown += `\n## Properties\n\n${properties.join('\n\n')}\n\n`;
+ }
+
+ if (events.length > 0) {
+ markdown += `\n## Events\n\n${events.join('\n\n')}\n\n`;
+ }
+
+ if (methods.length > 0) {
+ markdown += `\n## Methods\n\n${methods.join('\n\n')}\n`;
+ }
+ const outputPath = path.join(outputDir, `${className}.md`);
+ fs.writeFileSync(outputPath, markdown);
+ console.log(`Generated: ${outputPath}`);
+ }
+ });
+
+ // Generate index.md with all exported symbols grouped by group
+ const entriesByGroup = new Map>();
+
+ indexEntries.forEach((entry) => {
+ if (!entriesByGroup.has(entry.group)) {
+ entriesByGroup.set(entry.group, []);
+ }
+ entriesByGroup.get(entry.group)!.push({ name: entry.name, type: entry.type, order: entry.order });
+ });
+
+ // Sort groups according to API config order
+ const configGroups = Object.keys(groupConfig);
+ const sortedGroups = configGroups.filter(group => entriesByGroup.has(group));
+
+ // Check for groups in entries that aren't in config
+ const missingGroups = Array.from(entriesByGroup.keys()).filter(group => !configGroups.includes(group));
+ if (missingGroups.length > 0) {
+ throw new Error(`Groups found in code but not in API config: ${missingGroups.join(', ')}`);
+ }
+
+ let indexMarkdown = `# ${headingText}\n\n`;
+
+ // Add intro text if provided
+ if (introText) {
+ indexMarkdown += `${introText}\n\n`;
+ }
+
+ sortedGroups.forEach((group) => {
+ const entries = entriesByGroup.get(group)!;
+ // Sort entries by definition order
+ entries.sort((a, b) => a.order - b.order);
+
+ indexMarkdown += `## ${group}\n\n`;
+ const groupDescription = groupConfig[group];
+ if (groupDescription) {
+ indexMarkdown += `${groupDescription}\n\n`;
+ }
+ entries.forEach((entry) => {
+ indexMarkdown += `- [${entry.name}](./${entry.name}.md)\n`;
+ });
+ indexMarkdown += '\n';
+ });
+
+ const indexPath = path.join(outputDir, 'index.md');
+ fs.writeFileSync(indexPath, indexMarkdown);
+ console.log(`Generated: ${indexPath}`);
+
+ // Generate index.json with sidebar config structure
+ const sidebarConfig = sortedGroups.map((group) => {
+ const entries = entriesByGroup.get(group)!;
+ // Sort entries by definition order
+ entries.sort((a, b) => a.order - b.order);
+
+ return {
+ text: group,
+ collapsed: true,
+ items: entries.map(entry => ({
+ text: entry.name,
+ link: `/api/${entry.name}`,
+ })),
+ };
+ });
+
+ const jsonPath = path.join(outputDir, 'index.json');
+ fs.writeFileSync(jsonPath, JSON.stringify(sidebarConfig, null, 2));
+ console.log(`Generated: ${jsonPath}`);
+};
+
+const main = () => {
+ const args = process.argv.slice(2);
+ if (args.length < 2) {
+ console.error('Usage: npm run generate-docs [entry-file2 ...] ');
+ console.error(' entry-files: One or more TypeScript entry files');
+ console.error(' api-config-file: JSON config file defining groups');
+ process.exit(1);
+ }
+
+ // Last argument is the config file, everything else are entry files
+ const apiConfigFile = args[args.length - 1]!;
+ const entryFiles = args.slice(0, -1);
+
+ generateDocs(entryFiles, apiConfigFile);
+};
+
+main();
diff --git a/shared/mp3-misc.ts b/shared/mp3-misc.ts
index 42d1342..433771b 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,19 +141,21 @@ 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..794160c 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 (this.reader.fileSize !== null && 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,14 @@ 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;
+
+ if (!slice) {
+ return null; // Data didn't fit into the rest of the file
+ }
+
+ data = readBytes(slice, rawSample.dataSize);
}
return new EncodedPacket(
@@ -247,7 +245,7 @@ class AdtsAudioTrackBacking implements InputAudioTrackBacking {
);
}
- async getFirstPacket(options: PacketRetrievalOptions) {
+ getFirstPacket(options: PacketRetrievalOptions) {
return this.getPacketAtIndex(0, options);
}
@@ -268,9 +266,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 +292,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 +303,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/codec.ts b/src/codec.ts
index 8e4740e..119f7cb 100644
--- a/src/codec.ts
+++ b/src/codec.ts
@@ -28,6 +28,7 @@ import { SubtitleMetadata } from './subtitles';
/**
* List of known video codecs, ordered by encoding preference.
+ * @group Codecs
* @public
*/
export const VIDEO_CODECS = [
@@ -39,6 +40,7 @@ export const VIDEO_CODECS = [
] as const;
/**
* List of known PCM (uncompressed) audio codecs, ordered by encoding preference.
+ * @group Codecs
* @public
*/
export const PCM_AUDIO_CODECS = [
@@ -59,6 +61,7 @@ export const PCM_AUDIO_CODECS = [
] as const;
/**
* List of known compressed audio codecs, ordered by encoding preference.
+ * @group Codecs
* @public
*/
export const NON_PCM_AUDIO_CODECS = [
@@ -70,6 +73,7 @@ export const NON_PCM_AUDIO_CODECS = [
] as const;
/**
* List of known audio codecs, ordered by encoding preference.
+ * @group Codecs
* @public
*/
export const AUDIO_CODECS = [
@@ -78,6 +82,7 @@ export const AUDIO_CODECS = [
] as const;
/**
* List of known subtitle codecs, ordered by encoding preference.
+ * @group Codecs
* @public
*/
export const SUBTITLE_CODECS = [
@@ -86,22 +91,26 @@ export const SUBTITLE_CODECS = [
/**
* Union type of known video codecs.
+ * @group Codecs
* @public
*/
export type VideoCodec = typeof VIDEO_CODECS[number];
/**
* Union type of known audio codecs.
+ * @group Codecs
* @public
*/
export type AudioCodec = typeof AUDIO_CODECS[number];
export type PcmAudioCodec = typeof PCM_AUDIO_CODECS[number];
/**
* Union type of known subtitle codecs.
+ * @group Codecs
* @public
*/
export type SubtitleCodec = typeof SUBTITLE_CODECS[number];
/**
* Union type of known media codecs.
+ * @group Codecs
* @public
*/
export type MediaCodec = VideoCodec | AudioCodec | SubtitleCodec;
@@ -726,110 +735,6 @@ export const getAudioEncoderConfigExtension = (codec: AudioCodec) => {
return {};
};
-/**
- * Represents a subjective media quality level.
- * @public
- */
-export class Quality {
- /** @internal */
- _factor: number;
-
- /** @internal */
- constructor(factor: number) {
- this._factor = factor;
- }
-
- /** @internal */
- _toVideoBitrate(codec: VideoCodec, width: number, height: number) {
- const pixels = width * height;
-
- const codecEfficiencyFactors = {
- avc: 1.0, // H.264/AVC (baseline)
- hevc: 0.6, // H.265/HEVC (~40% more efficient than AVC)
- vp9: 0.6, // Similar to HEVC
- av1: 0.4, // ~60% more efficient than AVC
- vp8: 1.2, // Slightly less efficient than AVC
- };
-
- const referencePixels = 1920 * 1080;
- const referenceBitrate = 3000000;
- const scaleFactor = Math.pow(pixels / referencePixels, 0.95); // Slight non-linear scaling
- const baseBitrate = referenceBitrate * scaleFactor;
-
- const codecAdjustedBitrate = baseBitrate * codecEfficiencyFactors[codec];
- const finalBitrate = codecAdjustedBitrate * this._factor;
-
- return Math.ceil(finalBitrate / 1000) * 1000;
- }
-
- /** @internal */
- _toAudioBitrate(codec: AudioCodec) {
- if ((PCM_AUDIO_CODECS as readonly string[]).includes(codec) || codec === 'flac') {
- return undefined;
- }
-
- const baseRates = {
- aac: 128000, // 128kbps base for AAC
- opus: 64000, // 64kbps base for Opus
- mp3: 160000, // 160kbps base for MP3
- vorbis: 64000, // 64kbps base for Vorbis
- };
-
- const baseBitrate = baseRates[codec as keyof typeof baseRates];
- if (!baseBitrate) {
- throw new Error(`Unhandled codec: ${codec}`);
- }
-
- let finalBitrate = baseBitrate * this._factor;
-
- if (codec === 'aac') {
- // AAC only works with specific bitrates, let's find the closest
- const validRates = [96000, 128000, 160000, 192000];
- finalBitrate = validRates.reduce((prev, curr) =>
- Math.abs(curr - finalBitrate) < Math.abs(prev - finalBitrate) ? curr : prev,
- );
- } else if (codec === 'opus' || codec === 'vorbis') {
- finalBitrate = Math.max(6000, finalBitrate);
- } else if (codec === 'mp3') {
- const validRates = [
- 8000, 16000, 24000, 32000, 40000, 48000, 64000, 80000,
- 96000, 112000, 128000, 160000, 192000, 224000, 256000, 320000,
- ];
- finalBitrate = validRates.reduce((prev, curr) =>
- Math.abs(curr - finalBitrate) < Math.abs(prev - finalBitrate) ? curr : prev,
- );
- }
-
- return Math.round(finalBitrate / 1000) * 1000;
- }
-}
-
-/**
- * Represents a very low media quality.
- * @public
- */
-export const QUALITY_VERY_LOW = new Quality(0.3);
-/**
- * Represents a low media quality.
- * @public
- */
-export const QUALITY_LOW = new Quality(0.6);
-/**
- * Represents a medium media quality.
- * @public
- */
-export const QUALITY_MEDIUM = new Quality(1);
-/**
- * Represents a high media quality.
- * @public
- */
-export const QUALITY_HIGH = new Quality(2);
-/**
- * Represents a very high media quality.
- * @public
- */
-export const QUALITY_VERY_HIGH = new Quality(4);
-
const VALID_VIDEO_CODEC_STRING_PREFIXES = ['avc1', 'avc3', 'hev1', 'hvc1', 'vp8', 'vp09', 'av01'];
const AVC_CODEC_STRING_REGEX = /^(avc1|avc3)\.[0-9a-fA-F]{6}$/;
const HEVC_CODEC_STRING_REGEX = /^(hev1|hvc1)\.(?:[ABC]?\d+)\.[0-9a-fA-F]{1,8}\.[LH]\d+(?:\.[0-9a-fA-F]{1,2}){0,6}$/;
diff --git a/src/conversion.ts b/src/conversion.ts
index 92c1e7c..c3c1a0a 100644
--- a/src/conversion.ts
+++ b/src/conversion.ts
@@ -10,15 +10,14 @@ import {
AUDIO_CODECS,
AudioCodec,
NON_PCM_AUDIO_CODECS,
- Quality,
- QUALITY_HIGH,
VIDEO_CODECS,
VideoCodec,
} from './codec';
import {
- AudioEncodingConfig,
getEncodableAudioCodecs,
getFirstEncodableVideoCodec,
+ Quality,
+ QUALITY_HIGH,
VideoEncodingConfig,
} from './encode';
import { Input } from './input';
@@ -37,12 +36,21 @@ import {
VideoSampleSource,
AudioSampleSource,
} from './media-source';
-import { assert, clamp, MaybePromise, normalizeRotation, promiseWithResolvers, Rotation } from './misc';
+import {
+ assert,
+ clamp,
+ isIso639Dash2LanguageCode,
+ MaybePromise,
+ normalizeRotation,
+ promiseWithResolvers,
+ Rotation,
+} from './misc';
import { Output, TrackType } from './output';
import { AudioSample, VideoSample } from './sample';
/**
* The options for media file conversion.
+ * @group Conversion
* @public
*/
export type ConversionOptions = {
@@ -54,8 +62,8 @@ export type ConversionOptions = {
/**
* Video-specific options. When passing an object, the same options are applied to all video tracks. When passing a
* function, it will be invoked for each video track and is expected to return or resolve to the options
- * for that specific track. The function is passed an instance of `InputVideoTrack` as well as a number `n`, which
- * is the 1-based index of the track in the list of all video tracks.
+ * for that specific track. The function is passed an instance of {@link InputVideoTrack} as well as a number `n`,
+ * which is the 1-based index of the track in the list of all video tracks.
*/
video?: ConversionVideoOptions
| ((track: InputVideoTrack, n: number) => MaybePromise);
@@ -63,8 +71,8 @@ export type ConversionOptions = {
/**
* Audio-specific options. When passing an object, the same options are applied to all audio tracks. When passing a
* function, it will be invoked for each audio track and is expected to return or resolve to the options
- * for that specific track. The function is passed an instance of `InputAudioTrack` as well as a number `n`, which
- * is the 1-based index of the track in the list of all audio tracks.
+ * for that specific track. The function is passed an instance of {@link InputAudioTrack} as well as a number `n`,
+ * which is the 1-based index of the track in the list of all audio tracks.
*/
audio?: ConversionAudioOptions
| ((track: InputAudioTrack, n: number) => MaybePromise);
@@ -80,10 +88,11 @@ export type ConversionOptions = {
/**
* Video-specific options.
+ * @group Conversion
* @public
*/
export type ConversionVideoOptions = {
- /** If true, all video tracks will be discarded and will not be present in the output. */
+ /** If `true`, all video tracks will be discarded and will not be present in the output. */
discard?: boolean;
/**
* The desired width of the output video in pixels, defaulting to the video's natural display width. If height
@@ -98,10 +107,10 @@ export type ConversionVideoOptions = {
/**
* The fitting algorithm in case both width and height are set, or if the input video changes its size over time.
*
- * - 'fill' will stretch the image to fill the entire box, potentially altering aspect ratio.
- * - 'contain' will contain the entire image within the box while preserving aspect ratio. This may lead to
+ * - `'fill'` will stretch the image to fill the entire box, potentially altering aspect ratio.
+ * - `'contain'` will contain the entire image within the box while preserving aspect ratio. This may lead to
* letterboxing.
- * - 'cover' will scale the image until the entire box is filled, while preserving aspect ratio.
+ * - `'cover'` will scale the image until the entire box is filled, while preserving aspect ratio.
*/
fit?: 'fill' | 'contain' | 'cover';
/**
@@ -117,17 +126,18 @@ export type ConversionVideoOptions = {
/** The desired output video codec. */
codec?: VideoCodec;
/** The desired bitrate of the output video. */
- bitrate?: VideoEncodingConfig['bitrate'];
- /** When true, video will always be re-encoded instead of directly copying over the encoded samples. */
+ bitrate?: number | Quality;
+ /** When `true`, video will always be re-encoded instead of directly copying over the encoded samples. */
forceTranscode?: boolean;
};
/**
* Audio-specific options.
+ * @group Conversion
* @public
*/
export type ConversionAudioOptions = {
- /** If true, all audio tracks will be discarded and will not be present in the output. */
+ /** If `true`, all audio tracks will be discarded and will not be present in the output. */
discard?: boolean;
/** The desired channel count of the output audio. */
numberOfChannels?: number;
@@ -136,8 +146,8 @@ export type ConversionAudioOptions = {
/** The desired output audio codec. */
codec?: AudioCodec;
/** The desired bitrate of the output audio. */
- bitrate?: AudioEncodingConfig['bitrate'];
- /** When true, audio will always be re-encoded instead of directly copying over the encoded samples. */
+ bitrate?: number | Quality;
+ /** When `true`, audio will always be re-encoded instead of directly copying over the encoded samples. */
forceTranscode?: boolean;
};
@@ -238,9 +248,41 @@ const validateAudioOptions = (audioOptions: ConversionAudioOptions | undefined)
const FALLBACK_NUMBER_OF_CHANNELS = 2;
const FALLBACK_SAMPLE_RATE = 48000;
+/**
+ * An input track that was discarded (excluded) from a {@link Conversion} alongside the discard reason.
+ * @group Conversion
+ * @public
+ */
+export type DiscardedTrack = {
+ /** The track that was discarded. */
+ track: InputTrack;
+ /**
+ * The reason for discarding the track.
+ *
+ * - `'discarded_by_user'`: You discarded this track by setting `discard: true`.
+ * - `'max_track_count_reached'`: The output had no more room for another track.
+ * - `'max_track_count_of_type_reached'`: The output had no more room for another track of this type, or the output
+ * doesn't support this track type at all.
+ * - `'unknown_source_codec'`: We don't know the codec of the input track and therefore don't know what to do
+ * with it.
+ * - `'undecodable_source_codec'`: The input track's codec is known, but we are unable to decode it.
+ * - `'no_encodable_target_codec'`: We can't find a codec that we are able to encode and that can be contained
+ * within the output format. This reason can be hit if the environment doesn't support the necessary encoders, or if
+ * you requested a codec that cannot be contained within the output format.
+ */
+ reason:
+ | 'discarded_by_user'
+ | 'max_track_count_reached'
+ | 'max_track_count_of_type_reached'
+ | 'unknown_source_codec'
+ | 'undecodable_source_codec'
+ | 'no_encodable_target_codec';
+};
+
/**
* Represents a media file conversion process, used to convert one media file into another. In addition to conversion,
* this class can be used to resize and rotate video, resample audio, drop tracks, or trim to a specific time range.
+ * @group Conversion
* @public
*/
export class Conversion {
@@ -290,7 +332,7 @@ export class Conversion {
/**
* A callback that is fired whenever the conversion progresses. Returns a number between 0 and 1, indicating the
* completion of the conversion. Note that a progress of 1 doesn't necessarily mean the conversion is complete;
- * the conversion is complete once `execute` resolves.
+ * the conversion is complete once `execute()` resolves.
*
* In order for progress to be computed, this property must be set before `execute` is called.
*/
@@ -303,18 +345,7 @@ export class Conversion {
/** The list of tracks that are included in the output file. */
readonly utilizedTracks: InputTrack[] = [];
/** The list of tracks from the input file that have been discarded, alongside the discard reason. */
- readonly discardedTracks: {
- /** The track that was discarded. */
- track: InputTrack;
- /** The reason for discarding the track. */
- reason:
- | 'discarded_by_user'
- | 'max_track_count_reached'
- | 'max_track_count_of_type_reached'
- | 'unknown_source_codec'
- | 'undecodable_source_codec'
- | 'no_encodable_target_codec';
- }[] = [];
+ readonly discardedTracks: DiscardedTrack[] = [];
/** Initializes a new conversion process without starting the conversion. */
static async init(options: ConversionOptions) {
@@ -324,6 +355,7 @@ export class Conversion {
return conversion;
}
+ /** Creates a new Conversion instance (duh). */
private constructor(options: ConversionOptions) {
if (!options || typeof options !== 'object') {
throw new TypeError('options must be an object.');
@@ -811,7 +843,8 @@ export class Conversion {
this.output.addVideoTrack(videoSource, {
frameRate: trackOptions.frameRate,
- languageCode: track.languageCode,
+ // TEMP: This condition can be removed when all demuxers properly homogenize to BCP47 in v2
+ languageCode: isIso639Dash2LanguageCode(track.languageCode) ? track.languageCode : undefined,
name: track.name ?? undefined,
rotation: needsRerender ? 0 : totalRotation, // Rerendering will bake the rotation into the output
});
@@ -981,7 +1014,8 @@ export class Conversion {
}
this.output.addAudioTrack(audioSource, {
- languageCode: track.languageCode,
+ // TEMP: This condition can be removed when all demuxers properly homogenize to BCP47 in v2
+ languageCode: isIso639Dash2LanguageCode(track.languageCode) ? track.languageCode : undefined,
name: track.name ?? undefined,
});
this._addedCounts.audio++;
diff --git a/src/custom-coder.ts b/src/custom-coder.ts
index 4c3bccf..ddc422b 100644
--- a/src/custom-coder.ts
+++ b/src/custom-coder.ts
@@ -7,12 +7,14 @@
*/
import { AudioCodec, VideoCodec } from './codec';
+import { MaybePromise } from './misc';
import { EncodedPacket } from './packet';
import { AudioSample, VideoSample } from './sample';
/**
* Base class for custom video decoders. To add your own custom video decoder, extend this class, implement the
- * abstract methods and static `supports` method, and register the decoder using `registerDecoder`.
+ * abstract methods and static `supports` method, and register the decoder using {@link registerDecoder}.
+ * @group Custom coders
* @public
*/
export abstract class CustomVideoDecoder {
@@ -23,25 +25,26 @@ export abstract class CustomVideoDecoder {
/** The callback to call when a decoded VideoSample is available. */
readonly onSample!: (sample: VideoSample) => unknown;
- /** Returns true iff the decoder can decode the given codec configuration. */
+ /** Returns true if and only if the decoder can decode the given codec configuration. */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
static supports(codec: VideoCodec, config: VideoDecoderConfig): boolean {
return false;
}
/** Called after decoder creation; can be used for custom initialization logic. */
- abstract init(): Promise | void;
+ abstract init(): MaybePromise;
/** Decodes the provided encoded packet. */
- abstract decode(packet: EncodedPacket): Promise | void;
+ abstract decode(packet: EncodedPacket): MaybePromise;
/** Decodes all remaining packets and then resolves. */
- abstract flush(): Promise | void;
+ abstract flush(): MaybePromise;
/** Called when the decoder is no longer needed and its resources can be freed. */
- abstract close(): Promise | void;
+ abstract close(): MaybePromise;
}
/**
* Base class for custom audio decoders. To add your own custom audio decoder, extend this class, implement the
- * abstract methods and static `supports` method, and register the decoder using `registerDecoder`.
+ * abstract methods and static `supports` method, and register the decoder using {@link registerDecoder}.
+ * @group Custom coders
* @public
*/
export abstract class CustomAudioDecoder {
@@ -52,25 +55,26 @@ export abstract class CustomAudioDecoder {
/** The callback to call when a decoded AudioSample is available. */
readonly onSample!: (sample: AudioSample) => unknown;
- /** Returns true iff the decoder can decode the given codec configuration. */
+ /** Returns true if and only if the decoder can decode the given codec configuration. */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
static supports(codec: AudioCodec, config: AudioDecoderConfig): boolean {
return false;
}
/** Called after decoder creation; can be used for custom initialization logic. */
- abstract init(): Promise | void;
+ abstract init(): MaybePromise;
/** Decodes the provided encoded packet. */
- abstract decode(packet: EncodedPacket): Promise | void;
+ abstract decode(packet: EncodedPacket): MaybePromise;
/** Decodes all remaining packets and then resolves. */
- abstract flush(): Promise | void;
+ abstract flush(): MaybePromise;
/** Called when the decoder is no longer needed and its resources can be freed. */
- abstract close(): Promise | void;
+ abstract close(): MaybePromise;
}
/**
* Base class for custom video encoders. To add your own custom video encoder, extend this class, implement the
- * abstract methods and static `supports` method, and register the encoder using `registerEncoder`.
+ * abstract methods and static `supports` method, and register the encoder using {@link registerEncoder}.
+ * @group Custom coders
* @public
*/
export abstract class CustomVideoEncoder {
@@ -81,25 +85,26 @@ export abstract class CustomVideoEncoder {
/** The callback to call when an EncodedPacket is available. */
readonly onPacket!: (packet: EncodedPacket, meta?: EncodedVideoChunkMetadata) => unknown;
- /** Returns true iff the encoder can encode the given codec configuration. */
+ /** Returns true if and only if the encoder can encode the given codec configuration. */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
static supports(codec: VideoCodec, config: VideoEncoderConfig): boolean {
return false;
}
/** Called after encoder creation; can be used for custom initialization logic. */
- abstract init(): Promise | void;
+ abstract init(): MaybePromise;
/** Encodes the provided video sample. */
- abstract encode(videoSample: VideoSample, options: VideoEncoderEncodeOptions): Promise | void;
+ abstract encode(videoSample: VideoSample, options: VideoEncoderEncodeOptions): MaybePromise;
/** Encodes all remaining video samples and then resolves. */
- abstract flush(): Promise | void;
+ abstract flush(): MaybePromise;
/** Called when the encoder is no longer needed and its resources can be freed. */
- abstract close(): Promise | void;
+ abstract close(): MaybePromise;
}
/**
* Base class for custom audio encoders. To add your own custom audio encoder, extend this class, implement the
- * abstract methods and static `supports` method, and register the encoder using `registerEncoder`.
+ * abstract methods and static `supports` method, and register the encoder using {@link registerEncoder}.
+ * @group Custom coders
* @public
*/
export abstract class CustomAudioEncoder {
@@ -110,20 +115,20 @@ export abstract class CustomAudioEncoder {
/** The callback to call when an EncodedPacket is available. */
readonly onPacket!: (packet: EncodedPacket, meta?: EncodedAudioChunkMetadata) => unknown;
- /** Returns true iff the encoder can encode the given codec configuration. */
+ /** Returns true if and only if the encoder can encode the given codec configuration. */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
static supports(codec: AudioCodec, config: AudioEncoderConfig): boolean {
return false;
}
/** Called after encoder creation; can be used for custom initialization logic. */
- abstract init(): Promise | void;
+ abstract init(): MaybePromise;
/** Encodes the provided audio sample. */
- abstract encode(audioSample: AudioSample): Promise | void;
+ abstract encode(audioSample: AudioSample): MaybePromise;
/** Encodes all remaining audio samples and then resolves. */
- abstract flush(): Promise | void;
+ abstract flush(): MaybePromise;
/** Called when the encoder is no longer needed and its resources can be freed. */
- abstract close(): Promise | void;
+ abstract close(): MaybePromise;
}
export const customVideoDecoders: typeof CustomVideoDecoder[] = [];
@@ -134,6 +139,7 @@ export const customAudioEncoders: typeof CustomAudioEncoder[] = [];
/**
* Registers a custom video or audio decoder. Registered decoders will automatically be used for decoding whenever
* possible.
+ * @group Custom coders
* @public
*/
export const registerDecoder = (decoder: typeof CustomVideoDecoder | typeof CustomAudioDecoder) => {
@@ -163,6 +169,7 @@ export const registerDecoder = (decoder: typeof CustomVideoDecoder | typeof Cust
/**
* Registers a custom video or audio encoder. Registered encoders will automatically be used for encoding whenever
* possible.
+ * @group Custom coders
* @public
*/
export const registerEncoder = (encoder: typeof CustomVideoEncoder | typeof CustomAudioEncoder) => {
diff --git a/src/encode.ts b/src/encode.ts
index 8166c30..0eb26da 100644
--- a/src/encode.ts
+++ b/src/encode.ts
@@ -16,7 +16,6 @@ import {
inferCodecFromCodecString,
MediaCodec,
PCM_AUDIO_CODECS,
- Quality,
SUBTITLE_CODECS,
SubtitleCodec,
VIDEO_CODECS,
@@ -27,13 +26,14 @@ import { EncodedPacket } from './packet';
/**
* Configuration object that controls video encoding. Can be used to set codec, quality, and more.
+ * @group Encoding
* @public
*/
export type VideoEncodingConfig = {
/** The video codec that should be used for encoding the video samples (frames). */
codec: VideoCodec;
/**
- * The target bitrate for the encoded video, in bits per second. Alternatively, a subjective Quality can
+ * The target bitrate for the encoded video, in bits per second. Alternatively, a subjective {@link Quality} can
* be provided.
*/
bitrate: number | Quality;
@@ -44,14 +44,14 @@ export type VideoEncodingConfig = {
*/
keyFrameInterval?: number;
/**
- * Video frames may change size overtime. This field controls the behavior in case this happens.
+ * Video frames may change size over time. This field controls the behavior in case this happens.
*
- * - 'deny' (default) will throw an error, requiring all frames to have the exact same dimensions.
- * - 'passThrough' will allow the change and directly pass the frame to the encoder.
- * - 'fill' will stretch the image to fill the entire original box, potentially altering aspect ratio.
- * - 'contain' will contain the entire image within the originalbox while preserving aspect ratio. This may lead to
- * letterboxing.
- * - 'cover' will scale the image until the entire original box is filled, while preserving aspect ratio.
+ * - `'deny'` (default) will throw an error, requiring all frames to have the exact same dimensions.
+ * - `'passThrough'` will allow the change and directly pass the frame to the encoder.
+ * - `'fill'` will stretch the image to fill the entire original box, potentially altering aspect ratio.
+ * - `'contain'` will contain the entire image within the original box while preserving aspect ratio. This may lead
+ * to letterboxing.
+ * - `'cover'` will scale the image until the entire original box is filled, while preserving aspect ratio.
*
* The "original box" refers to the dimensions of the first encoded frame.
*/
@@ -59,7 +59,10 @@ export type VideoEncodingConfig = {
/** Called for each successfully encoded packet. Both the packet and the encoding metadata are passed. */
onEncodedPacket?: (packet: EncodedPacket, meta: EncodedVideoChunkMetadata | undefined) => unknown;
- /** Called when the internal encoder config, as used by the WebCodecs API, is created. */
+ /**
+ * Called when the internal [encoder config](https://www.w3.org/TR/webcodecs/#video-encoder-config), as used by the
+ * WebCodecs API, is created.
+ */
onEncoderConfig?: (config: VideoEncoderConfig) => unknown;
} & VideoEncodingAdditionalOptions;
@@ -79,7 +82,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.');
}
@@ -92,30 +103,33 @@ export const validateVideoEncodingConfig = (config: VideoEncodingConfig) => {
/**
* Additional options that control audio encoding.
+ * @group Encoding
* @public
*/
export type VideoEncodingAdditionalOptions = {
/** Configures the bitrate mode. */
bitrateMode?: 'constant' | 'variable';
/** The latency mode used by the encoder; controls the performance-quality tradeoff. */
- latencyMode?: VideoEncoderConfig['latencyMode'];
+ latencyMode?: 'quality' | 'realtime';
/**
* The full codec string as specified in the WebCodecs Codec Registry. This string must match the codec
* specified in `codec`. When not set, a fitting codec string will be constructed automatically by the library.
*/
fullCodecString?: string;
- /** A hint that configures the hardware acceleration method of this codec. This is best left on 'no-preference'. */
- hardwareAcceleration?: VideoEncoderConfig['hardwareAcceleration'];
+ /**
+ * A hint that configures the hardware acceleration method of this codec. This is best left on `'no-preference'`.
+ */
+ hardwareAcceleration?: 'no-preference' | 'prefer-hardware' | 'prefer-software';
/**
* An encoding scalability mode identifier as defined by
* [WebRTC-SVC](https://w3c.github.io/webrtc-svc/#scalabilitymodes*).
*/
- scalabilityMode?: VideoEncoderConfig['scalabilityMode'];
+ scalabilityMode?: string;
/**
* An encoding video content hint as defined by
* [mst-content-hint](https://w3c.github.io/mst-content-hint/#video-content-hints).
*/
- contentHint?: VideoEncoderConfig['contentHint'];
+ contentHint?: string;
};
export const validateVideoEncodingAdditionalOptions = (codec: VideoCodec, options: VideoEncodingAdditionalOptions) => {
@@ -186,20 +200,24 @@ export const buildVideoEncoderConfig = (options: {
/**
* Configuration object that controls audio encoding. Can be used to set codec, quality, and more.
+ * @group Encoding
* @public
*/
export type AudioEncodingConfig = {
/** The audio codec that should be used for encoding the audio samples. */
codec: AudioCodec;
/**
- * The target bitrate for the encoded audio, in bits per second. Alternatively, a subjective Quality can
+ * The target bitrate for the encoded audio, in bits per second. Alternatively, a subjective {@link Quality} can
* be provided. Required for compressed audio codecs, unused for PCM codecs.
*/
bitrate?: number | Quality;
/** Called for each successfully encoded packet. Both the packet and the encoding metadata are passed. */
onEncodedPacket?: (packet: EncodedPacket, meta: EncodedAudioChunkMetadata | undefined) => unknown;
- /** Called when the internal encoder config, as used by the WebCodecs API, is created. */
+ /**
+ * Called when the internal [encoder config](https://www.w3.org/TR/webcodecs/#audio-encoder-config), as used by the
+ * WebCodecs API, is created.
+ */
onEncoderConfig?: (config: AudioEncoderConfig) => unknown;
} & AudioEncodingAdditionalOptions;
@@ -235,6 +253,7 @@ export const validateAudioEncodingConfig = (config: AudioEncodingConfig) => {
/**
* Additional options that control audio encoding.
+ * @group Encoding
* @public
*/
export type AudioEncodingAdditionalOptions = {
@@ -288,8 +307,119 @@ export const buildAudioEncoderConfig = (options: {
};
};
+/**
+ * Represents a subjective media quality level.
+ * @group Encoding
+ * @public
+ */
+export class Quality {
+ /** @internal */
+ _factor: number;
+
+ /** @internal */
+ constructor(factor: number) {
+ this._factor = factor;
+ }
+
+ /** @internal */
+ _toVideoBitrate(codec: VideoCodec, width: number, height: number) {
+ const pixels = width * height;
+
+ const codecEfficiencyFactors = {
+ avc: 1.0, // H.264/AVC (baseline)
+ hevc: 0.6, // H.265/HEVC (~40% more efficient than AVC)
+ vp9: 0.6, // Similar to HEVC
+ av1: 0.4, // ~60% more efficient than AVC
+ vp8: 1.2, // Slightly less efficient than AVC
+ };
+
+ const referencePixels = 1920 * 1080;
+ const referenceBitrate = 3000000;
+ const scaleFactor = Math.pow(pixels / referencePixels, 0.95); // Slight non-linear scaling
+ const baseBitrate = referenceBitrate * scaleFactor;
+
+ const codecAdjustedBitrate = baseBitrate * codecEfficiencyFactors[codec];
+ const finalBitrate = codecAdjustedBitrate * this._factor;
+
+ return Math.ceil(finalBitrate / 1000) * 1000;
+ }
+
+ /** @internal */
+ _toAudioBitrate(codec: AudioCodec) {
+ if ((PCM_AUDIO_CODECS as readonly string[]).includes(codec) || codec === 'flac') {
+ return undefined;
+ }
+
+ const baseRates = {
+ aac: 128000, // 128kbps base for AAC
+ opus: 64000, // 64kbps base for Opus
+ mp3: 160000, // 160kbps base for MP3
+ vorbis: 64000, // 64kbps base for Vorbis
+ };
+
+ const baseBitrate = baseRates[codec as keyof typeof baseRates];
+ if (!baseBitrate) {
+ throw new Error(`Unhandled codec: ${codec}`);
+ }
+
+ let finalBitrate = baseBitrate * this._factor;
+
+ if (codec === 'aac') {
+ // AAC only works with specific bitrates, let's find the closest
+ const validRates = [96000, 128000, 160000, 192000];
+ finalBitrate = validRates.reduce((prev, curr) =>
+ Math.abs(curr - finalBitrate) < Math.abs(prev - finalBitrate) ? curr : prev,
+ );
+ } else if (codec === 'opus' || codec === 'vorbis') {
+ finalBitrate = Math.max(6000, finalBitrate);
+ } else if (codec === 'mp3') {
+ const validRates = [
+ 8000, 16000, 24000, 32000, 40000, 48000, 64000, 80000,
+ 96000, 112000, 128000, 160000, 192000, 224000, 256000, 320000,
+ ];
+ finalBitrate = validRates.reduce((prev, curr) =>
+ Math.abs(curr - finalBitrate) < Math.abs(prev - finalBitrate) ? curr : prev,
+ );
+ }
+
+ return Math.round(finalBitrate / 1000) * 1000;
+ }
+}
+
+/**
+ * Represents a very low media quality.
+ * @group Encoding
+ * @public
+ */
+export const QUALITY_VERY_LOW = new Quality(0.3);
+/**
+ * Represents a low media quality.
+ * @group Encoding
+ * @public
+ */
+export const QUALITY_LOW = new Quality(0.6);
+/**
+ * Represents a medium media quality.
+ * @group Encoding
+ * @public
+ */
+export const QUALITY_MEDIUM = new Quality(1);
+/**
+ * Represents a high media quality.
+ * @group Encoding
+ * @public
+ */
+export const QUALITY_HIGH = new Quality(2);
+/**
+ * Represents a very high media quality.
+ * @group Encoding
+ * @public
+ */
+export const QUALITY_VERY_HIGH = new Quality(4);
+
/**
* Checks if the browser is able to encode the given codec.
+ * @group Encoding
* @public
*/
export const canEncode = (codec: MediaCodec) => {
@@ -306,18 +436,24 @@ export const canEncode = (codec: MediaCodec) => {
/**
* Checks if the browser is able to encode the given video codec with the given parameters.
+ * @group Encoding
* @public
*/
-export const canEncodeVideo = async (codec: VideoCodec, {
- width = 1280,
- height = 720,
- bitrate = 1e6,
- ...restOptions
-}: {
- width?: number;
- height?: number;
- bitrate?: number | Quality;
-} & VideoEncodingAdditionalOptions = {}) => {
+export const canEncodeVideo = async (
+ codec: VideoCodec,
+ options: {
+ width?: number;
+ height?: number;
+ bitrate?: number | Quality;
+ } & VideoEncodingAdditionalOptions = {},
+) => {
+ const {
+ width = 1280,
+ height = 720,
+ bitrate = 1e6,
+ ...restOptions
+ } = options;
+
if (!VIDEO_CODECS.includes(codec)) {
return false;
}
@@ -369,18 +505,24 @@ export const canEncodeVideo = async (codec: VideoCodec, {
/**
* Checks if the browser is able to encode the given audio codec with the given parameters.
+ * @group Encoding
* @public
*/
-export const canEncodeAudio = async (codec: AudioCodec, {
- numberOfChannels = 2,
- sampleRate = 48000,
- bitrate = 128e3,
- ...restOptions
-}: {
- numberOfChannels?: number;
- sampleRate?: number;
- bitrate?: number | Quality;
-} & AudioEncodingAdditionalOptions = {}) => {
+export const canEncodeAudio = async (
+ codec: AudioCodec,
+ options: {
+ numberOfChannels?: number;
+ sampleRate?: number;
+ bitrate?: number | Quality;
+ } & AudioEncodingAdditionalOptions = {},
+) => {
+ const {
+ numberOfChannels = 2,
+ sampleRate = 48000,
+ bitrate = 128e3,
+ ...restOptions
+ } = options;
+
if (!AUDIO_CODECS.includes(codec)) {
return false;
}
@@ -434,6 +576,7 @@ export const canEncodeAudio = async (codec: AudioCodec, {
/**
* Checks if the browser is able to encode the given subtitle codec.
+ * @group Encoding
* @public
*/
export const canEncodeSubtitles = async (codec: SubtitleCodec) => {
@@ -446,6 +589,7 @@ export const canEncodeSubtitles = async (codec: SubtitleCodec) => {
/**
* Returns the list of all media codecs that can be encoded by the browser.
+ * @group Encoding
* @public
*/
export const getEncodableCodecs = async (): Promise => {
@@ -460,10 +604,11 @@ export const getEncodableCodecs = async (): Promise => {
/**
* Returns the list of all video codecs that can be encoded by the browser.
+ * @group Encoding
* @public
*/
export const getEncodableVideoCodecs = async (
- checkedCodecs = VIDEO_CODECS as unknown as VideoCodec[],
+ checkedCodecs: VideoCodec[] = VIDEO_CODECS as unknown as VideoCodec[],
options?: {
width?: number;
height?: number;
@@ -476,10 +621,11 @@ export const getEncodableVideoCodecs = async (
/**
* Returns the list of all audio codecs that can be encoded by the browser.
+ * @group Encoding
* @public
*/
export const getEncodableAudioCodecs = async (
- checkedCodecs = AUDIO_CODECS as unknown as AudioCodec[],
+ checkedCodecs: AudioCodec[] = AUDIO_CODECS as unknown as AudioCodec[],
options?: {
numberOfChannels?: number;
sampleRate?: number;
@@ -492,10 +638,11 @@ export const getEncodableAudioCodecs = async (
/**
* Returns the list of all subtitle codecs that can be encoded by the browser.
+ * @group Encoding
* @public
*/
export const getEncodableSubtitleCodecs = async (
- checkedCodecs = SUBTITLE_CODECS as unknown as SubtitleCodec[],
+ checkedCodecs: SubtitleCodec[] = SUBTITLE_CODECS as unknown as SubtitleCodec[],
): Promise => {
const bools = await Promise.all(checkedCodecs.map(canEncodeSubtitles));
return checkedCodecs.filter((_, i) => bools[i]);
@@ -503,6 +650,7 @@ export const getEncodableSubtitleCodecs = async (
/**
* Returns the first video codec from the given list that can be encoded by the browser.
+ * @group Encoding
* @public
*/
export const getFirstEncodableVideoCodec = async (
@@ -524,6 +672,7 @@ export const getFirstEncodableVideoCodec = async (
/**
* Returns the first audio codec from the given list that can be encoded by the browser.
+ * @group Encoding
* @public
*/
export const getFirstEncodableAudioCodec = async (
@@ -545,6 +694,7 @@ export const getFirstEncodableAudioCodec = async (
/**
* Returns the first subtitle codec from the given list that can be encoded by the browser.
+ * @group Encoding
* @public
*/
export const getFirstEncodableSubtitleCodec = async (
diff --git a/src/index.ts b/src/index.ts
index 9354d29..9bd1991 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -16,61 +16,53 @@ export {
VideoTrackMetadata,
AudioTrackMetadata,
SubtitleTrackMetadata,
- TrackType,
- ALL_TRACK_TYPES,
} from './output';
export {
OutputFormat,
+ AdtsOutputFormat,
+ AdtsOutputFormatOptions,
IsobmffOutputFormat,
- Mp4OutputFormat,
- MovOutputFormat,
IsobmffOutputFormatOptions,
MkvOutputFormat,
MkvOutputFormatOptions,
- WebMOutputFormat,
- WebMOutputFormatOptions,
+ MovOutputFormat,
Mp3OutputFormat,
Mp3OutputFormatOptions,
- WavOutputFormat,
- WavOutputFormatOptions,
+ Mp4OutputFormat,
OggOutputFormat,
OggOutputFormatOptions,
- AdtsOutputFormat,
- AdtsOutputFormatOptions,
- TrackCountLimits,
+ WavOutputFormat,
+ WavOutputFormatOptions,
+ WebMOutputFormat,
+ WebMOutputFormatOptions,
InclusiveIntegerRange,
+ TrackCountLimits,
} from './output-format';
export {
MediaSource,
VideoSource,
- EncodedVideoPacketSource,
- VideoSampleSource,
- CanvasSource,
- MediaStreamVideoTrackSource,
AudioSource,
- EncodedAudioPacketSource,
- AudioSampleSource,
- AudioBufferSource,
- MediaStreamAudioTrackSource,
SubtitleSource,
+ AudioBufferSource,
+ AudioSampleSource,
+ CanvasSource,
+ EncodedAudioPacketSource,
+ EncodedVideoPacketSource,
+ MediaStreamAudioTrackSource,
+ MediaStreamVideoTrackSource,
TextSubtitleSource,
+ VideoSampleSource,
} from './media-source';
export {
- VIDEO_CODECS,
+ MediaCodec,
VideoCodec,
+ AudioCodec,
+ SubtitleCodec,
+ VIDEO_CODECS,
+ AUDIO_CODECS,
PCM_AUDIO_CODECS,
NON_PCM_AUDIO_CODECS,
- AUDIO_CODECS,
- AudioCodec,
SUBTITLE_CODECS,
- SubtitleCodec,
- MediaCodec,
- Quality,
- QUALITY_VERY_LOW,
- QUALITY_LOW,
- QUALITY_MEDIUM,
- QUALITY_HIGH,
- QUALITY_VERY_HIGH,
} from './codec';
export {
VideoEncodingConfig,
@@ -88,64 +80,110 @@ export {
getFirstEncodableVideoCodec,
getFirstEncodableAudioCodec,
getFirstEncodableSubtitleCodec,
+ Quality,
+ QUALITY_VERY_LOW,
+ QUALITY_LOW,
+ QUALITY_MEDIUM,
+ QUALITY_HIGH,
+ QUALITY_VERY_HIGH,
} from './encode';
-export { Target, BufferTarget, StreamTarget, StreamTargetChunk, StreamTargetOptions } from './target';
-export { Rotation, AnyIterable, SetRequired, MaybePromise } from './misc';
+export {
+ Target,
+ BufferTarget,
+ NullTarget,
+ StreamTarget,
+ StreamTargetOptions,
+ StreamTargetChunk,
+} from './target';
+export {
+ AnyIterable,
+ MaybePromise,
+ Rotation,
+ SetRequired,
+} from './misc';
+export {
+ TrackType,
+ ALL_TRACK_TYPES,
+} from './output';
export {
Source,
+ BlobSource,
+ BlobSourceOptions,
BufferSource,
+ FilePathSource,
+ FilePathSourceOptions,
StreamSource,
StreamSourceOptions,
- BlobSource,
+ ReadableStreamSource,
+ ReadableStreamSourceOptions,
UrlSource,
UrlSourceOptions,
} from './source';
export {
InputFormat,
+ AdtsInputFormat,
IsobmffInputFormat,
- Mp4InputFormat,
- QuickTimeInputFormat,
MatroskaInputFormat,
- WebMInputFormat,
Mp3InputFormat,
- WaveInputFormat,
+ Mp4InputFormat,
OggInputFormat,
+ QuickTimeInputFormat,
+ WaveInputFormat,
+ WebMInputFormat,
ALL_FORMATS,
- MP4,
- QTFF,
+ ADTS,
MATROSKA,
- WEBM,
MP3,
- WAVE,
+ MP4,
OGG,
+ QTFF,
+ WAVE,
+ WEBM,
} from './input-format';
-export { Input, InputOptions } from './input';
-export { InputTrack, InputVideoTrack, InputAudioTrack, PacketStats } from './input-track';
-export { EncodedPacket, PacketType } from './packet';
export {
- VideoSample,
- VideoSampleInit,
+ Input,
+ InputOptions,
+} from './input';
+export {
+ InputTrack,
+ InputVideoTrack,
+ InputAudioTrack,
+ PacketStats,
+} from './input-track';
+export {
+ EncodedPacket,
+ PacketType,
+} from './packet';
+export {
AudioSample,
AudioSampleInit,
AudioSampleCopyToOptions,
+ VideoSample,
+ VideoSampleInit,
} from './sample';
export {
- PacketRetrievalOptions,
- EncodedPacketSink,
- BaseMediaSampleSink,
- VideoSampleSink,
- CanvasSinkOptions,
- CanvasSink,
- WrappedCanvas,
- AudioSampleSink,
AudioBufferSink,
+ AudioSampleSink,
+ BaseMediaSampleSink,
+ CanvasSink,
+ CanvasSinkOptions,
+ EncodedPacketSink,
+ PacketRetrievalOptions,
+ VideoSampleSink,
WrappedAudioBuffer,
+ WrappedCanvas,
} from './media-sink';
-export { Conversion, ConversionOptions, ConversionVideoOptions, ConversionAudioOptions } from './conversion';
+export {
+ Conversion,
+ ConversionOptions,
+ ConversionVideoOptions,
+ ConversionAudioOptions,
+ DiscardedTrack,
+} from './conversion';
export {
CustomVideoDecoder,
- CustomAudioDecoder,
CustomVideoEncoder,
+ CustomAudioDecoder,
CustomAudioEncoder,
registerDecoder,
registerEncoder,
diff --git a/src/input-format.ts b/src/input-format.ts
index a6b6d78..01aa838 100644
--- a/src/input-format.ts
+++ b/src/input-format.ts
@@ -9,21 +9,29 @@
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 { ID3_V2_HEADER_SIZE, Mp3Reader } from './mp3/mp3-reader';
+import { ID3_V2_HEADER_SIZE, readId3V2Header, 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.
+ * @group Input formats
* @public
*/
export abstract class InputFormat {
@@ -41,25 +49,24 @@ export abstract class InputFormat {
/**
* Format representing files compatible with the ISO base media file format (ISOBMFF), like MP4 or MOV files.
+ * @group Input formats
* @public
*/
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 */
@@ -70,6 +77,10 @@ export abstract class IsobmffInputFormat extends InputFormat {
/**
* MPEG-4 Part 14 (MP4) file format.
+ *
+ * Do not instantiate this class; use the {@link MP4} singleton instead.
+ *
+ * @group Input formats
* @public
*/
export class Mp4InputFormat extends IsobmffInputFormat {
@@ -90,6 +101,10 @@ export class Mp4InputFormat extends IsobmffInputFormat {
/**
* QuickTime File Format (QTFF), often called MOV.
+ *
+ * Do not instantiate this class; use the {@link QTFF} singleton instead.
+ *
+ * @group Input formats
* @public
*/
export class QuickTimeInputFormat extends IsobmffInputFormat {
@@ -108,81 +123,82 @@ export class QuickTimeInputFormat extends IsobmffInputFormat {
}
}
-function foo() {
- return 5;
-}
-
/**
* Matroska file format.
+ *
+ * Do not instantiate this class; use the {@link MATROSKA} singleton instead.
+ *
+ * @group Input formats
* @public
*/
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;
@@ -209,6 +225,10 @@ export class MatroskaInputFormat extends InputFormat {
/**
* WebM file format, based on Matroska.
+ *
+ * Do not instantiate this class; use the {@link WEBM} singleton instead.
+ *
+ * @group Input formats
* @public
*/
export class WebMInputFormat extends MatroskaInputFormat {
@@ -228,38 +248,38 @@ export class WebMInputFormat extends MatroskaInputFormat {
/**
* MP3 file format.
+ *
+ * Do not instantiate this class; use the {@link MP3} singleton instead.
+ *
+ * @group Input formats
* @public
*/
export class Mp3InputFormat extends InputFormat {
/** @internal */
async _canReadInput(input: Input) {
- const sourceSize = await input._mainReader.source.getSize();
- if (sourceSize < 4) {
- return false;
- }
-
- const mp3Reader = new Mp3Reader(input._mainReader);
- mp3Reader.fileSize = sourceSize;
+ let slice = input._reader.requestSlice(0, 10);
+ if (slice instanceof Promise) slice = await slice;
+ if (!slice) return false;
+ let currentPos = 0;
let id3V2HeaderFound = false;
while (true) {
- await mp3Reader.reader.loadRange(mp3Reader.pos, mp3Reader.pos + ID3_V2_HEADER_SIZE);
+ let slice = input._reader.requestSlice(currentPos, ID3_V2_HEADER_SIZE);
+ if (slice instanceof Promise) slice = await slice;
+ if (!slice) break;
- const id3V2Header = mp3Reader.readId3V2Header();
+ const id3V2Header = readId3V2Header(slice);
if (!id3V2Header) {
break;
}
id3V2HeaderFound = true;
- mp3Reader.pos += id3V2Header.size;
+ currentPos = slice.filePos + id3V2Header.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;
}
@@ -268,15 +288,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;
@@ -302,24 +325,27 @@ export class Mp3InputFormat extends InputFormat {
/**
* WAVE file format, based on RIFF.
+ *
+ * Do not instantiate this class; use the {@link WAVE} singleton instead.
+ *
+ * @group Input formats
* @public
*/
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';
}
@@ -339,18 +365,20 @@ export class WaveInputFormat extends InputFormat {
/**
* Ogg file format.
+ *
+ * Do not instantiate this class; use the {@link OGG} singleton instead.
+ *
+ * @group Input formats
* @public
*/
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 */
@@ -369,29 +397,29 @@ export class OggInputFormat extends InputFormat {
/**
* ADTS file format.
+ *
+ * Do not instantiate this class; use the {@link ADTS} singleton instead.
+ *
+ * @group Input formats
* @public
*/
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;
}
@@ -417,41 +445,49 @@ export class AdtsInputFormat extends InputFormat {
/**
* MP4 input format singleton.
+ * @group Input formats
* @public
*/
export const MP4 = new Mp4InputFormat();
/**
* QuickTime File Format input format singleton.
+ * @group Input formats
* @public
*/
export const QTFF = new QuickTimeInputFormat();
/**
* Matroska input format singleton.
+ * @group Input formats
* @public
*/
export const MATROSKA = new MatroskaInputFormat();
/**
* WebM input format singleton.
+ * @group Input formats
* @public
*/
export const WEBM = new WebMInputFormat();
/**
* MP3 input format singleton.
+ * @group Input formats
* @public
*/
export const MP3 = new Mp3InputFormat();
/**
* WAVE input format singleton.
+ * @group Input formats
* @public
*/
export const WAVE = new WaveInputFormat();
/**
* Ogg input format singleton.
+ * @group Input formats
* @public
*/
export const OGG = new OggInputFormat();
/**
* ADTS input format singleton.
+ * @group Input formats
* @public
*/
export const ADTS = new AdtsInputFormat();
@@ -459,6 +495,7 @@ export const ADTS = new AdtsInputFormat();
/**
* List of all input format singletons. If you don't need to support all input formats, you should specify the
* formats individually for better tree shaking.
+ * @group Input formats
* @public
*/
export const ALL_FORMATS: InputFormat[] = [MP4, QTFF, MATROSKA, WEBM, WAVE, OGG, MP3, ADTS];
diff --git a/src/input-track.ts b/src/input-track.ts
index bb5ec42..b093b0d 100644
--- a/src/input-track.ts
+++ b/src/input-track.ts
@@ -16,6 +16,7 @@ import { EncodedPacket, PacketType } from './packet';
/**
* Contains aggregate statistics about the encoded packets of a track.
+ * @group Input files & tracks
* @public
*/
export type PacketStats = {
@@ -46,6 +47,7 @@ export interface InputTrackBacking {
/**
* Represents a media track in an input file.
+ * @group Input files & tracks
* @public
*/
export abstract class InputTrack {
@@ -71,12 +73,12 @@ export abstract class InputTrack {
*/
abstract determinePacketType(packet: EncodedPacket): Promise;
- /** Returns true iff this track is a video track. */
+ /** Returns true if and only if this track is a video track. */
isVideoTrack(): this is InputVideoTrack {
return this instanceof InputVideoTrack;
}
- /** Returns true iff this track is an audio track. */
+ /** Returns true if and only if this track is an audio track. */
isAudioTrack(): this is InputAudioTrack {
return this instanceof InputAudioTrack;
}
@@ -92,17 +94,19 @@ export abstract class InputTrack {
*
* This field can be used to determine the codec of a track in case Mediabunny doesn't know that codec.
*
- * - For ISOBMFF files, this field returns the name of the Sample Description Box (e.g. 'avc1').
- * - For Matroska files, this field returns the value of the CodecID element.
- * - For WAVE files, this field returns the value of the format tag in the 'fmt ' chunk.
- * - For ADTS files, this field contains the MPEG-4 Audio Object Type.
+ * - For ISOBMFF files, this field returns the name of the Sample Description Box (e.g. `'avc1'`).
+ * - For Matroska files, this field returns the value of the `CodecID` element.
+ * - For WAVE files, this field returns the value of the format tag in the `'fmt '` chunk.
+ * - For ADTS files, this field contains the `MPEG-4 Audio Object Type`.
* - In all other cases, this field is `null`.
*/
get internalCodecId() {
return this._backing.getInternalCodecId();
}
- /** The ISO 639-2/T language code for this track. If the language is unknown, this field is 'und' (undetermined). */
+ /**
+ * The ISO 639-2/T language code for this track. If the language is unknown, this field is `'und'` (undetermined).
+ */
get languageCode() {
return this._backing.getLanguageCode();
}
@@ -189,6 +193,7 @@ export interface InputVideoTrackBacking extends InputTrackBacking {
/**
* Represents a video track in an input file.
+ * @group Input files & tracks
* @public
*/
export class InputVideoTrack extends InputTrack {
@@ -206,7 +211,7 @@ export class InputVideoTrack extends InputTrack {
return 'video';
}
- get codec() {
+ get codec(): VideoCodec | null {
return this._backing.getCodec();
}
@@ -252,8 +257,8 @@ export class InputVideoTrack extends InputTrack {
}
/**
- * Returns the decoder configuration for decoding the track's packets using a VideoDecoder. Returns null if the
- * track's codec is unknown.
+ * Returns the [decoder configuration](https://www.w3.org/TR/webcodecs/#video-decoder-config) for decoding the
+ * track's packets using a VideoDecoder. Returns null if the track's codec is unknown.
*/
getDecoderConfig() {
return this._backing.getDecoderConfig();
@@ -315,6 +320,7 @@ export interface InputAudioTrackBacking extends InputTrackBacking {
/**
* Represents an audio track in an input file.
+ * @group Input files & tracks
* @public
*/
export class InputAudioTrack extends InputTrack {
@@ -347,8 +353,8 @@ export class InputAudioTrack extends InputTrack {
}
/**
- * Returns the decoder configuration for decoding the track's packets using an AudioDecoder. Returns null if the
- * track's codec is unknown.
+ * Returns the [decoder configuration](https://www.w3.org/TR/webcodecs/#audio-decoder-config) for decoding the
+ * track's packets using an AudioDecoder. Returns null if the track's codec is unknown.
*/
getDecoderConfig() {
return this._backing.getDecoderConfig();
diff --git a/src/input.ts b/src/input.ts
index 10c15e8..bc52896 100644
--- a/src/input.ts
+++ b/src/input.ts
@@ -14,6 +14,7 @@ import { Source } from './source';
/**
* The options for creating an Input object.
+ * @group Input files & tracks
* @public
*/
export type InputOptions = {
@@ -25,6 +26,7 @@ export type InputOptions = {
/**
* Represents an input media file. This is the root object from which all media read operations start.
+ * @group Input files & tracks
* @public
*/
export class Input {
@@ -33,12 +35,16 @@ export class Input {
/** @internal */
_formats: InputFormat[];
/** @internal */
- _mainReader: Reader;
- /** @internal */
_demuxerPromise: Promise | null = null;
/** @internal */
_format: InputFormat | null = null;
+ /** @internal */
+ _reader: Reader;
+ /**
+ * Creates a new input file from the specified options. No reading operations will be performed until methods are
+ * called on this instance.
+ */
constructor(options: InputOptions) {
if (!options || typeof options !== 'object') {
throw new TypeError('options must be an object.');
@@ -52,13 +58,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.getSizeOrNull();
for (const format of this._formats) {
const canRead = await format._canReadInput(this);
@@ -81,9 +87,9 @@ export class Input {
}
/**
- * Returns the format of the input file. You can compare this result directly to the InputFormat singletons or use
- * `instanceof` checks for subset-aware logic (for example, `format instanceof MatroskaInputFormat` is true for
- * both MKV and WebM).
+ * Returns the format of the input file. You can compare this result directly to the {@link InputFormat} singletons
+ * or use `instanceof` checks for subset-aware logic (for example, `format instanceof MatroskaInputFormat` is true
+ * for both MKV and WebM).
*/
async getFormat() {
await this._getDemuxer();
@@ -112,18 +118,18 @@ export class Input {
return tracks.filter(x => x.isVideoTrack());
}
- /** Returns the primary video track of this input file, or null if there are no video tracks. */
- async getPrimaryVideoTrack() {
- const tracks = await this.getTracks();
- return tracks.find(x => x.isVideoTrack()) ?? null;
- }
-
/** Returns the list of all audio tracks of this input file. */
async getAudioTracks() {
const tracks = await this.getTracks();
return tracks.filter(x => x.isAudioTrack());
}
+ /** Returns the primary video track of this input file, or null if there are no video tracks. */
+ async getPrimaryVideoTrack() {
+ const tracks = await this.getTracks();
+ return tracks.find(x => x.isVideoTrack()) ?? null;
+ }
+
/** Returns the primary audio track of this input file, or null if there are no audio tracks. */
async getPrimaryAudioTrack() {
const tracks = await this.getTracks();
diff --git a/src/isobmff/isobmff-demuxer.ts b/src/isobmff/isobmff-demuxer.ts
index ba5ec2d..f43c784 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 (true) {
+ 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) {
+ if (this.isFragmented && this.reader.fileSize !== null) {
// 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 (true) {
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..9da08d4 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,8 @@ export enum EBMLId {
DocType = 0x4282,
DocTypeVersion = 0x4287,
DocTypeReadVersion = 0x4285,
+ Void = 0xec,
+ Segment = 0x18538067,
SeekHead = 0x114d9b74,
Seek = 0x4dbb,
SeekID = 0x53ab,
@@ -101,7 +103,6 @@ export enum EBMLId {
SamplingFrequency = 0xb5,
Channels = 0x9f,
BitDepth = 0x6264,
- Segment = 0x18538067,
SimpleBlock = 0xa3,
BlockGroup = 0xa0,
Block = 0xa1,
@@ -397,250 +398,220 @@ 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 | null,
+): Promise<{ pos: number; found: boolean }> => {
+ const idsSet = new Set(ids);
+ let currentPos = startPos;
+
+ while (until === null || 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 { pos: currentPos, found: true };
+ }
+
+ assertDefinedSize(elementHeader.size);
+
+ currentPos = slice.filePos + elementHeader.size;
+ }
+
+ return { pos: (until !== null && until > currentPos) ? until : currentPos, found: false };
+};
+
+/** 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 +638,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 a1f4b37..21ba787 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;
@@ -77,7 +85,7 @@ type Segment = {
cuePoints: CuePoint[];
dataStartPos: number;
- elementEndPos: number;
+ elementEndPos: number | null;
clusterSeekStartPos: number;
clusters: Cluster[];
@@ -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,40 +234,48 @@ export class MatroskaDemuxer extends Demuxer {
readMetadata() {
return this.readMetadataPromise ??= (async () => {
- this.metadataReader.pos = 0;
-
- const fileSize = await this.input.source.getSize();
+ let currentPos = 0;
// 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,
- );
+ while (true) {
+ 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
// and only segment
break;
}
+
+ if (this.reader.fileSize === null) {
+ break; // Stop at the first segment
+ }
} else if (id === EBMLId.Cluster) {
+ if (this.reader.fileSize === null) {
+ break; // Shouldn't be reached anyway, since we stop at the first segment
+ }
+
// Clusters are not a top-level element in Matroska, but some files contain a Segment whose size
// doesn't contain any of the clusters that follow it. In the case, we apply the following logic: if
// we find a top-level cluster, attribute it to the previous segment.
@@ -271,29 +283,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.pos - 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,
@@ -309,7 +321,7 @@ export class MatroskaDemuxer extends Demuxer {
dataStartPos: segmentDataStart,
elementEndPos: dataSize === null
- ? await this.input.source.getSize() // Assume it goes until the end of the file
+ ? null // Assume it goes until the end of the file
: segmentDataStart + dataSize,
clusterSeekStartPos: segmentDataStart,
@@ -318,33 +330,28 @@ 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 = segmentDataStart;
- 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,
- );
+ while (this.currentSegment.elementEndPos === null || 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 = this.metadataReader.pos;
- const header = this.metadataReader.readElementHeader();
+ const elementStartPos = currentPos;
+ const header = readElementHeader(slice);
- if (!header || !LEVEL_1_EBML_IDS.includes(header.id)) {
+ if (!header || (!LEVEL_1_EBML_IDS.includes(header.id) && header.id !== EBMLId.Void)) {
// 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 ?? Infinity, elementStartPos + MAX_RESYNC_LENGTH),
);
if (nextPos) {
- this.metadataReader.pos = nextPos;
+ currentPos = nextPos;
continue;
} else {
break; // Resync failed
@@ -352,7 +359,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,84 +367,62 @@ 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;
- this.currentSegment.clusterSeekStartPos = elementStartPos;
- }
- }
-
- if (size !== null) {
- this.metadataReader.pos = dataStartPos + size;
- }
-
- if (this.currentSegment.infoSeen && this.currentSegment.tracksSeen && this.currentSegment.cuesSeen) {
- // No need to search anymore, we have everything
- break;
- }
-
- if (this.currentSegment.seekHeadSeen) {
- let hasInfo = this.currentSegment.infoSeen;
- let hasTracks = this.currentSegment.tracksSeen;
- let hasCues = this.currentSegment.cuesSeen;
-
- for (const entry of this.currentSegment.seekEntries) {
- if (entry.id === EBMLId.Info) {
- hasInfo = true;
- } else if (entry.id === EBMLId.Tracks) {
- hasTracks = true;
- } else if (entry.id === EBMLId.Cues) {
- hasCues = true;
- }
- }
-
- if (hasInfo && hasTracks && hasCues) {
- // No need to search sequentially anymore, we can use the seek head
- break;
- }
+ this.currentSegment.clusterSeekStartPos = elementStartPos;
+ break; // Stop at the first cluster
}
if (size === null) {
break;
- }
- }
-
- if (!clusterEncountered) {
- const seekEntry = this.currentSegment.seekEntries.find(entry => entry.id === EBMLId.Cluster);
-
- if (seekEntry) {
- // The seek head points us to the first cluster, nice
- this.currentSegment.clusterSeekStartPos = segmentDataStart + seekEntry.segmentPosition;
} else {
- this.currentSegment.clusterSeekStartPos = this.metadataReader.pos;
+ currentPos = dataStartPos + size;
}
}
- // Use the seek head to read missing metadata elements
- for (const target of METADATA_ELEMENTS) {
- if (this.currentSegment[target.flag]) continue;
+ if (this.reader.fileSize !== null) {
+ // Sort the seek entries by file position so reading them exhibits a sequential pattern
+ this.currentSegment.seekEntries.sort((a, b) => a.segmentPosition - b.segmentPosition);
- const seekEntry = this.currentSegment.seekEntries.find(entry => entry.id === target.id);
- if (!seekEntry) continue;
+ // Use the seek head to read missing metadata elements
+ for (const seekEntry of this.currentSegment.seekEntries) {
+ const target = METADATA_ELEMENTS.find(x => x.id === seekEntry.id);
+ if (!target) {
+ 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
- );
- const header = this.metadataReader.readElementHeader();
- if (!header) continue;
+ if (this.currentSegment[target.flag]) continue;
- const { id, size } = header;
- if (id !== target.id) continue;
+ let slice = this.reader.requestSliceRange(
+ segmentDataStart + seekEntry.segmentPosition,
+ MIN_HEADER_SIZE,
+ MAX_HEADER_SIZE,
+ );
+ if (slice instanceof Promise) slice = await slice;
+ if (!slice) continue;
- assertDefinedSize(size);
+ const header = readElementHeader(slice);
+ if (!header) continue;
- this.currentSegment[target.flag] = true;
- await this.metadataReader.reader.loadRange(this.metadataReader.pos, this.metadataReader.pos + size);
- this.readContiguousElements(this.metadataReader, size);
+ const { id, size } = header;
+ if (id !== target.id) continue;
+
+ assertDefinedSize(size);
+
+ this.currentSegment[target.flag] = true;
+
+ 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,35 +487,38 @@ 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,
);
- size = (nextElementPos ?? segment.elementEndPos) - dataStartPos;
+ size = nextElementPos.pos - dataStartPos;
}
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 +530,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 +642,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 +655,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 +669,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 +684,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 +718,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 +738,6 @@ export class MatroskaDemuxer extends Demuxer {
data: frameData,
lacing: BlockLacing.None,
});
-
- dataOffset += frameSize;
}
blockIndex += frameCount; // Skip the blocks we just added
@@ -756,11 +745,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 +757,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 +787,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 +831,7 @@ export class MatroskaDemuxer extends Demuxer {
info: null,
};
- this.readContiguousElements(reader, size);
+ this.readContiguousElements(slice.slice(dataStartPos, size));
if (
this.currentTrack
@@ -941,13 +930,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 +963,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,39 +973,42 @@ 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: {
if (!this.currentTrack) break;
- if (this.currentTrack.languageCode) break; // LanguageBCP47 was present, which takes precedence
+ if (this.currentTrack.languageCode !== UNDETERMINED_LANGUAGE) {
+ // LanguageBCP47 was present, which takes precedence
+ break;
+ }
- this.currentTrack.languageCode = reader.readAsciiString(size);
+ this.currentTrack.languageCode = readAsciiString(slice, size);
if (!isIso639Dash2LanguageCode(this.currentTrack.languageCode)) {
this.currentTrack.languageCode = UNDETERMINED_LANGUAGE;
@@ -1026,7 +1018,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) {
@@ -1043,32 +1035,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;
@@ -1076,13 +1068,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;
@@ -1090,7 +1082,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;
@@ -1098,13 +1090,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 {
@@ -1117,36 +1109,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: {
@@ -1155,7 +1147,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();
@@ -1166,7 +1158,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: {
@@ -1174,24 +1166,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
@@ -1201,7 +1193,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;
@@ -1209,7 +1201,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++) {
@@ -1223,12 +1215,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);
@@ -1237,7 +1229,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);
@@ -1246,7 +1238,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: {
@@ -1254,14 +1246,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;
}
}
@@ -1615,10 +1607,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;
@@ -1632,24 +1620,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 (segment.elementEndPos === null || currentPos <= segment.elementEndPos - MIN_HEADER_SIZE) {
if (prevCluster) {
const trackData = prevCluster.trackData.get(this.internalTrack.id);
if (trackData && trackData.startTimestamp > latestTimestamp) {
@@ -1659,30 +1648,35 @@ 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;
- 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
+ const elementStartPos = currentPos;
+ const elementHeader = readElementHeader(slice);
+
+ if (
+ !elementHeader
+ || (!LEVEL_1_EBML_IDS.includes(elementHeader.id) && elementHeader.id !== EBMLId.Void)
+ ) {
+ // There's an element here that shouldn't be here. 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 ?? Infinity, elementStartPos + MAX_RESYNC_LENGTH),
);
if (nextPos) {
- metadataReader.pos = nextPos;
+ currentPos = nextPos;
continue;
} else {
break; // Resync failed
@@ -1691,7 +1685,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);
@@ -1699,8 +1693,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]!;
@@ -1736,25 +1729,30 @@ 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,
);
- size = (nextElementPos ?? segment.elementEndPos) - dataStartPos;
+ size = nextElementPos.pos - dataStartPos;
}
const endPos = dataStartPos + size;
- if (endPos > segment.elementEndPos - MIN_HEADER_SIZE) {
+ if (segment.elementEndPos !== null && endPos > segment.elementEndPos - MIN_HEADER_SIZE) {
// No more elements fit in this segment
break;
} else {
// 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;
@@ -1762,7 +1760,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/media-sink.ts b/src/media-sink.ts
index 6506f4f..643f259 100644
--- a/src/media-sink.ts
+++ b/src/media-sink.ts
@@ -32,11 +32,12 @@ import { AudioSample, VideoSample } from './sample';
/**
* Additional options for controlling packet retrieval.
+ * @group Media sinks
* @public
*/
export type PacketRetrievalOptions = {
/**
- * When set to true, only packet metadata (like timestamp) will be retrieved - the actual packet data will not
+ * When set to `true`, only packet metadata (like timestamp) will be retrieved - the actual packet data will not
* be loaded.
*/
metadataOnly?: boolean;
@@ -97,12 +98,14 @@ const maybeFixPacketType = (
/**
* Sink for retrieving encoded packets from an input track.
+ * @group Media sinks
* @public
*/
export class EncodedPacketSink {
/** @internal */
_track: InputTrack;
+ /** Creates a new {@link EncodedPacketSink} for the given {@link InputTrack}. */
constructor(track: InputTrack) {
if (!(track instanceof InputTrack)) {
throw new TypeError('track must be an InputTrack.');
@@ -342,6 +345,7 @@ abstract class DecoderWrapper<
/**
* Base class for decoded media sample sinks.
+ * @group Media sinks
* @public
*/
export abstract class BaseMediaSampleSink<
@@ -894,12 +898,14 @@ class VideoDecoderWrapper extends DecoderWrapper {
/**
* A sink that retrieves decoded video samples (video frames) from a video track.
+ * @group Media sinks
* @public
*/
export class VideoSampleSink extends BaseMediaSampleSink {
/** @internal */
_videoTrack: InputVideoTrack;
+ /** Creates a new {@link VideoSampleSink} for the given {@link InputVideoTrack}. */
constructor(videoTrack: InputVideoTrack) {
if (!(videoTrack instanceof InputVideoTrack)) {
throw new TypeError('videoTrack must be an InputVideoTrack.');
@@ -978,6 +984,7 @@ export class VideoSampleSink extends BaseMediaSampleSink {
/**
* A canvas with additional timing information (timestamp & duration).
+ * @group Media sinks
* @public
*/
export type WrappedCanvas = {
@@ -991,6 +998,7 @@ export type WrappedCanvas = {
/**
* Options for constructing a CanvasSink.
+ * @group Media sinks
* @public
*/
export type CanvasSinkOptions = {
@@ -1007,10 +1015,10 @@ export type CanvasSinkOptions = {
/**
* The fitting algorithm in case both width and height are set.
*
- * - 'fill' will stretch the image to fill the entire box, potentially altering aspect ratio.
- * - 'contain' will contain the entire image within the box while preserving aspect ratio. This may lead to
+ * - `'fill'` will stretch the image to fill the entire box, potentially altering aspect ratio.
+ * - `'contain'` will contain the entire image within the box while preserving aspect ratio. This may lead to
* letterboxing.
- * - 'cover' will scale the image until the entire box is filled, while preserving aspect ratio.
+ * - `'cover'` will scale the image until the entire box is filled, while preserving aspect ratio.
*/
fit?: 'fill' | 'contain' | 'cover';
/**
@@ -1032,7 +1040,9 @@ export type CanvasSinkOptions = {
* directly retrieving frames, as it comes with common preprocessing steps such as resizing or applying rotation
* metadata.
*
- * This sink will yield HTMLCanvasElements when in a DOM context, and OffscreenCanvases otherwise.
+ * This sink will yield `HTMLCanvasElement`s when in a DOM context, and `OffscreenCanvas`es otherwise.
+ *
+ * @group Media sinks
* @public
*/
export class CanvasSink {
@@ -1053,6 +1063,7 @@ export class CanvasSink {
/** @internal */
_nextCanvasIndex = 0;
+ /** Creates a new {@link CanvasSink} for the given {@link InputVideoTrack}. */
constructor(videoTrack: InputVideoTrack, options: CanvasSinkOptions = {}) {
if (!(videoTrack instanceof InputVideoTrack)) {
throw new TypeError('videoTrack must be an InputVideoTrack.');
@@ -1217,6 +1228,10 @@ class AudioDecoderWrapper extends DecoderWrapper {
customDecoderCallSerializer = new CallSerializer();
customDecoderQueueSize = 0;
+ // Internal state to accumulate a precise current timestamp based on audio durations, not the (potentially
+ // inaccurate) packet timestamps.
+ currentTimestamp: number | null = null;
+
constructor(
onSample: (sample: AudioSample) => unknown,
onError: (error: DOMException) => unknown,
@@ -1226,6 +1241,17 @@ class AudioDecoderWrapper extends DecoderWrapper {
super(onSample, onError);
const sampleHandler = (sample: AudioSample) => {
+ if (
+ this.currentTimestamp === null
+ || Math.abs(sample.timestamp - this.currentTimestamp) >= sample.duration
+ ) {
+ // We need to sync with the sample timestamp again
+ this.currentTimestamp = sample.timestamp;
+ }
+
+ const preciseTimestamp = this.currentTimestamp;
+ this.currentTimestamp += sample.duration;
+
if (sample.numberOfFrames === 0) {
// We skip zero-data (empty) AudioSamples. These are sometimes emitted, for example, by Firefox when it
// decodes Vorbis (at the start).
@@ -1235,7 +1261,7 @@ class AudioDecoderWrapper extends DecoderWrapper {
// Round the timestamp to the sample rate
const sampleRate = decoderConfig.sampleRate;
- sample.setTimestamp(Math.round(sample.timestamp * sampleRate) / sampleRate);
+ sample.setTimestamp(Math.round(preciseTimestamp * sampleRate) / sampleRate);
onSample(sample);
};
@@ -1320,7 +1346,7 @@ class PcmAudioDecoderWrapper extends DecoderWrapper {
writeOutputValue: (view: DataView, byteOffset: number, value: number) => void;
// Internal state to accumulate a precise current timestamp based on audio durations, not the (potentially
- // inaccurate) sample timestamps.
+ // inaccurate) packet timestamps.
currentTimestamp: number | null = null;
constructor(
@@ -1494,12 +1520,14 @@ class PcmAudioDecoderWrapper extends DecoderWrapper {
/**
* Sink for retrieving decoded audio samples from an audio track.
+ * @group Media sinks
* @public
*/
export class AudioSampleSink extends BaseMediaSampleSink {
/** @internal */
_audioTrack: InputAudioTrack;
+ /** Creates a new {@link AudioSampleSink} for the given {@link InputAudioTrack}. */
constructor(audioTrack: InputAudioTrack) {
if (!(audioTrack instanceof InputAudioTrack)) {
throw new TypeError('audioTrack must be an InputAudioTrack.');
@@ -1580,6 +1608,7 @@ export class AudioSampleSink extends BaseMediaSampleSink {
/**
* An AudioBuffer with additional timing information (timestamp & duration).
+ * @group Media sinks
* @public
*/
export type WrappedAudioBuffer = {
@@ -1592,14 +1621,17 @@ export type WrappedAudioBuffer = {
};
/**
- * A sink that retrieves decoded audio samples from an audio track and converts them to AudioBuffers. This is often
- * more useful than directly retrieving audio samples, as AudioBuffers can be directly used with the Web Audio API.
+ * A sink that retrieves decoded audio samples from an audio track and converts them to `AudioBuffer` instances. This is
+ * often more useful than directly retrieving audio samples, as audio buffers can be directly used with the
+ * Web Audio API.
+ * @group Media sinks
* @public
*/
export class AudioBufferSink {
/** @internal */
_audioSampleSink: AudioSampleSink;
+ /** Creates a new {@link AudioBufferSink} for the given {@link InputAudioTrack}. */
constructor(audioTrack: InputAudioTrack) {
if (!(audioTrack instanceof InputAudioTrack)) {
throw new TypeError('audioTrack must be an InputAudioTrack.');
diff --git a/src/media-source.ts b/src/media-source.ts
index d8730e1..89b5485 100644
--- a/src/media-source.ts
+++ b/src/media-source.ts
@@ -41,6 +41,7 @@ import {
/**
* Base class for media sources. Media sources are used to add media samples to an output file.
+ * @group Media sources
* @public
*/
export abstract class MediaSource {
@@ -131,6 +132,7 @@ export abstract class MediaSource {
/**
* Base class for video sources - sources for video tracks.
+ * @group Media sources
* @public
*/
export abstract class VideoSource extends MediaSource {
@@ -139,6 +141,7 @@ export abstract class VideoSource extends MediaSource {
/** @internal */
_codec: VideoCodec;
+ /** Internal constructor. */
constructor(codec: VideoCodec) {
super();
@@ -152,9 +155,11 @@ export abstract class VideoSource extends MediaSource {
/**
* The most basic video source; can be used to directly pipe encoded packets into the output file.
+ * @group Media sources
* @public
*/
export class EncodedVideoPacketSource extends VideoSource {
+ /** Creates a new {@link EncodedVideoPacketSource} whose packets are encoded using `codec`. */
constructor(codec: VideoCodec) {
super(codec);
}
@@ -458,12 +463,17 @@ class VideoEncoderWrapper {
/**
* This source can be used to add raw, unencoded video samples (frames) to an output video track. These frames will
* automatically be encoded and then piped into the output.
+ * @group Media sources
* @public
*/
export class VideoSampleSource extends VideoSource {
/** @internal */
private _encoder: VideoEncoderWrapper;
+ /**
+ * Creates a new {@link VideoSampleSource} whose samples are encoded according to the specified
+ * {@link VideoEncodingConfig}.
+ */
constructor(encodingConfig: VideoEncodingConfig) {
validateVideoEncodingConfig(encodingConfig);
@@ -493,7 +503,8 @@ export class VideoSampleSource extends VideoSource {
/**
* This source can be used to add video frames to the output track from a fixed canvas element. Since canvases are often
- * used for rendering, this source provides a convenient wrapper around VideoSampleSource.
+ * used for rendering, this source provides a convenient wrapper around {@link VideoSampleSource}.
+ * @group Media sources
* @public
*/
export class CanvasSource extends VideoSource {
@@ -502,6 +513,10 @@ export class CanvasSource extends VideoSource {
/** @internal */
private _canvas: HTMLCanvasElement | OffscreenCanvas;
+ /**
+ * Creates a new {@link CanvasSource} from a canvas element or `OffscreenCanvas` whose samples are encoded
+ * according to the specified {@link VideoEncodingConfig}.
+ */
constructor(canvas: HTMLCanvasElement | OffscreenCanvas, encodingConfig: VideoEncodingConfig) {
if (
!(typeof HTMLCanvasElement !== 'undefined' && canvas instanceof HTMLCanvasElement)
@@ -544,10 +559,12 @@ export class CanvasSource extends VideoSource {
}
/**
- * Video source that encodes the frames of a MediaStreamVideoTrack and pipes them into the output. This is useful for
- * capturing live or real-time data such as webcams or screen captures. Frames will automatically start being captured
- * once the connected Output is started, and will keep being captured until the Output is finalized or this source
- * is closed.
+ * Video source that encodes the frames of a
+ * [`MediaStreamVideoTrack`](https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack) and pipes them into the
+ * output. This is useful for capturing live or real-time data such as webcams or screen captures. Frames will
+ * automatically start being captured once the connected {@link Output} is started, and will keep being captured until
+ * the {@link Output} is finalized or this source is closed.
+ * @group Media sources
* @public
*/
export class MediaStreamVideoTrackSource extends VideoSource {
@@ -572,6 +589,11 @@ export class MediaStreamVideoTrackSource extends VideoSource {
return this._promiseWithResolvers.promise;
}
+ /**
+ * Creates a new {@link MediaStreamVideoTrackSource} from a
+ * [`MediaStreamVideoTrack`](https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack), which will pull
+ * video samples from the stream in real time and encode them according to {@link VideoEncodingConfig}.
+ */
constructor(track: MediaStreamVideoTrack, encodingConfig: VideoEncodingConfig) {
if (!(track instanceof MediaStreamTrack) || track.kind !== 'video') {
throw new TypeError('track must be a video MediaStreamTrack.');
@@ -726,6 +748,7 @@ export class MediaStreamVideoTrackSource extends VideoSource {
/**
* Base class for audio sources - sources for audio tracks.
+ * @group Media sources
* @public
*/
export abstract class AudioSource extends MediaSource {
@@ -734,6 +757,7 @@ export abstract class AudioSource extends MediaSource {
/** @internal */
_codec: AudioCodec;
+ /** Internal constructor. */
constructor(codec: AudioCodec) {
super();
@@ -747,9 +771,11 @@ export abstract class AudioSource extends MediaSource {
/**
* The most basic audio source; can be used to directly pipe encoded packets into the output file.
+ * @group Media sources
* @public
*/
export class EncodedAudioPacketSource extends AudioSource {
+ /** Creates a new {@link EncodedAudioPacketSource} whose packets are encoded using `codec`. */
constructor(codec: AudioCodec) {
super(codec);
}
@@ -1171,12 +1197,17 @@ class AudioEncoderWrapper {
/**
* This source can be used to add raw, unencoded audio samples to an output audio track. These samples will
* automatically be encoded and then piped into the output.
+ * @group Media sources
* @public
*/
export class AudioSampleSource extends AudioSource {
/** @internal */
private _encoder: AudioEncoderWrapper;
+ /**
+ * Creates a new {@link AudioSampleSource} whose samples are encoded according to the specified
+ * {@link AudioEncodingConfig}.
+ */
constructor(encodingConfig: AudioEncodingConfig) {
validateAudioEncodingConfig(encodingConfig);
@@ -1207,6 +1238,7 @@ export class AudioSampleSource extends AudioSource {
/**
* This source can be used to add audio data from an AudioBuffer to the output track. This is useful when working with
* the Web Audio API.
+ * @group Media sources
* @public
*/
export class AudioBufferSource extends AudioSource {
@@ -1215,6 +1247,10 @@ export class AudioBufferSource extends AudioSource {
/** @internal */
private _accumulatedTime = 0;
+ /**
+ * Creates a new {@link AudioBufferSource} whose `AudioBuffer` instances are encoded according to the specified
+ * {@link AudioEncodingConfig}.
+ */
constructor(encodingConfig: AudioEncodingConfig) {
validateAudioEncodingConfig(encodingConfig);
@@ -1250,10 +1286,12 @@ export class AudioBufferSource extends AudioSource {
}
/**
- * Audio source that encodes the data of a MediaStreamAudioTrack and pipes it into the output. This is useful for
- * capturing live or real-time audio such as microphones or audio from other media elements. Audio will automatically
- * start being captured once the connected Output is started, and will keep being captured until the Output is
- * finalized or this source is closed.
+ * Audio source that encodes the data of a
+ * [`MediaStreamAudioTrack`](https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack) and pipes it into the
+ * output. This is useful for capturing live or real-time audio such as microphones or audio from other media elements.
+ * Audio will automatically start being captured once the connected {@link Output} is started, and will keep being
+ * captured until the {@link Output} is finalized or this source is closed.
+ * @group Media sources
* @public
*/
export class MediaStreamAudioTrackSource extends AudioSource {
@@ -1278,6 +1316,10 @@ export class MediaStreamAudioTrackSource extends AudioSource {
return this._promiseWithResolvers.promise;
}
+ /**
+ * Creates a new {@link MediaStreamAudioTrackSource} from a `MediaStreamAudioTrack`, which will pull audio samples
+ * from the stream in real time and encode them according to {@link AudioEncodingConfig}.
+ */
constructor(track: MediaStreamAudioTrack, encodingConfig: AudioEncodingConfig) {
if (!(track instanceof MediaStreamTrack) || track.kind !== 'audio') {
throw new TypeError('track must be an audio MediaStreamTrack.');
@@ -1572,6 +1614,7 @@ const sendMessageToMediaStreamTrackProcessorWorker = (
/**
* Base class for subtitle sources - sources for subtitle tracks.
+ * @group Media sources
* @public
*/
export abstract class SubtitleSource extends MediaSource {
@@ -1580,6 +1623,7 @@ export abstract class SubtitleSource extends MediaSource {
/** @internal */
_codec: SubtitleCodec;
+ /** Internal constructor. */
constructor(codec: SubtitleCodec) {
super();
@@ -1593,12 +1637,14 @@ export abstract class SubtitleSource extends MediaSource {
/**
* This source can be used to add subtitles from a subtitle text file.
+ * @group Media sources
* @public
*/
export class TextSubtitleSource extends SubtitleSource {
/** @internal */
private _parser: SubtitleParser;
+ /** Creates a new {@link TextSubtitleSource} where added text chunks are in the specified `codec`. */
constructor(codec: SubtitleCodec) {
super(codec);
diff --git a/src/misc.ts b/src/misc.ts
index ea64527..c794b10 100644
--- a/src/misc.ts
+++ b/src/misc.ts
@@ -14,6 +14,7 @@ export function assert(x: unknown): asserts x {
/**
* Represents a clockwise rotation in degrees.
+ * @group Miscellaneous
* @public
*/
export type Rotation = 0 | 90 | 180 | 270;
@@ -366,6 +367,7 @@ export const findLastIndex = (arr: T[], predicate: (x: T) => boolean) => {
/**
* Sync or async iterable.
+ * @group Miscellaneous
* @public
*/
export type AnyIterable =
@@ -512,6 +514,7 @@ export const SECOND_TO_MICROSECOND_FACTOR = 1e6 * (1 + Number.EPSILON);
/**
* Sets all keys K of T to be required.
+ * @group Miscellaneous
* @public
*/
export type SetRequired = T & Required>;
@@ -641,6 +644,7 @@ export const isSafari = () => {
/**
* T or a promise that resolves to T.
+ * @group Miscellaneous
* @public
*/
export type MaybePromise = T | Promise;
@@ -649,3 +653,7 @@ export type MaybePromise = T | Promise;
export const coalesceIndex = (a: number, b: number) => {
return a !== -1 ? a : b;
};
+
+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 957fea9..5515c9f 100644
--- a/src/mp3/mp3-demuxer.ts
+++ b/src/mp3/mp3-demuxer.ts
@@ -15,7 +15,15 @@ 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 { ID3_V1_TAG_SIZE, ID3_V2_HEADER_SIZE, Mp3Reader } from './mp3-reader';
+import {
+ ID3_V1_TAG_SIZE,
+ ID3_V2_HEADER_SIZE,
+ parseId3V1Tag,
+ parseId3V2Tag,
+ readId3V2Header,
+ readNextFrameHeader,
+} from './mp3-reader';
+import { readAscii, readBytes, Reader, readU32Be } from '../reader';
type Sample = {
timestamp: number;
@@ -25,7 +33,7 @@ type Sample = {
};
export class Mp3Demuxer extends Demuxer {
- reader: Mp3Reader;
+ reader: Reader;
metadataPromise: Promise | null = null;
firstFrameHeader: FrameHeader | null = null;
@@ -35,24 +43,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.lastSampleLoaded) {
+ await this.advanceReader();
}
// There has to be a frame if this demuxer got selected
@@ -62,78 +67,69 @@ 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's skip all ID3v2 tags at the start of the file
+ while (true) {
+ let slice = this.reader.requestSlice(this.lastLoadedPos, ID3_V2_HEADER_SIZE);
+ if (slice instanceof Promise) slice = await slice;
- this.reader.pos = this.lastLoadedPos;
+ if (!slice) {
+ this.lastSampleLoaded = true;
+ return;
+ }
- 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);
-
- if (this.reader.pos === 0) {
- while (true) { // There may be multiple
- await this.reader.reader.loadRange(this.reader.pos, this.reader.pos + ID3_V2_HEADER_SIZE);
- const id3V2Header = this.reader.readId3V2Header();
+ const id3V2Header = readId3V2Header(slice);
if (!id3V2Header) {
break;
}
- this.reader.pos += id3V2Header.size; // Skip it for now
+ this.lastLoadedPos = slice.filePos + id3V2Header.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() {
@@ -165,30 +161,39 @@ export class Mp3Demuxer extends Demuxer {
}
this.metadata = {};
- this.reader.pos = 0;
+ let currentPos = 0;
let id3V2HeaderFound = false;
while (true) {
- await this.reader.reader.loadRange(this.reader.pos, this.reader.pos + ID3_V2_HEADER_SIZE);
- const id3V2Header = this.reader.readId3V2Header();
+ let headerSlice = this.reader.requestSlice(currentPos, ID3_V2_HEADER_SIZE);
+ if (headerSlice instanceof Promise) headerSlice = await headerSlice;
+ if (!headerSlice) break;
+
+ const id3V2Header = readId3V2Header(headerSlice);
if (!id3V2Header) {
break;
}
id3V2HeaderFound = true;
- await this.reader.reader.loadRange(this.reader.pos, this.reader.pos + id3V2Header.size);
- this.reader.parseId3V2Tag(id3V2Header, this.metadata);
+ let contentSlice = this.reader.requestSlice(headerSlice.filePos, id3V2Header.size);
+ if (contentSlice instanceof Promise) contentSlice = await contentSlice;
+ if (!contentSlice) break;
+
+ parseId3V2Tag(contentSlice, id3V2Header, this.metadata);
+
+ currentPos = headerSlice.filePos + id3V2Header.size;
}
- if (!id3V2HeaderFound) {
+ if (!id3V2HeaderFound && this.reader.fileSize !== null && this.reader.fileSize >= ID3_V1_TAG_SIZE) {
// Try reading an ID3v1 tag at the end of the file
- this.reader.pos = Math.max(0, this.fileSize - ID3_V1_TAG_SIZE);
- await this.reader.reader.loadRange(this.reader.pos, this.reader.pos + ID3_V1_TAG_SIZE);
+ let slice = this.reader.requestSlice(this.reader.fileSize - ID3_V1_TAG_SIZE, ID3_V1_TAG_SIZE);
+ if (slice instanceof Promise) slice = await slice;
+ assert(slice);
- const tag = this.reader.readAscii(3);
+ const tag = readAscii(slice, 3);
if (tag === 'TAG') {
- this.reader.parseId3V1Tag(this.metadata);
+ parseId3V1Tag(slice, this.metadata);
}
}
@@ -256,7 +261,7 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking {
};
}
- getPacketAtIndex(sampleIndex: number, options: PacketRetrievalOptions) {
+ async getPacketAtIndex(sampleIndex: number, options: PacketRetrievalOptions) {
if (sampleIndex === -1) {
return null;
}
@@ -270,8 +275,14 @@ 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;
+
+ if (!slice) {
+ return null; // Data didn't fit into the rest of the file
+ }
+
+ data = readBytes(slice, rawSample.dataSize);
}
return new EncodedPacket(
@@ -284,7 +295,7 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking {
);
}
- async getFirstPacket(options: PacketRetrievalOptions) {
+ getFirstPacket(options: PacketRetrievalOptions) {
return this.getPacketAtIndex(0, options);
}
@@ -305,9 +316,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);
@@ -318,6 +329,7 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking {
async getPacket(timestamp: number, options: PacketRetrievalOptions) {
const release = await this.demuxer.readingMutex.acquire();
+
try {
while (true) {
const index = binarySearchLessOrEqual(
@@ -331,7 +343,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);
}
@@ -342,7 +354,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 ea9ce89..81549d6 100644
--- a/src/mp3/mp3-muxer.ts
+++ b/src/mp3/mp3-muxer.ts
@@ -63,7 +63,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 105877e..0bb7066 100644
--- a/src/mp3/mp3-reader.ts
+++ b/src/mp3/mp3-reader.ts
@@ -6,10 +6,10 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
-import { assert, coalesceIndex, textDecoder } from '../misc';
-import { Reader } from '../reader';
import { decodeSynchsafe, FRAME_HEADER_SIZE, FrameHeader, readFrameHeader } from '../../shared/mp3-misc';
import { MediaMetadata } from '../metadata';
+import { coalesceIndex, textDecoder } from '../misc';
+import { FileSlice, readAscii, readBytes, Reader, readU32Be, readU8 } from '../reader';
export type Id3V2Header = {
majorVersion: number;
@@ -65,353 +65,335 @@ export const ID3_V1_GENRES = [
'Dubstep', 'Garage rock', 'Psybient',
];
-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);
- }
-
- readU8() {
- const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + 1);
- this.pos += 1;
-
- return view.getUint8(offset);
- }
-
- 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;
- }
-
- 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;
}
- parseId3V1Tag(metadata: MediaMetadata) {
- const title = this.readId3V1String(30);
- if (title) metadata.title ??= title;
+ slice.skip(3);
- const artist = this.readId3V1String(30);
- if (artist) metadata.artist ??= artist;
+ const size = decodeSynchsafe(readU32Be(slice));
+ return { size };
+};
+*/
- const album = this.readId3V1String(30);
- if (album) metadata.album ??= album;
+export const readNextFrameHeader = async (reader: Reader, startPos: number, until: number | null): Promise<{
+ header: FrameHeader;
+ startPos: number;
+} | null> => {
+ let currentPos = startPos;
- const yearText = this.readId3V1String(4);
- const year = Number.parseInt(yearText, 10);
- if (Number.isInteger(year) && year > 0) {
- metadata.releasedAt ??= new Date(year, 0, 1);
+ while (until === null || 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 !== null ? reader.fileSize - currentPos : null);
+ if (result.header) {
+ return { header: result.header, startPos: currentPos };
}
- const commentBytes = this.readBytes(30);
- let comment: string;
+ currentPos += result.bytesAdvanced;
+ }
- // Check for the ID3v1.1 track number format:
- // The 29th byte (index 28) is a null terminator, and the 30th byte is the track number.
- if (commentBytes[28] === 0 && commentBytes[29] !== 0) {
- const trackNum = commentBytes[29]!;
- if (trackNum > 0) {
- metadata.trackNumber ??= trackNum;
- }
+ return null;
+};
- this.pos -= 30;
- comment = this.readId3V1String(28);
- this.pos += 2;
+export const parseId3V1Tag = (slice: FileSlice, metadata: MediaMetadata) => {
+ const title = readId3V1String(slice, 30);
+ if (title) metadata.title ??= title;
+
+ const artist = readId3V1String(slice, 30);
+ if (artist) metadata.artist ??= artist;
+
+ const album = readId3V1String(slice, 30);
+ if (album) metadata.album ??= album;
+
+ const yearText = readId3V1String(slice, 4);
+ const year = Number.parseInt(yearText, 10);
+ if (Number.isInteger(year) && year > 0) {
+ metadata.releasedAt ??= new Date(year, 0, 1);
+ }
+
+ const commentBytes = readBytes(slice, 30);
+ let comment: string;
+
+ // Check for the ID3v1.1 track number format:
+ // The 29th byte (index 28) is a null terminator, and the 30th byte is the track number.
+ if (commentBytes[28] === 0 && commentBytes[29] !== 0) {
+ const trackNum = commentBytes[29]!;
+ if (trackNum > 0) {
+ metadata.trackNumber ??= trackNum;
+ }
+
+ slice.skip(-30);
+ comment = readId3V1String(slice, 28);
+ slice.skip(2);
+ } else {
+ slice.skip(-30);
+ comment = readId3V1String(slice, 30);
+ }
+
+ if (comment) metadata.comment ??= comment;
+
+ const genreIndex = readU8(slice);
+ if (genreIndex < ID3_V1_GENRES.length) {
+ metadata.genre ??= ID3_V1_GENRES[genreIndex];
+ }
+};
+
+export const readId3V1String = (slice: FileSlice, length: number) => {
+ const bytes = readBytes(slice, length);
+
+ const endIndex = coalesceIndex(bytes.indexOf(0), bytes.length);
+ const relevantBytes = bytes.subarray(0, endIndex);
+
+ // Decode as ISO-8859-1
+ let str = '';
+ for (let i = 0; i < relevantBytes.length; i++) {
+ str += String.fromCharCode(relevantBytes[i]!);
+ }
+
+ return str.trimEnd(); // String also may be padded with spaces
+};
+
+export const readId3V2Header = (slice: FileSlice): Id3V2Header | null => {
+ const startPos = slice.filePos;
+
+ const tag = readAscii(slice, 3);
+ const majorVersion = readU8(slice);
+ const revision = readU8(slice);
+ const flags = readU8(slice);
+ const sizeRaw = readU32Be(slice);
+
+ if (tag !== 'ID3' || majorVersion === 0xff || revision === 0xff || (sizeRaw & 0x80808080) !== 0) {
+ slice.filePos = startPos;
+ return null;
+ }
+
+ const size = decodeSynchsafe(sizeRaw);
+
+ return { majorVersion, revision, flags, size };
+};
+
+export const parseId3V2Tag = (slice: FileSlice, header: Id3V2Header, metadata: MediaMetadata) => {
+ // https://id3.org/id3v2.3.0
+
+ if (![2, 3, 4].includes(header.majorVersion)) {
+ console.warn(`Unsupported ID3v2 major version: ${header.majorVersion}`);
+ return;
+ }
+
+ const bytes = readBytes(slice, header.size);
+ const reader = new Id3V2Reader(header, bytes);
+
+ if (header.flags & Id3V2HeaderFlags.Footer) {
+ reader.removeFooter();
+ }
+
+ if ((header.flags & Id3V2HeaderFlags.Unsynchronisation) && header.majorVersion === 3) {
+ reader.ununsynchronizeAll();
+ }
+
+ if (header.flags & Id3V2HeaderFlags.ExtendedHeader) {
+ const extendedHeaderSize = reader.readU32();
+
+ if (header.majorVersion === 3) {
+ reader.pos += extendedHeaderSize; // The extended header size excludes itself
} else {
- this.pos -= 30;
- comment = this.readId3V1String(30);
- }
-
- if (comment) metadata.comment ??= comment;
-
- const genreIndex = this.readU8();
- if (genreIndex < ID3_V1_GENRES.length) {
- metadata.genre ??= ID3_V1_GENRES[genreIndex];
+ reader.pos += extendedHeaderSize - 4; // The extended header size includes itself
}
}
- readId3V1String(length: number): string {
- const bytes = this.readBytes(length);
-
- const endIndex = coalesceIndex(bytes.indexOf(0), bytes.length);
- const relevantBytes = bytes.subarray(0, endIndex);
-
- // Decode as ISO-8859-1
- let str = '';
- for (let i = 0; i < relevantBytes.length; i++) {
- str += String.fromCharCode(relevantBytes[i]!);
+ while (reader.pos <= reader.bytes.length - reader.frameHeaderSize()) {
+ const frame = reader.readId3V2Frame();
+ if (!frame) {
+ break;
}
- return str.trimEnd(); // String also may be padded with spaces
- }
+ const frameEndPos = reader.pos + frame.size;
- readId3V2Header(): Id3V2Header | null {
- const startPos = this.pos;
+ let frameEncrypted = false;
+ let frameCompressed = false;
+ let frameUnsynchronized = false;
- const tag = this.readAscii(3);
- const majorVersion = this.readU8();
- const revision = this.readU8();
- const flags = this.readU8();
- const sizeRaw = this.readU32();
-
- if (tag !== 'ID3' || majorVersion === 0xff || revision === 0xff || (sizeRaw & 0x80808080) !== 0) {
- this.pos = startPos;
- return null;
+ if (header.majorVersion === 3) {
+ frameEncrypted = !!(frame.flags & (1 << 6));
+ frameCompressed = !!(frame.flags & (1 << 7));
+ } else if (header.majorVersion === 4) {
+ frameEncrypted = !!(frame.flags & (1 << 2));
+ frameCompressed = !!(frame.flags & (1 << 3));
+ frameUnsynchronized = !!(frame.flags & (1 << 1))
+ || !!(header.flags & Id3V2HeaderFlags.Unsynchronisation);
}
- const size = decodeSynchsafe(sizeRaw);
-
- return { majorVersion, revision, flags, size };
- }
-
- parseId3V2Tag(header: Id3V2Header, metadata: MediaMetadata) {
- // https://id3.org/id3v2.3.0
-
- if (![2, 3, 4].includes(header.majorVersion)) {
- console.warn(`Unsupported ID3v2 major version: ${header.majorVersion}`);
- return;
- }
-
- this.pos = ID3_V2_HEADER_SIZE;
- const bytes = this.readBytes(header.size);
- const reader = new Id3V2Reader(header, bytes);
-
- if (header.flags & Id3V2HeaderFlags.Footer) {
- reader.removeFooter();
- }
-
- if ((header.flags & Id3V2HeaderFlags.Unsynchronisation) && header.majorVersion === 3) {
- reader.ununsynchronizeAll();
- }
-
- if (header.flags & Id3V2HeaderFlags.ExtendedHeader) {
- const extendedHeaderSize = reader.readU32();
-
- if (header.majorVersion === 3) {
- reader.pos += extendedHeaderSize; // The extended header size excludes itself
- } else {
- reader.pos += extendedHeaderSize - 4; // The extended header size includes itself
- }
- }
-
- while (reader.pos <= reader.bytes.length - reader.frameHeaderSize()) {
- const frame = reader.readId3V2Frame();
- if (!frame) {
- break;
- }
-
- const frameEndPos = reader.pos + frame.size;
-
- let frameEncrypted = false;
- let frameCompressed = false;
- let frameUnsynchronized = false;
-
- if (header.majorVersion === 3) {
- frameEncrypted = !!(frame.flags & (1 << 6));
- frameCompressed = !!(frame.flags & (1 << 7));
- } else if (header.majorVersion === 4) {
- frameEncrypted = !!(frame.flags & (1 << 2));
- frameCompressed = !!(frame.flags & (1 << 3));
- frameUnsynchronized = !!(frame.flags & (1 << 1))
- || !!(header.flags & Id3V2HeaderFlags.Unsynchronisation);
- }
-
- if (frameEncrypted) {
- console.warn(`Skipping encrypted ID3v2 frame ${frame.id}`);
- reader.pos = frameEndPos;
- continue;
- }
-
- if (frameCompressed) {
- console.warn(`Skipping compressed ID3v2 frame ${frame.id}`); // Maybe someday? Idk
- reader.pos = frameEndPos;
- continue;
- }
-
- if (frameUnsynchronized) {
- reader.ununsynchronizeRegion(reader.pos, frameEndPos);
- }
-
- switch (frame.id) {
- case 'TIT2':
- case 'TT2': {
- metadata.title ??= reader.readId3V2EncodingAndText(frameEndPos);
- }; break;
-
- case 'TPE1':
- case 'TP1': {
- metadata.artist ??= reader.readId3V2EncodingAndText(frameEndPos);
- }; break;
-
- case 'TALB':
- case 'TAL': {
- metadata.album ??= reader.readId3V2EncodingAndText(frameEndPos);
- }; break;
-
- case 'TPE2':
- case 'TP2': {
- metadata.albumArtist ??= reader.readId3V2EncodingAndText(frameEndPos);
- }; break;
-
- case 'TRCK':
- case 'TRK': {
- const trackText = reader.readId3V2EncodingAndText(frameEndPos);
- const trackNum = Number.parseInt(trackText, 10);
-
- if (Number.isInteger(trackNum) && trackNum > 0) {
- metadata.trackNumber ??= trackNum;
- }
- }; break;
-
- case 'TPOS':
- case 'TPA': {
- const discText = reader.readId3V2EncodingAndText(frameEndPos);
- const discNum = Number.parseInt(discText, 10);
-
- if (Number.isInteger(discNum) && discNum > 0) {
- metadata.discNumber ??= discNum;
- }
- }; break;
-
- case 'TCON':
- case 'TCO': {
- const genreText = reader.readId3V2EncodingAndText(frameEndPos);
- let match = /^\((\d+)\)/.exec(genreText);
- if (match) {
- const genreNumber = Number.parseInt(match[1]!);
- if (ID3_V1_GENRES[genreNumber] !== undefined) {
- metadata.genre ??= ID3_V1_GENRES[genreNumber];
- break;
- }
- }
-
- match = /^\d+$/.exec(genreText);
- if (match) {
- const genreNumber = Number.parseInt(match[0]);
- if (ID3_V1_GENRES[genreNumber] !== undefined) {
- metadata.genre ??= ID3_V1_GENRES[genreNumber];
- break;
- }
- }
-
- metadata.genre ??= genreText;
- }; break;
-
- case 'TDRC':
- case 'TDAT': {
- const dateText = reader.readId3V2EncodingAndText(frameEndPos);
- const date = new Date(dateText);
-
- if (!Number.isNaN(date.getTime())) {
- metadata.releasedAt ??= date;
- }
- }; break;
-
- case 'TYER':
- case 'TYE': {
- const yearText = reader.readId3V2EncodingAndText(frameEndPos);
- const year = Number.parseInt(yearText, 10);
-
- if (Number.isInteger(year)) {
- metadata.releasedAt ??= new Date(year, 0, 1);
- }
- }; break;
-
- case 'USLT':
- case 'ULT': {
- const encoding = reader.readU8();
- reader.pos += 3; // Skip language
- reader.readId3V2Text(encoding, frameEndPos); // Short content description
- metadata.lyrics ??= reader.readId3V2Text(encoding, frameEndPos);
- }; break;
-
- case 'COMM':
- case 'COM': {
- const encoding = reader.readU8();
- reader.pos += 3; // Skip language
- reader.readId3V2Text(encoding, frameEndPos); // Short content description
- metadata.comment ??= reader.readId3V2Text(encoding, frameEndPos);
- }; break;
-
- case 'APIC':
- case 'PIC': {
- const encoding = reader.readId3V2TextEncoding();
-
- let mimeType: string;
- if (header.majorVersion === 2) {
- const imageFormat = reader.readAscii(3);
- mimeType = imageFormat === 'PNG'
- ? 'image/png'
- : imageFormat === 'JPG'
- ? 'image/jpeg'
- : 'image/*';
- } else {
- mimeType = reader.readId3V2Text(encoding, frameEndPos);
- }
-
- const pictureType = reader.readU8();
- const description = reader.readId3V2Text(encoding, frameEndPos).trimEnd(); // Trim ending spaces
-
- const imageDataSize = frameEndPos - reader.pos;
- if (imageDataSize >= 0) {
- const imageData = reader.readBytes(imageDataSize);
-
- if (!metadata.images) metadata.images = [];
- metadata.images.push({
- data: imageData,
- mimeType,
- kind: pictureType === 3
- ? 'coverFront'
- : pictureType === 4
- ? 'coverBack'
- : 'unknown',
- description,
- });
- }
- }; break;
-
- default: {
- reader.pos += frame.size;
- }; break;
- }
-
+ if (frameEncrypted) {
+ console.warn(`Skipping encrypted ID3v2 frame ${frame.id}`);
reader.pos = frameEndPos;
+ continue;
}
+
+ if (frameCompressed) {
+ console.warn(`Skipping compressed ID3v2 frame ${frame.id}`); // Maybe someday? Idk
+ reader.pos = frameEndPos;
+ continue;
+ }
+
+ if (frameUnsynchronized) {
+ reader.ununsynchronizeRegion(reader.pos, frameEndPos);
+ }
+
+ switch (frame.id) {
+ case 'TIT2':
+ case 'TT2': {
+ metadata.title ??= reader.readId3V2EncodingAndText(frameEndPos);
+ }; break;
+
+ case 'TPE1':
+ case 'TP1': {
+ metadata.artist ??= reader.readId3V2EncodingAndText(frameEndPos);
+ }; break;
+
+ case 'TALB':
+ case 'TAL': {
+ metadata.album ??= reader.readId3V2EncodingAndText(frameEndPos);
+ }; break;
+
+ case 'TPE2':
+ case 'TP2': {
+ metadata.albumArtist ??= reader.readId3V2EncodingAndText(frameEndPos);
+ }; break;
+
+ case 'TRCK':
+ case 'TRK': {
+ const trackText = reader.readId3V2EncodingAndText(frameEndPos);
+ const trackNum = Number.parseInt(trackText, 10);
+
+ if (Number.isInteger(trackNum) && trackNum > 0) {
+ metadata.trackNumber ??= trackNum;
+ }
+ }; break;
+
+ case 'TPOS':
+ case 'TPA': {
+ const discText = reader.readId3V2EncodingAndText(frameEndPos);
+ const discNum = Number.parseInt(discText, 10);
+
+ if (Number.isInteger(discNum) && discNum > 0) {
+ metadata.discNumber ??= discNum;
+ }
+ }; break;
+
+ case 'TCON':
+ case 'TCO': {
+ const genreText = reader.readId3V2EncodingAndText(frameEndPos);
+ let match = /^\((\d+)\)/.exec(genreText);
+ if (match) {
+ const genreNumber = Number.parseInt(match[1]!);
+ if (ID3_V1_GENRES[genreNumber] !== undefined) {
+ metadata.genre ??= ID3_V1_GENRES[genreNumber];
+ break;
+ }
+ }
+
+ match = /^\d+$/.exec(genreText);
+ if (match) {
+ const genreNumber = Number.parseInt(match[0]);
+ if (ID3_V1_GENRES[genreNumber] !== undefined) {
+ metadata.genre ??= ID3_V1_GENRES[genreNumber];
+ break;
+ }
+ }
+
+ metadata.genre ??= genreText;
+ }; break;
+
+ case 'TDRC':
+ case 'TDAT': {
+ const dateText = reader.readId3V2EncodingAndText(frameEndPos);
+ const date = new Date(dateText);
+
+ if (!Number.isNaN(date.getTime())) {
+ metadata.releasedAt ??= date;
+ }
+ }; break;
+
+ case 'TYER':
+ case 'TYE': {
+ const yearText = reader.readId3V2EncodingAndText(frameEndPos);
+ const year = Number.parseInt(yearText, 10);
+
+ if (Number.isInteger(year)) {
+ metadata.releasedAt ??= new Date(year, 0, 1);
+ }
+ }; break;
+
+ case 'USLT':
+ case 'ULT': {
+ const encoding = reader.readU8();
+ reader.pos += 3; // Skip language
+ reader.readId3V2Text(encoding, frameEndPos); // Short content description
+ metadata.lyrics ??= reader.readId3V2Text(encoding, frameEndPos);
+ }; break;
+
+ case 'COMM':
+ case 'COM': {
+ const encoding = reader.readU8();
+ reader.pos += 3; // Skip language
+ reader.readId3V2Text(encoding, frameEndPos); // Short content description
+ metadata.comment ??= reader.readId3V2Text(encoding, frameEndPos);
+ }; break;
+
+ case 'APIC':
+ case 'PIC': {
+ const encoding = reader.readId3V2TextEncoding();
+
+ let mimeType: string;
+ if (header.majorVersion === 2) {
+ const imageFormat = reader.readAscii(3);
+ mimeType = imageFormat === 'PNG'
+ ? 'image/png'
+ : imageFormat === 'JPG'
+ ? 'image/jpeg'
+ : 'image/*';
+ } else {
+ mimeType = reader.readId3V2Text(encoding, frameEndPos);
+ }
+
+ const pictureType = reader.readU8();
+ const description = reader.readId3V2Text(encoding, frameEndPos).trimEnd(); // Trim ending spaces
+
+ const imageDataSize = frameEndPos - reader.pos;
+ if (imageDataSize >= 0) {
+ const imageData = reader.readBytes(imageDataSize);
+
+ if (!metadata.images) metadata.images = [];
+ metadata.images.push({
+ data: imageData,
+ mimeType,
+ kind: pictureType === 3
+ ? 'coverFront'
+ : pictureType === 4
+ ? 'coverBack'
+ : 'unknown',
+ description,
+ });
+ }
+ }; break;
+
+ default: {
+ reader.pos += frame.size;
+ }; break;
+ }
+
+ reader.pos = frameEndPos;
}
-}
+};
// https://id3.org/id3v2.3.0
export class Id3V2Reader {
diff --git a/src/ogg/ogg-demuxer.ts b/src/ogg/ogg-demuxer.ts
index 39a5ed3..2632e49 100644
--- a/src/ogg/ogg-demuxer.ts
+++ b/src/ogg/ogg-demuxer.ts
@@ -12,11 +12,27 @@ 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,
+ AsyncMutex,
+ binarySearchLessOrEqual,
+ findLast,
+ last,
+ 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 +52,28 @@ 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 (this.reader.pos < this.fileSize - MIN_PAGE_HEADER_SIZE) {
- await this.reader.reader.loadRange(
- this.reader.pos,
- this.reader.pos + MAX_PAGE_HEADER_SIZE,
- );
+ while (true) {
+ let slice = this.reader.requestSliceRange(currentPos, MIN_PAGE_HEADER_SIZE, MAX_PAGE_HEADER_SIZE);
+ if (slice instanceof Promise) slice = await slice;
+ if (!slice) break;
- const page = this.reader.readPageHeader();
+ const page = readPageHeader(slice);
if (!page) {
break;
}
@@ -91,11 +99,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 +143,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 +224,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 +252,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 +269,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 +292,15 @@ 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) {
+ 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;
}
- await reader.reader.loadRange(reader.pos, reader.pos + MAX_PAGE_HEADER_SIZE);
- const nextPage = reader.readPageHeader();
+ const nextPage = readPageHeader(headerSlice);
if (!nextPage) {
return null;
}
@@ -312,6 +309,7 @@ export class OggDemuxer extends Demuxer {
if (currentPage.serialNumber === startPage.serialNumber) {
break;
}
+ currentPos = currentPage.headerStartPos + currentPage.totalSize;
}
startDataOffset = 0;
@@ -336,9 +334,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 +347,15 @@ 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) {
+ 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;
}
- await reader.reader.loadRange(reader.pos, reader.pos + MAX_PAGE_HEADER_SIZE);
- const nextPage = reader.readPageHeader();
+ const nextPage = readPageHeader(slice);
if (!nextPage) {
return null;
}
@@ -367,7 +364,7 @@ export class OggDemuxer extends Demuxer {
return { startPage: nextPage, startSegmentIndex: 0 };
}
- reader.pos = nextPage.headerStartPos + nextPage.totalSize;
+ currentPos = nextPage.headerStartPos + nextPage.totalSize;
}
}
@@ -397,12 +394,15 @@ type EncodedPacketMetadata = {
packet: Packet;
timestampInSamples: number;
durationInSamples: number;
+ vorbisLastBlockSize: number | null;
vorbisBlockSize: number | null;
};
class OggAudioTrackBacking implements InputAudioTrackBacking {
internalSampleRate: number;
encodedPacketToMetadata = new WeakMap();
+ sequentialScanCache: EncodedPacketMetadata[] = [];
+ sequentialScanMutex = new AsyncMutex();
constructor(public bitstream: LogicalBitstream, public demuxer: OggDemuxer) {
// Opus always uses a fixed sample rate for its internal calculations, even if the actual rate is different
@@ -503,411 +503,456 @@ class OggAudioTrackBacking implements InputAudioTrackBacking {
packet,
timestampInSamples: additional.timestampInSamples,
durationInSamples,
+ vorbisLastBlockSize: additional.vorbisLastBlocksize,
vorbisBlockSize,
});
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();
+ if (this.demuxer.reader.fileSize === null) {
+ // No file size known, can't do binary search, but fall back to sequential algo instead
+ return this.getPacketSequential(timestamp, options);
+ }
- try {
- assert(this.demuxer.fileSize !== 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);
+ }
+ if (timestampInSamples < 0) {
+ // There's nothing here
+ 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;
- }
+ assert(this.bitstream.lastMetadataPacket);
+ const startPosition = await this.demuxer.findNextPacketStart(this.bitstream.lastMetadataPacket);
+ if (!startPosition) {
+ return null;
+ }
- const reader = this.demuxer.reader;
+ let lowPage = startPosition.startPage;
+ let high = this.demuxer.reader.fileSize;
- assert(this.bitstream.lastMetadataPacket);
- const startPosition = await this.demuxer.findNextPacketStart(
- reader,
- this.bitstream.lastMetadataPacket,
- );
- if (!startPosition) {
- return null;
- }
+ const lowPages: Page[] = [lowPage];
- let lowPage = startPosition.startPage;
- let high = this.demuxer.fileSize;
+ // 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.
- const lowPages: Page[] = [lowPage];
+ // Outer loop: Does the binary serach
+ outer:
+ while (lowPage.headerStartPos + lowPage.totalSize < high) {
+ const low = lowPage.headerStartPos;
+ const mid = Math.floor((low + high) / 2);
- // 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 searchStartPos = mid;
- // Outer loop: Does the binary serach
- outer:
- while (lowPage.headerStartPos + lowPage.totalSize < high) {
- const low = lowPage.headerStartPos;
- const mid = Math.floor((low + high) / 2);
+ // 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,
+ );
- 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;
+ }
+ }
+
+ const nextPosition = await this.demuxer.findNextPacketStart(packet);
+ if (!nextPosition) {
+ break;
+ }
+
+ currentPage = nextPosition.startPage;
+ currentSegmentIndex = nextPosition.startSegmentIndex;
+ }
+
+ return lastEncodedPacket;
+ }
+
+ // A slower but simpler and sequential algorithm for finding a packet in a file
+ async getPacketSequential(timestamp: number, options: PacketRetrievalOptions) {
+ const release = await this.sequentialScanMutex.acquire(); // Requires exclusivity because we write to a cache
+
+ try {
+ const timestampInSamples = roundToPrecision(timestamp * this.internalSampleRate, 14);
+ timestamp = timestampInSamples / this.internalSampleRate;
+
+ const index = binarySearchLessOrEqual(
+ this.sequentialScanCache,
+ timestampInSamples,
+ x => x.timestampInSamples,
+ );
+
+ let currentPacket: EncodedPacket | null;
+ if (index !== -1) {
+ // We don't need to start from the beginning, we can start at a previous scan point
+ const cacheEntry = this.sequentialScanCache[index]!;
+ currentPacket = this.createEncodedPacketFromOggPacket(
+ cacheEntry.packet,
+ {
+ timestampInSamples: cacheEntry.timestampInSamples,
+ vorbisLastBlocksize: cacheEntry.vorbisLastBlockSize,
+ },
+ options,
+ );
+ } else {
+ currentPacket = await this.getFirstPacket(options);
+ }
+
+ let i = 0;
+
+ while (currentPacket && currentPacket.timestamp < timestamp) {
+ const nextPacket = await this.getNextPacket(currentPacket, options);
+ if (!nextPacket || nextPacket.timestamp > timestamp) {
break;
}
- currentPage = nextPosition.startPage;
- currentSegmentIndex = nextPosition.startSegmentIndex;
+ currentPacket = nextPacket;
+ i++;
+
+ if (i === 100) {
+ // Add "checkpoints" every once in a while to speed up subsequent random accesses
+ i = 0;
+ const metadata = this.encodedPacketToMetadata.get(currentPacket);
+ assert(metadata);
+
+ if (this.sequentialScanCache.length > 0) {
+ // If we reach this case, we must be at the end of the cache
+ assert(last(this.sequentialScanCache)!.timestampInSamples <= metadata.timestampInSamples);
+ }
+
+ this.sequentialScanCache.push(metadata);
+ }
}
- return lastEncodedPacket;
+ return currentPacket;
} finally {
release();
}
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/output-format.ts b/src/output-format.ts
index 0f5e228..c1a1a17 100644
--- a/src/output-format.ts
+++ b/src/output-format.ts
@@ -29,6 +29,7 @@ import { WaveMuxer } from './wave/wave-muxer';
/**
* Specifies an inclusive range of integers.
+ * @group Miscellaneous
* @public
*/
export type InclusiveIntegerRange = {
@@ -40,6 +41,7 @@ export type InclusiveIntegerRange = {
/**
* Specifies the number of tracks (for each track type and in total) that an output format supports.
+ * @group Output formats
* @public
*/
export type TrackCountLimits = {
@@ -51,6 +53,7 @@ export type TrackCountLimits = {
/**
* Base class representing an output media file format.
+ * @group Output formats
* @public
*/
export abstract class OutputFormat {
@@ -97,6 +100,7 @@ export abstract class OutputFormat {
/**
* ISOBMFF-specific output options.
+ * @group Output formats
* @public
*/
export type IsobmffOutputFormatOptions = {
@@ -168,12 +172,14 @@ export type IsobmffOutputFormatOptions = {
/**
* Format representing files compatible with the ISO base media file format (ISOBMFF), like MP4 or MOV files.
+ * @group Output formats
* @public
*/
export abstract class IsobmffOutputFormat extends OutputFormat {
/** @internal */
_options: IsobmffOutputFormatOptions;
+ /** Internal constructor. */
constructor(options: IsobmffOutputFormatOptions = {}) {
if (!options || typeof options !== 'object') {
throw new TypeError('options must be an object.');
@@ -225,10 +231,16 @@ export abstract class IsobmffOutputFormat extends OutputFormat {
}
/**
- * MPEG-4 Part 14 (MP4) file format. Supports all codecs except PCM audio codecs.
+ * MPEG-4 Part 14 (MP4) file format. Supports most codecs.
+ * @group Output formats
* @public
*/
export class Mp4OutputFormat extends IsobmffOutputFormat {
+ /** Creates a new {@link Mp4OutputFormat} configured with the specified `options`. */
+ constructor(options?: IsobmffOutputFormatOptions) {
+ super(options);
+ }
+
/** @internal */
get _name() {
return 'MP4';
@@ -273,9 +285,15 @@ export class Mp4OutputFormat extends IsobmffOutputFormat {
/**
* QuickTime File Format (QTFF), often called MOV. Supports all video and audio codecs, but not subtitle codecs.
+ * @group Output formats
* @public
*/
export class MovOutputFormat extends IsobmffOutputFormat {
+ /** Creates a new {@link MovOutputFormat} configured with the specified `options`. */
+ constructor(options?: IsobmffOutputFormatOptions) {
+ super(options);
+ }
+
/** @internal */
get _name() {
return 'MOV';
@@ -308,6 +326,7 @@ export class MovOutputFormat extends IsobmffOutputFormat {
/**
* Matroska-specific output options.
+ * @group Output formats
* @public
*/
export type MkvOutputFormatOptions = {
@@ -353,12 +372,14 @@ export type MkvOutputFormatOptions = {
/**
* Matroska file format.
+ * @group Output formats
* @public
*/
export class MkvOutputFormat extends OutputFormat {
/** @internal */
_options: MkvOutputFormatOptions;
+ /** Creates a new {@link MkvOutputFormat} configured with the specified `options`. */
constructor(options: MkvOutputFormatOptions = {}) {
if (!options || typeof options !== 'object') {
throw new TypeError('options must be an object.');
@@ -431,15 +452,22 @@ export class MkvOutputFormat extends OutputFormat {
/**
* WebM-specific output options.
+ * @group Output formats
* @public
*/
export type WebMOutputFormatOptions = MkvOutputFormatOptions;
/**
* WebM file format, based on Matroska.
+ * @group Output formats
* @public
*/
export class WebMOutputFormat extends MkvOutputFormat {
+ /** Creates a new {@link WebMOutputFormat} configured with the specified `options`. */
+ constructor(options?: MkvOutputFormatOptions) {
+ super(options);
+ }
+
override getSupportedCodecs(): MediaCodec[] {
return [
...VIDEO_CODECS.filter(codec => ['vp8', 'vp9', 'av1'].includes(codec)),
@@ -473,12 +501,13 @@ export class WebMOutputFormat extends MkvOutputFormat {
/**
* MP3-specific output options.
+ * @group Output formats
* @public
*/
export type Mp3OutputFormatOptions = {
/**
* Controls whether the Xing header, which contains additional metadata as well as an index, is written to the start
- * of the MP3 file. When disabled, the writing process becomes append-only. Defaults to true.
+ * of the MP3 file. When disabled, the writing process becomes append-only. Defaults to `true`.
*/
xingHeader?: boolean;
@@ -493,12 +522,14 @@ export type Mp3OutputFormatOptions = {
/**
* MP3 file format.
+ * @group Output formats
* @public
*/
export class Mp3OutputFormat extends OutputFormat {
/** @internal */
_options: Mp3OutputFormatOptions;
+ /** Creates a new {@link Mp3OutputFormat} configured with the specified `options`. */
constructor(options: Mp3OutputFormatOptions = {}) {
if (!options || typeof options !== 'object') {
throw new TypeError('options must be an object.');
@@ -553,12 +584,13 @@ export class Mp3OutputFormat extends OutputFormat {
/**
* WAVE-specific output options.
+ * @group Output formats
* @public
*/
export type WavOutputFormatOptions = {
/**
- * When enabled, an RF64 file be written, allowing for file sizes to exceed 4 GiB, which is otherwise not possible
- * for regular WAVE files.
+ * When enabled, an RF64 file will be written, allowing for file sizes to exceed 4 GiB, which is otherwise not
+ * possible for regular WAVE files.
*/
large?: boolean;
@@ -571,12 +603,14 @@ export type WavOutputFormatOptions = {
/**
* WAVE file format, based on RIFF.
+ * @group Output formats
* @public
*/
export class WavOutputFormat extends OutputFormat {
/** @internal */
_options: WavOutputFormatOptions;
+ /** Creates a new {@link WavOutputFormat} configured with the specified `options`. */
constructor(options: WavOutputFormatOptions = {}) {
if (!options || typeof options !== 'object') {
throw new TypeError('options must be an object.');
@@ -635,6 +669,7 @@ export class WavOutputFormat extends OutputFormat {
/**
* Ogg-specific output options.
+ * @group Output formats
* @public
*/
export type OggOutputFormatOptions = {
@@ -643,19 +678,21 @@ export type OggOutputFormatOptions = {
*
* @param data - The raw bytes.
* @param position - The byte offset of the data in the file.
- * @param source - The media source backing the page's logical bitstream (track).
+ * @param source - The {@link MediaSource} backing the page's logical bitstream (track).
*/
onPage?: (data: Uint8Array, position: number, source: MediaSource) => unknown;
};
/**
* Ogg file format.
+ * @group Output formats
* @public
*/
export class OggOutputFormat extends OutputFormat {
/** @internal */
_options: OggOutputFormatOptions;
+ /** Creates a new {@link OggOutputFormat} configured with the specified `options`. */
constructor(options: OggOutputFormatOptions = {}) {
if (!options || typeof options !== 'object') {
throw new TypeError('options must be an object.');
@@ -709,6 +746,7 @@ export class OggOutputFormat extends OutputFormat {
/**
* ADTS-specific output options.
+ * @group Output formats
* @public
*/
export type AdtsOutputFormatOptions = {
@@ -723,12 +761,14 @@ export type AdtsOutputFormatOptions = {
/**
* ADTS file format.
+ * @group Output formats
* @public
*/
export class AdtsOutputFormat extends OutputFormat {
/** @internal */
_options: AdtsOutputFormatOptions;
+ /** Creates a new {@link AdtsOutputFormat} configured with the specified `options`. */
constructor(options: AdtsOutputFormatOptions = {}) {
if (!options || typeof options !== 'object') {
throw new TypeError('options must be an object.');
diff --git a/src/output.ts b/src/output.ts
index a2063d0..234941f 100644
--- a/src/output.ts
+++ b/src/output.ts
@@ -16,6 +16,7 @@ import { Writer } from './writer';
/**
* The options for creating an Output object.
+ * @group Output files
* @public
*/
export type OutputOptions<
@@ -30,11 +31,13 @@ export type OutputOptions<
/**
* List of all track types.
+ * @group Miscellaneous
* @public
*/
export const ALL_TRACK_TYPES = ['video', 'audio', 'subtitle'] as const;
/**
* Union type of all track types.
+ * @group Miscellaneous
* @public
*/
export type TrackType = typeof ALL_TRACK_TYPES[number];
@@ -63,6 +66,7 @@ export type OutputSubtitleTrack = OutputTrack & { type: 'subtitle' };
/**
* Base track metadata, applicable to all tracks.
+ * @group Output files
* @public
*/
export type BaseTrackMetadata = {
@@ -74,6 +78,7 @@ export type BaseTrackMetadata = {
/**
* Additional metadata for video tracks.
+ * @group Output files
* @public
*/
export type VideoTrackMetadata = BaseTrackMetadata & {
@@ -88,11 +93,13 @@ export type VideoTrackMetadata = BaseTrackMetadata & {
};
/**
* Additional metadata for audio tracks.
+ * @group Output files
* @public
*/
export type AudioTrackMetadata = BaseTrackMetadata & {};
/**
* Additional metadata for subtitle tracks.
+ * @group Output files
* @public
*/
export type SubtitleTrackMetadata = BaseTrackMetadata & {};
@@ -111,6 +118,7 @@ const validateBaseTrackMetadata = (metadata: BaseTrackMetadata) => {
/**
* Main class orchestrating the creation of a new media file.
+ * @group Output files
* @public
*/
export class Output<
@@ -141,6 +149,10 @@ export class Output<
/** @internal */
_metadata: MediaMetadata = {};
+ /**
+ * Creates a new instance of {@link Output} which can then be used to create a new media file according to the
+ * specified {@link OutputOptions}.
+ */
constructor(options: OutputOptions) {
if (!options || typeof options !== 'object') {
throw new TypeError('options must be an object.');
diff --git a/src/packet.ts b/src/packet.ts
index ddbdc56..5221267 100644
--- a/src/packet.ts
+++ b/src/packet.ts
@@ -13,13 +13,17 @@ export const PLACEHOLDER_DATA = new Uint8Array(0);
/**
* The type of a packet. Key packets can be decoded without previous packets, while delta packets depend on previous
* packets.
+ * @group Packets
* @public
*/
export type PacketType = 'key' | 'delta';
/**
- * Represents an encoded chunk of media. Mainly used as an expressive wrapper around WebCodecs API's EncodedVideoChunk
- * and EncodedAudioChunk, but can also be used standalone.
+ * Represents an encoded chunk of media. Mainly used as an expressive wrapper around WebCodecs API's
+ * [`EncodedVideoChunk`](https://developer.mozilla.org/en-US/docs/Web/API/EncodedVideoChunk) and
+ * [`EncodedAudioChunk`](https://developer.mozilla.org/en-US/docs/Web/API/EncodedAudioChunk), but can also be used
+ * standalone.
+ * @group Packets
* @public
*/
export class EncodedPacket {
@@ -29,6 +33,7 @@ export class EncodedPacket {
*/
readonly byteLength: number;
+ /** Creates a new {@link EncodedPacket} from raw bytes and timing information. */
constructor(
/** The encoded data of this packet. */
public readonly data: Uint8Array,
diff --git a/src/reader.ts b/src/reader.ts
index 138601a..b0104f0 100644
--- a/src/reader.ts
+++ b/src/reader.ts
@@ -6,200 +6,261 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
-import { assert, binarySearchLessOrEqual, removeItem } from './misc';
+import { assert, 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 | null;
- 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 (this.fileSize !== null && 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,
- start,
- x => x.start,
- );
- if (index !== -1) {
- for (let i = index; i < this.loadedSegments.length; i++) {
- const segment = this.loadedSegments[i]!;
- if (segment.start > start) {
- break;
+ if (result instanceof Promise) {
+ return result.then((x) => {
+ if (!x) {
+ return null;
}
- 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;
+ return new FileSlice(x.bytes, x.view, x.offset, start, end);
+ });
+ } else {
+ if (!result) {
+ return null;
}
- 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);
+ return new FileSlice(result.bytes, result.view, result.offset, start, end);
}
}
- getViewAndOffset(start: number, end: number) {
- const startIndex = binarySearchLessOrEqual(this.loadedSegments, start, x => x.start);
- let segment: ReadSegment | null = null;
+ requestSliceRange(start: number, minLength: number, maxLength: number): MaybePromise {
+ if (this.fileSize !== null) {
+ return this.requestSlice(
+ start,
+ clamp(this.fileSize - start, minLength, maxLength),
+ );
+ } else {
+ const promisedAttempt = this.requestSlice(start, maxLength);
- if (startIndex !== -1) {
- for (let i = startIndex; i < this.loadedSegments.length; i++) {
- const candidate = this.loadedSegments[i]!;
-
- if (candidate.start > start) {
- break;
+ const handleAttempt = (attempt: FileSlice | null) => {
+ if (attempt) {
+ return attempt;
}
- if (end <= candidate.end) {
- segment = candidate;
- break;
+ const handleFileSize = (fileSize: number | null) => {
+ assert(fileSize !== null); // The slice couldn't fit, meaning we must know the file size now
+
+ return this.requestSlice(
+ start,
+ clamp(fileSize - start, minLength, maxLength),
+ );
+ };
+
+ const promisedFileSize = this.source._retrieveSize();
+ if (promisedFileSize instanceof Promise) {
+ return promisedFileSize.then(handleFileSize);
+ } else {
+ return handleFileSize(promisedFileSize);
}
+ };
+
+ if (promisedAttempt instanceof Promise) {
+ return promisedAttempt.then(handleAttempt);
+ } else {
+ return handleAttempt(promisedAttempt);
}
}
-
- 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..5f407cd 100644
--- a/src/sample.ts
+++ b/src/sample.ts
@@ -19,10 +19,14 @@ import {
/**
* Metadata used for VideoSample initialization.
+ * @group Samples
* @public
*/
export type VideoSampleInit = {
- /** The internal pixel format in which the frame is stored. */
+ /**
+ * The internal pixel format in which the frame is stored.
+ * [See pixel formats](https://developer.mozilla.org/en-US/docs/Web/API/VideoFrame/format)
+ */
format?: VideoPixelFormat;
/** The width of the frame in pixels. */
codedWidth?: number;
@@ -40,7 +44,8 @@ export type VideoSampleInit = {
/**
* Represents a raw, unencoded video sample (frame). Mainly used as an expressive wrapper around WebCodecs API's
- * VideoFrame, but can also be used standalone.
+ * [`VideoFrame`](https://developer.mozilla.org/en-US/docs/Web/API/VideoFrame), but can also be used standalone.
+ * @group Samples
* @public
*/
export class VideoSample {
@@ -49,7 +54,10 @@ export class VideoSample {
/** @internal */
_closed: boolean = false;
- /** The internal pixel format in which the frame is stored. */
+ /**
+ * The internal pixel format in which the frame is stored.
+ * [See pixel formats](https://developer.mozilla.org/en-US/docs/Web/API/VideoFrame/format)
+ */
readonly format!: VideoPixelFormat | null;
/** The width of the frame in pixels. */
readonly codedWidth!: number;
@@ -87,8 +95,24 @@ export class VideoSample {
return Math.trunc(SECOND_TO_MICROSECOND_FACTOR * this.duration);
}
+ /**
+ * Creates a new {@link VideoSample} from a
+ * [`VideoFrame`](https://developer.mozilla.org/en-US/docs/Web/API/VideoFrame). This is essentially a near zero-cost
+ * wrapper around `VideoFrame`. The sample's metadata is optionally refined using the data specified in `init`.
+ */
constructor(data: VideoFrame, init?: VideoSampleInit);
+ /**
+ * Creates a new {@link VideoSample} from a
+ * [`CanvasImageSource`](https://udn.realityripple.com/docs/Web/API/CanvasImageSource), similar to the
+ * [`VideoFrame`](https://developer.mozilla.org/en-US/docs/Web/API/VideoFrame) constructor. When `VideoFrame` is
+ * available, this is simply a wrapper around its constructor. If not, it will copy the source's image data to an
+ * internal canvas for later use.
+ */
constructor(data: CanvasImageSource, init: SetRequired);
+ /**
+ * Creates a new {@link VideoSample} from raw pixel data specified in `data`. Additional metadata must be provided
+ * in `init`.
+ */
constructor(
data: BufferSource,
init: SetRequired
@@ -143,8 +167,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;
@@ -541,10 +568,10 @@ export class VideoSample {
/**
* Controls the fitting algorithm.
*
- * - 'fill' will stretch the image to fill the entire box, potentially altering aspect ratio.
- * - 'contain' will contain the entire image within the box while preserving aspect ratio. This may lead to
+ * - `'fill'` will stretch the image to fill the entire box, potentially altering aspect ratio.
+ * - `'contain'` will contain the entire image within the box while preserving aspect ratio. This may lead to
* letterboxing.
- * - 'cover' will scale the image until the entire box is filled, while preserving aspect ratio.
+ * - `'cover'` will scale the image until the entire box is filled, while preserving aspect ratio.
*/
fit: 'fill' | 'contain' | 'cover';
/** A way to override rotation. Defaults to the rotation of the sample. */
@@ -593,7 +620,8 @@ export class VideoSample {
}
/**
- * Converts this video sample to a CanvasImageSource for drawing to a canvas.
+ * Converts this video sample to a
+ * [`CanvasImageSource`](https://udn.realityripple.com/docs/Web/API/CanvasImageSource) for drawing to a canvas.
*
* You must use the value returned by this method immediately, as any VideoFrame created internally will
* automatically be closed in the next microtask.
@@ -657,12 +685,15 @@ const AUDIO_SAMPLE_FORMATS = new Set(
/**
* Metadata used for AudioSample initialization.
+ * @group Samples
* @public
*/
export type AudioSampleInit = {
/** The audio data for this sample. */
data: AllowSharedBufferSource;
- /** The audio sample format. */
+ /**
+ * The audio sample format. [See sample formats](https://developer.mozilla.org/en-US/docs/Web/API/AudioData/format)
+ */
format: AudioSampleFormat;
/** The number of audio channels. */
numberOfChannels: number;
@@ -674,6 +705,7 @@ export type AudioSampleInit = {
/**
* Options used for copying audio sample data.
+ * @group Samples
* @public
*/
export type AudioSampleCopyToOptions = {
@@ -681,7 +713,10 @@ export type AudioSampleCopyToOptions = {
* The index identifying the plane to copy from. This must be 0 if using a non-planar (interleaved) output format.
*/
planeIndex: number;
- /** The output format for the destination data. Defaults to the AudioSample's format. */
+ /**
+ * The output format for the destination data. Defaults to the AudioSample's format.
+ * [See sample formats](https://developer.mozilla.org/en-US/docs/Web/API/AudioData/format)
+ */
format?: AudioSampleFormat;
/** An offset into the source plane data indicating which frame to begin copying from. Defaults to 0. */
frameOffset?: number;
@@ -693,8 +728,9 @@ export type AudioSampleCopyToOptions = {
};
/**
- * Represents a raw, unencoded audio sample. Mainly used as an expressive wrapper around WebCodecs API's AudioData,
- * but can also be used standalone.
+ * Represents a raw, unencoded audio sample. Mainly used as an expressive wrapper around WebCodecs API's
+ * [`AudioData`](https://developer.mozilla.org/en-US/docs/Web/API/AudioData), but can also be used standalone.
+ * @group Samples
* @public
*/
export class AudioSample {
@@ -703,7 +739,10 @@ export class AudioSample {
/** @internal */
_closed: boolean = false;
- /** The audio sample format. */
+ /**
+ * The audio sample format.
+ * [See sample formats](https://developer.mozilla.org/en-US/docs/Web/API/AudioData/format)
+ */
readonly format: AudioSampleFormat;
/** The audio sample rate in hertz. */
readonly sampleRate: number;
@@ -713,7 +752,7 @@ export class AudioSample {
readonly numberOfFrames: number;
/** The number of audio channels. */
readonly numberOfChannels: number;
- /** The timestamp of the sample in seconds. */
+ /** The duration of the sample in seconds. */
readonly duration: number;
/**
* The presentation timestamp of the sample in seconds. May be negative. Samples with negative end timestamps should
@@ -731,6 +770,11 @@ export class AudioSample {
return Math.trunc(SECOND_TO_MICROSECOND_FACTOR * this.duration);
}
+ /**
+ * Creates a new {@link AudioSample}, either from an existing
+ * [`AudioData`](https://developer.mozilla.org/en-US/docs/Web/API/AudioData) or from raw bytes specified in
+ * {@link AudioSampleInit}.
+ */
constructor(init: AudioData | AudioSampleInit) {
if (isAudioData(init)) {
if (init.format === null) {
diff --git a/src/source.ts b/src/source.ts
index 20c662c..ae24aaf 100644
--- a/src/source.ts
+++ b/src/source.ts
@@ -6,172 +6,260 @@
* 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,
+ toUint8Array,
+} 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.
+ * @group Input sources
* @public
*/
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.
+ *
+ * Returns null if the source is unsized.
*/
- getSize() {
- return this._sizePromise ??= this._retrieveSize();
+ async getSizeOrNull() {
+ return this._sizePromise ??= Promise.resolve(this._retrieveSize());
}
- /** Called each time data is requested from the source. */
+ /**
+ * Resolves with the total size of the file in bytes. This function is memoized, meaning only the first call
+ * will retrieve the size.
+ *
+ * Throws an error if the source is unsized.
+ */
+ async getSize() {
+ const result = await this.getSizeOrNull();
+ if (result === null) {
+ throw new Error('Cannot determine the size of an unsized source.');
+ }
+
+ return result;
+ }
+
+ /** Called each time data is retrieved from the source. Will be called with the retrieved range (end exclusive). */
onread: ((start: number, end: number) => unknown) | null = null;
}
/**
* A source backed by an ArrayBuffer or ArrayBufferView, with the entire file held in memory.
+ * @group Input sources
* @public
*/
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)) {
- throw new TypeError('buffer must be an ArrayBuffer or Uint8Array.');
+ /** Creates a new {@link BufferSource} backed the specified `ArrayBuffer` or `ArrayBufferView`. */
+ constructor(buffer: ArrayBuffer | ArrayBufferView) {
+ if (!(buffer instanceof ArrayBuffer) && !ArrayBuffer.isView(buffer)) {
+ throw new TypeError('buffer must be an ArrayBuffer or ArrayBufferView.');
}
super();
- this._bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
+ this._bytes = toUint8Array(buffer);
+ this._view = toDataView(buffer);
}
/** @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 {@link BlobSource}.
+ * @group Input sources
* @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.
+ * A source backed by a [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob). Since a
+ * [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File) is also a `Blob`, this is the source to use when
+ * reading files off the disk.
+ * @group Input sources
* @public
*/
export class BlobSource extends Source {
/** @internal */
_blob: Blob;
+ /** @internal */
+ _orchestrator: ReadOrchestrator;
- constructor(blob: Blob) {
+ /**
+ * Creates a new {@link BlobSource} backed by the specified
+ * [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/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);
+ }
+
+ worker.running = false;
}
}
+const URL_SOURCE_MIN_LOAD_AMOUNT = 0.5 * 2 ** 20; // 0.5 MiB
+
/**
- * Options for UrlSource.
+ * Options for {@link UrlSource}.
+ * @group Input sources
* @public
*/
export type UrlSourceOptions = {
/**
- * The RequestInit used by the Fetch API. Can be used to further control the requests, such as setting
- * custom headers.
+ * The [`RequestInit`](https://developer.mozilla.org/en-US/docs/Web/API/RequestInit) used by the Fetch API. Can be
+ * used to further control the requests, such as setting custom headers.
*/
requestInit?: RequestInit;
/**
* 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.
+ * @group Input sources
+ * @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();
+ /** Creates a new {@link UrlSource} backed by the resource at the specified URL. */
constructor(
url: string | URL,
options: UrlSourceOptions = {},
@@ -188,141 +276,1236 @@ 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, Math.min(fileSize, 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.',
+ );
+ }
+
+ worker.running = false;
+ return;
+ }
+
+ this.onread?.(worker.currentPos, worker.currentPos + value.length);
+ this._orchestrator.supplyWorkerData(worker, value);
+
+ if (worker.currentPos >= worker.targetPos || worker.aborted) {
+ abortController.abort();
+
+ worker.running = false;
+ return;
+ }
+ }
+ }
+
+ worker.running = false;
+
+ // 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 {@link FilePathSource}.
+ * @group Input sources
+ * @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.
+ * @group Input sources
+ * @public
+ */
+export class FilePathSource extends Source {
+ /** @internal */
+ _streamSource: StreamSource;
+
+ /** Creates a new {@link FilePathSource} backed by the file at the specified file path. */
+ 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 {@link StreamSource}.
+ * @group Input sources
+ * @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 profile 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.
+ * @group Input sources
+ * @public
+ */
+export class StreamSource extends Source {
+ /** @internal */
+ _options: StreamSourceOptions;
+ /** @internal */
+ _orchestrator: ReadOrchestrator;
+
+ /** Creates a new {@link StreamSource} whose behavior is specified by `options`. */
+ 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.');
+ }
+ }
+
+ worker.running = false;
+ }
+}
+
+type ReadableStreamSourcePendingSlice = {
+ start: number;
+ end: number;
+ bytes: Uint8Array;
+ resolve: (bytes: ReadResult | null) => void;
+ reject: (error: unknown) => void;
+};
+
+/**
+ * Options for {@link ReadableStreamSource}.
+ * @group Input sources
+ * @public
+ */
+export type ReadableStreamSourceOptions = {
+ /** The maximum number of bytes the cache is allowed to hold in memory. Defaults to 16 MiB. */
+ maxCacheSize?: number;
+};
+
+/**
+ * A source backed by a [`ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream) of
+ * `Uint8Array`, representing an append-only byte stream of unknown length. This is the source to use for incrementally
+ * streaming in input files that are still being constructed and whose size we don't yet know, like for example the
+ * output chunks of [MediaRecorder](https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder).
+ *
+ * This source is *unsized*, meaning calls to `.getSize()` will throw and readers are more limited due to the
+ * lack of random file access. You should only use this source with sequential access patterns, such as reading all
+ * packets from start to end. This source does not work well with random access patterns unless you increase its
+ * max cache size.
+ *
+ * @group Input sources
+ * @public
+ */
+export class ReadableStreamSource extends Source {
+ /** @internal */
+ _stream: ReadableStream;
+ /** @internal */
+ _reader: ReadableStreamDefaultReader | null = null;
+ /** @internal */
+ _cache: CacheEntry[] = [];
+ /** @internal */
+ _maxCacheSize: number;
+ /** @internal */
+ _pendingSlices: ReadableStreamSourcePendingSlice[] = [];
+ /** @internal */
+ _currentIndex = 0;
+ /** @internal */
+ _targetIndex = 0;
+ /** @internal */
+ _maxRequestedIndex = 0;
+ /** @internal */
+ _endIndex: number | null = null;
+ /** @internal */
+ _pulling = false;
+
+ /** Creates a new {@link ReadableStreamSource} backed by the specified `ReadableStream`. */
+ constructor(stream: ReadableStream, options: ReadableStreamSourceOptions = {}) {
+ if (!(stream instanceof ReadableStream)) {
+ throw new TypeError('stream must be a ReadableStream.');
+ }
+ 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._stream = stream;
+ this._maxCacheSize = options.maxCacheSize ?? (16 * 2 ** 20 /* 16 MiB */);
+ }
+
+ /** @internal */
+ _retrieveSize() {
+ return this._endIndex; // Starts out as null, meaning this source is unsized
+ }
+
+ /** @internal */
+ _read(start: number, end: number): MaybePromise {
+ if (this._endIndex !== null && end > this._endIndex) {
+ return null;
+ }
+
+ this._maxRequestedIndex = Math.max(this._maxRequestedIndex, end);
+
+ const cacheStartIndex = binarySearchLessOrEqual(this._cache, start, x => x.start);
+ const cacheStartEntry = cacheStartIndex !== -1 ? this._cache[cacheStartIndex]! : null;
+
+ if (cacheStartEntry && cacheStartEntry.start <= start && end <= cacheStartEntry.end) {
+ // The request can be satisfied with a single cache entry
+ return {
+ bytes: cacheStartEntry.bytes,
+ view: cacheStartEntry.view,
+ offset: cacheStartEntry.start,
+ };
+ }
+
+ let lastEnd = start;
+ const bytes = new Uint8Array(end - start);
+
+ if (cacheStartIndex !== -1) {
+ // Walk over the cache to see if we can satisfy the request using multiple cache entries
+ for (let i = cacheStartIndex; i < this._cache.length; i++) {
+ const cacheEntry = this._cache[i]!;
+ if (cacheEntry.start >= end) {
+ break;
+ }
+
+ const cappedStart = Math.max(start, cacheEntry.start);
+ if (cappedStart > lastEnd) {
+ // We're too far behind
+ this._throwDueToCacheMiss();
+ }
+
+ const cappedEnd = Math.min(end, cacheEntry.end);
+
+ if (cappedStart < cappedEnd) {
+ bytes.set(
+ cacheEntry.bytes.subarray(cappedStart - cacheEntry.start, cappedEnd - cacheEntry.start),
+ cappedStart - start,
+ );
+
+ lastEnd = cappedEnd;
+ }
+ }
+ }
+
+ if (lastEnd === end) {
+ return {
+ bytes,
+ view: toDataView(bytes),
+ offset: start,
+ };
+ }
+
+ // We need to pull more data
+
+ if (this._currentIndex > lastEnd) {
+ // We're too far behind
+ this._throwDueToCacheMiss();
+ }
+
+ const { promise, resolve, reject } = promiseWithResolvers();
+
+ this._pendingSlices.push({
+ start,
+ end,
+ bytes,
+ resolve,
+ reject,
+ });
+
+ this._targetIndex = Math.max(this._targetIndex, end);
+
+ // Start pulling from the stream if we're not already doing it
+ if (!this._pulling) {
+ this._pulling = true;
+ void this._pull()
+ .catch((error) => {
+ this._pulling = false;
+
+ if (this._pendingSlices.length > 0) {
+ this._pendingSlices.forEach(x => x.reject(error)); // Make sure to propagate any errors
+ this._pendingSlices.length = 0;
+ } else {
+ throw error; // So it doesn't get swallowed
+ }
+ });
+ }
+
+ return promise;
+ }
+
+ /** @internal */
+ _throwDueToCacheMiss() {
+ throw new Error(
+ 'Read is before the cached region. With ReadableStreamSource, you must access the data more'
+ + ' sequentially or increase the size of its cache.',
+ );
+ }
+
+ /** @internal */
+ async _pull() {
+ this._reader ??= this._stream.getReader();
+
+ // This is the loop that keeps pulling data from the stream until a target index is reached, filling requests
+ // in the process
+ while (this._currentIndex < this._targetIndex) {
+ const { done, value } = await this._reader.read();
+ if (done) {
+ for (const pendingSlice of this._pendingSlices) {
+ pendingSlice.resolve(null);
+ }
+ this._pendingSlices.length = 0;
+ this._endIndex = this._currentIndex; // We know how long the file is now!
+
+ break;
+ }
+
+ const startIndex = this._currentIndex;
+ const endIndex = this._currentIndex + value.byteLength;
+
+ // Fill the pending slices with the data
+ for (let i = 0; i < this._pendingSlices.length; i++) {
+ const pendingSlice = this._pendingSlices[i]!;
+
+ const cappedStart = Math.max(startIndex, pendingSlice.start);
+ const cappedEnd = Math.min(endIndex, pendingSlice.end);
+
+ if (cappedStart < cappedEnd) {
+ pendingSlice.bytes.set(
+ value.subarray(cappedStart - startIndex, cappedEnd - startIndex),
+ cappedStart - pendingSlice.start,
+ );
+ if (cappedEnd === pendingSlice.end) {
+ // Pending slice fully filled
+ pendingSlice.resolve({
+ bytes: pendingSlice.bytes,
+ view: toDataView(pendingSlice.bytes),
+ offset: pendingSlice.start,
+ });
+ this._pendingSlices.splice(i, 1);
+ i--;
+ }
+ }
+ }
+
+ this._cache.push({
+ start: startIndex,
+ end: endIndex,
+ bytes: value,
+ view: toDataView(value),
+ age: 0, // Unused
+ });
+
+ // Do cache eviction, based on the distance from the last-requested index. It's important that we do it like
+ // this and not based on where the reader is at, because if the reader is fast, we'll unnecessarily evict
+ // data that we still might need.
+ while (this._cache.length > 0) {
+ const firstEntry = this._cache[0]!;
+ const distance = this._maxRequestedIndex - firstEntry.end;
+
+ if (distance <= this._maxCacheSize) {
+ break;
+ }
+
+ this._cache.shift();
+ }
+
+ this._currentIndex += value.byteLength;
+ }
+
+ this._pulling = false;
+ }
+}
+
+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;
+
+ // This check also implies worker.currentPos <= outerHole.start, a critical condition
+ 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) => {
+ worker.running = false;
+
+ 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
+ }
+ });
+ }
+
+ /** 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) {
+ 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;
+ }
+ }
+
+ if (this.currentCacheSize - oldestEntry.bytes.length <= this.options.maxCacheSize) {
+ // Don't evict if it would shrink the cache below the max size
+ break;
+ }
+
+ this.cache.splice(oldestIndex, 1);
+ this.currentCacheSize -= oldestEntry.bytes.length;
+ }
}
}
diff --git a/src/target.ts b/src/target.ts
index 78fc777..3893be7 100644
--- a/src/target.ts
+++ b/src/target.ts
@@ -6,11 +6,12 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
-import { BufferTargetWriter, StreamTargetWriter, Writer } from './writer';
+import { BufferTargetWriter, NullTargetWriter, StreamTargetWriter, Writer } from './writer';
import { Output } from './output';
/**
* Base class for targets, specifying where output files are written.
+ * @group Output targets
* @public
*/
export abstract class Target {
@@ -19,15 +20,24 @@ export abstract class Target {
/** @internal */
abstract _createWriter(): Writer;
+
+ /**
+ * Called each time data is written to the target. Will be called with the byte range into which data was written.
+ *
+ * Use this callback to track the size of the output file as it grows. But be warned, this function is chatty and
+ * gets called *extremely* often.
+ */
+ onwrite: ((start: number, end: number) => unknown) | null = null;
}
/**
* A target that writes data directly into an ArrayBuffer in memory. Great for performance, but not suitable for very
* large files. The buffer will be available once the output has been finalized.
+ * @group Output targets
* @public
*/
export class BufferTarget extends Target {
- /** Stores the final output buffer. Until the output is finalized, this will be null. */
+ /** Stores the final output buffer. Until the output is finalized, this will be `null`. */
buffer: ArrayBuffer | null = null;
/** @internal */
@@ -37,7 +47,8 @@ export class BufferTarget extends Target {
}
/**
- * A data chunk for StreamTarget.
+ * A data chunk for {@link StreamTarget}.
+ * @group Output targets
* @public
*/
export type StreamTargetChunk = {
@@ -50,7 +61,8 @@ export type StreamTargetChunk = {
};
/**
- * Options for StreamTarget.
+ * Options for {@link StreamTarget}.
+ * @group Output targets
* @public
*/
export type StreamTargetOptions = {
@@ -65,9 +77,12 @@ export type StreamTargetOptions = {
};
/**
- * This target writes data to a WritableStream, making it a general-purpose target for writing data anywhere. It is
- * also compatible with FileSystemWritableFileStream for use with the File System Access API. The WritableStream can
- * also apply backpressure, which will propagate to the output and throttle the encoders.
+ * This target writes data to a [`WritableStream`](https://developer.mozilla.org/en-US/docs/Web/API/WritableStream),
+ * making it a general-purpose target for writing data anywhere. It is also compatible with
+ * [`FileSystemWritableFileStream`](https://developer.mozilla.org/en-US/docs/Web/API/FileSystemWritableFileStream) for
+ * use with the [File System Access API](https://developer.mozilla.org/en-US/docs/Web/API/File_System_API). The
+ * `WritableStream` can also apply backpressure, which will propagate to the output and throttle the encoders.
+ * @group Output targets
* @public
*/
export class StreamTarget extends Target {
@@ -76,6 +91,7 @@ export class StreamTarget extends Target {
/** @internal */
_options: StreamTargetOptions;
+ /** Creates a new {@link StreamTarget} which writes to the specified `writable`. */
constructor(
writable: WritableStream,
options: StreamTargetOptions = {},
@@ -104,3 +120,16 @@ export class StreamTarget extends Target {
return new StreamTargetWriter(this);
}
}
+
+/**
+ * This target just discards all incoming data. It is useful for when you need an {@link Output} but extract data from
+ * it differently, for example through format-specific callbacks (`onMoof`, `onMdat`, ...) or encoder events.
+ * @group Output targets
+ * @public
+ */
+export class NullTarget extends Target {
+ /** @internal */
+ _createWriter() {
+ return new NullTargetWriter(this);
+ }
+}
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..43795f5 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;
@@ -40,64 +38,70 @@ export class WaveDemuxer extends Demuxer {
} | null = null;
tracks: InputAudioTrack[] = [];
+ lastKnownPacketIndex = 0;
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 ?? Infinity);
+ 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 (totalFileSize === null || 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.dataSize = Math.min(dataChunkSize, totalFileSize - this.dataStart);
+ this.dataStart = slice.filePos;
+ this.dataSize = Math.min(dataChunkSize, (totalFileSize ?? Infinity) - 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 ?? Infinity);
}
- this.metadataReader.pos = startPos + chunkSize + (chunkSize & 1); // Handle padding
+ currentPos = startPos + chunkSize + (chunkSize & 1); // Handle padding
chunksRead++;
}
@@ -115,33 +119,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);
@@ -197,10 +203,11 @@ export class WaveDemuxer extends Demuxer {
async computeDuration() {
await this.readMetadata();
- assert(this.audioInfo);
- const numberOfBlocks = this.dataSize / this.audioInfo.blockSizeInBytes;
- return numberOfBlocks / this.audioInfo.sampleRate;
+ const track = this.tracks[0];
+ assert(track);
+
+ return track.computeDuration();
}
async getTracks() {
@@ -241,8 +248,9 @@ class WaveAudioTrackBacking implements InputAudioTrackBacking {
};
}
- computeDuration() {
- return this.demuxer.computeDuration();
+ async computeDuration() {
+ const lastPacket = await this.getPacket(Infinity, { metadataOnly: true });
+ return (lastPacket?.timestamp ?? 0) + (lastPacket?.duration ?? 0);
}
getNumberOfChannels() {
@@ -287,28 +295,38 @@ class WaveAudioTrackBacking implements InputAudioTrackBacking {
this.demuxer.dataSize - startOffset,
);
+ if (this.demuxer.reader.fileSize === null) {
+ // If the file size is unknown, we weren't able to cap the dataSize in the init logic and we instead have to
+ // rely on the headers telling us how large the file is. But, these might be wrong, so let's check if the
+ // requested slice actually exists.
+
+ let slice = this.demuxer.reader.requestSlice(this.demuxer.dataStart + startOffset, sizeInBytes);
+ if (slice instanceof Promise) slice = await slice;
+
+ if (!slice) {
+ return null;
+ }
+ }
+
let data: Uint8Array;
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;
const duration = sizeInBytes / this.demuxer.audioInfo.blockSizeInBytes / this.demuxer.audioInfo.sampleRate;
+ this.demuxer.lastKnownPacketIndex = Math.max(
+ packetIndex,
+ timestamp,
+ );
+
return new EncodedPacket(
data,
'key',
@@ -323,11 +341,38 @@ class WaveAudioTrackBacking implements InputAudioTrackBacking {
return this.getPacketAtIndex(0, options);
}
- getPacket(timestamp: number, options: PacketRetrievalOptions) {
+ async getPacket(timestamp: number, options: PacketRetrievalOptions) {
assert(this.demuxer.audioInfo);
- const packetIndex = Math.floor(timestamp * this.demuxer.audioInfo.sampleRate / PACKET_SIZE_IN_FRAMES);
- return this.getPacketAtIndex(packetIndex, options);
+ const packetIndex = Math.floor(Math.min(
+ timestamp * this.demuxer.audioInfo.sampleRate / PACKET_SIZE_IN_FRAMES,
+ (this.demuxer.dataSize - 1) / (PACKET_SIZE_IN_FRAMES * this.demuxer.audioInfo.blockSizeInBytes),
+ ));
+
+ const packet = await this.getPacketAtIndex(packetIndex, options);
+ if (packet) {
+ return packet;
+ }
+
+ if (packetIndex === 0) {
+ return null; // Empty data chunk
+ }
+
+ assert(this.demuxer.reader.fileSize === null);
+
+ // The file is shorter than we thought, meaning the packet we were looking for doesn't exist. So, let's find
+ // the last packet by doing a sequential scan, instead.
+ let currentPacket = await this.getPacketAtIndex(this.demuxer.lastKnownPacketIndex, options);
+ while (currentPacket) {
+ const nextPacket = await this.getNextPacket(currentPacket, options);
+ if (!nextPacket) {
+ break;
+ }
+
+ currentPacket = nextPacket;
+ }
+
+ return currentPacket;
}
getNextPacket(packet: EncodedPacket, options: PacketRetrievalOptions) {
diff --git a/src/writer.ts b/src/writer.ts
index d9853c4..30de003 100644
--- a/src/writer.ts
+++ b/src/writer.ts
@@ -6,7 +6,7 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
-import { BufferTarget, StreamTarget, StreamTargetChunk } from './target';
+import { BufferTarget, NullTarget, StreamTarget, StreamTargetChunk } from './target';
import { assert } from './misc';
export abstract class Writer {
@@ -156,8 +156,9 @@ export class BufferTargetWriter extends Writer {
this.ensureSize(this.pos + data.byteLength);
this.bytes.set(data, this.pos);
- this.pos += data.byteLength;
+ this.target.onwrite?.(this.pos, this.pos + data.byteLength);
+ this.pos += data.byteLength;
this.maxPos = Math.max(this.maxPos, this.pos);
}
@@ -250,6 +251,8 @@ export class StreamTargetWriter extends Writer {
data: data.slice(),
start: this.pos,
});
+ this.target.onwrite?.(this.pos, this.pos + data.byteLength);
+
this.pos += data.byteLength;
this.lastWriteEnd = Math.max(this.lastWriteEnd, this.pos);
@@ -459,3 +462,29 @@ export class StreamTargetWriter extends Writer {
return this.writer?.close();
}
}
+
+export class NullTargetWriter extends Writer {
+ private pos = 0;
+
+ constructor(private target: NullTarget) {
+ super();
+ }
+
+ write(data: Uint8Array) {
+ this.maybeTrackWrites(data);
+ this.target.onwrite?.(this.pos, this.pos + data.byteLength);
+ this.pos += data.byteLength;
+ }
+
+ getPos() {
+ return this.pos;
+ }
+
+ seek(newPos: number) {
+ this.pos = newPos;
+ }
+
+ async flush() {}
+ async finalize() {}
+ async close() {}
+}
diff --git a/test/browser/url-source-short-file.test.ts b/test/browser/url-source-short-file.test.ts
new file mode 100644
index 0000000..d9d003a
--- /dev/null
+++ b/test/browser/url-source-short-file.test.ts
@@ -0,0 +1,19 @@
+import { expect, test } from 'vitest';
+import { UrlSource } from '../../src/source.js';
+import { ALL_FORMATS } from '../../src/input-format.js';
+import { Input } from '../../src/input.js';
+
+test('Should be able to load a very small video file via URL (<512 kB)', async () => {
+ const source = new UrlSource('/frames.webm');
+ const input = new Input({
+ source,
+ formats: ALL_FORMATS,
+ });
+ const primaryVideoTrack = await input.getPrimaryVideoTrack();
+ if (!primaryVideoTrack) {
+ throw new Error('No video track found');
+ };
+
+ const duration = await primaryVideoTrack.computeDuration();
+ expect(duration).toBeCloseTo(3.33333);
+});
diff --git a/test/node/read-mp4.test.ts b/test/node/read-mp4.test.ts
new file mode 100644
index 0000000..1332cc5
--- /dev/null
+++ b/test/node/read-mp4.test.ts
@@ -0,0 +1,35 @@
+import { expect, test } from 'vitest';
+import path from 'node:path';
+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 filePath = path.join(__dirname, '..', 'public/video.mp4');
+ const input = new Input({
+ source: new FilePathSource(filePath),
+ 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/public/frames.webm b/test/public/frames.webm
new file mode 100644
index 0000000..7b7c009
Binary files /dev/null and b/test/public/frames.webm differ
diff --git a/test/public/video.mp4 b/test/public/video.mp4
new file mode 100644
index 0000000..75b02b4
Binary files /dev/null and b/test/public/video.mp4 differ
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/test/vitest.config.ts b/test/vitest.config.ts
new file mode 100644
index 0000000..90db06d
--- /dev/null
+++ b/test/vitest.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ browser: {
+ provider: 'webdriverio',
+ instances: [{ browser: 'chrome' }],
+ },
+ },
+});
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" }]
+}
diff --git a/tsdoc.json b/tsdoc.json
new file mode 100644
index 0000000..e0118bc
--- /dev/null
+++ b/tsdoc.json
@@ -0,0 +1,13 @@
+{
+ "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json",
+ "extends": ["@microsoft/api-extractor/extends/tsdoc-base.json"],
+ "tagDefinitions": [
+ {
+ "tagName": "@group",
+ "syntaxKind": "block"
+ }
+ ],
+ "supportForTags": {
+ "@group": true
+ }
+}
\ No newline at end of file
diff --git a/vite.config.ts b/vite.config.ts
index db8a881..6b460b0 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -28,6 +28,7 @@ export default defineConfig({
],
server: {
hmr: false,
+ allowedHosts: true,
},
build: {
outDir: 'dist-docs', // Build them directly into the docs build folder