mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-09-29 03:43:50 +02:00
Add the video portion of @mediabunny/server
This commit is contained in:
@@ -0,0 +1,373 @@
|
||||
Mozilla Public License Version 2.0
|
||||
==================================
|
||||
|
||||
1. Definitions
|
||||
--------------
|
||||
|
||||
1.1. "Contributor"
|
||||
means each individual or legal entity that creates, contributes to
|
||||
the creation of, or owns Covered Software.
|
||||
|
||||
1.2. "Contributor Version"
|
||||
means the combination of the Contributions of others (if any) used
|
||||
by a Contributor and that particular Contributor's Contribution.
|
||||
|
||||
1.3. "Contribution"
|
||||
means Covered Software of a particular Contributor.
|
||||
|
||||
1.4. "Covered Software"
|
||||
means Source Code Form to which the initial Contributor has attached
|
||||
the notice in Exhibit A, the Executable Form of such Source Code
|
||||
Form, and Modifications of such Source Code Form, in each case
|
||||
including portions thereof.
|
||||
|
||||
1.5. "Incompatible With Secondary Licenses"
|
||||
means
|
||||
|
||||
(a) that the initial Contributor has attached the notice described
|
||||
in Exhibit B to the Covered Software; or
|
||||
|
||||
(b) that the Covered Software was made available under the terms of
|
||||
version 1.1 or earlier of the License, but not also under the
|
||||
terms of a Secondary License.
|
||||
|
||||
1.6. "Executable Form"
|
||||
means any form of the work other than Source Code Form.
|
||||
|
||||
1.7. "Larger Work"
|
||||
means a work that combines Covered Software with other material, in
|
||||
a separate file or files, that is not Covered Software.
|
||||
|
||||
1.8. "License"
|
||||
means this document.
|
||||
|
||||
1.9. "Licensable"
|
||||
means having the right to grant, to the maximum extent possible,
|
||||
whether at the time of the initial grant or subsequently, any and
|
||||
all of the rights conveyed by this License.
|
||||
|
||||
1.10. "Modifications"
|
||||
means any of the following:
|
||||
|
||||
(a) any file in Source Code Form that results from an addition to,
|
||||
deletion from, or modification of the contents of Covered
|
||||
Software; or
|
||||
|
||||
(b) any new file in Source Code Form that contains any Covered
|
||||
Software.
|
||||
|
||||
1.11. "Patent Claims" of a Contributor
|
||||
means any patent claim(s), including without limitation, method,
|
||||
process, and apparatus claims, in any patent Licensable by such
|
||||
Contributor that would be infringed, but for the grant of the
|
||||
License, by the making, using, selling, offering for sale, having
|
||||
made, import, or transfer of either its Contributions or its
|
||||
Contributor Version.
|
||||
|
||||
1.12. "Secondary License"
|
||||
means either the GNU General Public License, Version 2.0, the GNU
|
||||
Lesser General Public License, Version 2.1, the GNU Affero General
|
||||
Public License, Version 3.0, or any later versions of those
|
||||
licenses.
|
||||
|
||||
1.13. "Source Code Form"
|
||||
means the form of the work preferred for making modifications.
|
||||
|
||||
1.14. "You" (or "Your")
|
||||
means an individual or a legal entity exercising rights under this
|
||||
License. For legal entities, "You" includes any entity that
|
||||
controls, is controlled by, or is under common control with You. For
|
||||
purposes of this definition, "control" means (a) the power, direct
|
||||
or indirect, to cause the direction or management of such entity,
|
||||
whether by contract or otherwise, or (b) ownership of more than
|
||||
fifty percent (50%) of the outstanding shares or beneficial
|
||||
ownership of such entity.
|
||||
|
||||
2. License Grants and Conditions
|
||||
--------------------------------
|
||||
|
||||
2.1. Grants
|
||||
|
||||
Each Contributor hereby grants You a world-wide, royalty-free,
|
||||
non-exclusive license:
|
||||
|
||||
(a) under intellectual property rights (other than patent or trademark)
|
||||
Licensable by such Contributor to use, reproduce, make available,
|
||||
modify, display, perform, distribute, and otherwise exploit its
|
||||
Contributions, either on an unmodified basis, with Modifications, or
|
||||
as part of a Larger Work; and
|
||||
|
||||
(b) under Patent Claims of such Contributor to make, use, sell, offer
|
||||
for sale, have made, import, and otherwise transfer either its
|
||||
Contributions or its Contributor Version.
|
||||
|
||||
2.2. Effective Date
|
||||
|
||||
The licenses granted in Section 2.1 with respect to any Contribution
|
||||
become effective for each Contribution on the date the Contributor first
|
||||
distributes such Contribution.
|
||||
|
||||
2.3. Limitations on Grant Scope
|
||||
|
||||
The licenses granted in this Section 2 are the only rights granted under
|
||||
this License. No additional rights or licenses will be implied from the
|
||||
distribution or licensing of Covered Software under this License.
|
||||
Notwithstanding Section 2.1(b) above, no patent license is granted by a
|
||||
Contributor:
|
||||
|
||||
(a) for any code that a Contributor has removed from Covered Software;
|
||||
or
|
||||
|
||||
(b) for infringements caused by: (i) Your and any other third party's
|
||||
modifications of Covered Software, or (ii) the combination of its
|
||||
Contributions with other software (except as part of its Contributor
|
||||
Version); or
|
||||
|
||||
(c) under Patent Claims infringed by Covered Software in the absence of
|
||||
its Contributions.
|
||||
|
||||
This License does not grant any rights in the trademarks, service marks,
|
||||
or logos of any Contributor (except as may be necessary to comply with
|
||||
the notice requirements in Section 3.4).
|
||||
|
||||
2.4. Subsequent Licenses
|
||||
|
||||
No Contributor makes additional grants as a result of Your choice to
|
||||
distribute the Covered Software under a subsequent version of this
|
||||
License (see Section 10.2) or under the terms of a Secondary License (if
|
||||
permitted under the terms of Section 3.3).
|
||||
|
||||
2.5. Representation
|
||||
|
||||
Each Contributor represents that the Contributor believes its
|
||||
Contributions are its original creation(s) or it has sufficient rights
|
||||
to grant the rights to its Contributions conveyed by this License.
|
||||
|
||||
2.6. Fair Use
|
||||
|
||||
This License is not intended to limit any rights You have under
|
||||
applicable copyright doctrines of fair use, fair dealing, or other
|
||||
equivalents.
|
||||
|
||||
2.7. Conditions
|
||||
|
||||
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
|
||||
in Section 2.1.
|
||||
|
||||
3. Responsibilities
|
||||
-------------------
|
||||
|
||||
3.1. Distribution of Source Form
|
||||
|
||||
All distribution of Covered Software in Source Code Form, including any
|
||||
Modifications that You create or to which You contribute, must be under
|
||||
the terms of this License. You must inform recipients that the Source
|
||||
Code Form of the Covered Software is governed by the terms of this
|
||||
License, and how they can obtain a copy of this License. You may not
|
||||
attempt to alter or restrict the recipients' rights in the Source Code
|
||||
Form.
|
||||
|
||||
3.2. Distribution of Executable Form
|
||||
|
||||
If You distribute Covered Software in Executable Form then:
|
||||
|
||||
(a) such Covered Software must also be made available in Source Code
|
||||
Form, as described in Section 3.1, and You must inform recipients of
|
||||
the Executable Form how they can obtain a copy of such Source Code
|
||||
Form by reasonable means in a timely manner, at a charge no more
|
||||
than the cost of distribution to the recipient; and
|
||||
|
||||
(b) You may distribute such Executable Form under the terms of this
|
||||
License, or sublicense it under different terms, provided that the
|
||||
license for the Executable Form does not attempt to limit or alter
|
||||
the recipients' rights in the Source Code Form under this License.
|
||||
|
||||
3.3. Distribution of a Larger Work
|
||||
|
||||
You may create and distribute a Larger Work under terms of Your choice,
|
||||
provided that You also comply with the requirements of this License for
|
||||
the Covered Software. If the Larger Work is a combination of Covered
|
||||
Software with a work governed by one or more Secondary Licenses, and the
|
||||
Covered Software is not Incompatible With Secondary Licenses, this
|
||||
License permits You to additionally distribute such Covered Software
|
||||
under the terms of such Secondary License(s), so that the recipient of
|
||||
the Larger Work may, at their option, further distribute the Covered
|
||||
Software under the terms of either this License or such Secondary
|
||||
License(s).
|
||||
|
||||
3.4. Notices
|
||||
|
||||
You may not remove or alter the substance of any license notices
|
||||
(including copyright notices, patent notices, disclaimers of warranty,
|
||||
or limitations of liability) contained within the Source Code Form of
|
||||
the Covered Software, except that You may alter any license notices to
|
||||
the extent required to remedy known factual inaccuracies.
|
||||
|
||||
3.5. Application of Additional Terms
|
||||
|
||||
You may choose to offer, and to charge a fee for, warranty, support,
|
||||
indemnity or liability obligations to one or more recipients of Covered
|
||||
Software. However, You may do so only on Your own behalf, and not on
|
||||
behalf of any Contributor. You must make it absolutely clear that any
|
||||
such warranty, support, indemnity, or liability obligation is offered by
|
||||
You alone, and You hereby agree to indemnify every Contributor for any
|
||||
liability incurred by such Contributor as a result of warranty, support,
|
||||
indemnity or liability terms You offer. You may include additional
|
||||
disclaimers of warranty and limitations of liability specific to any
|
||||
jurisdiction.
|
||||
|
||||
4. Inability to Comply Due to Statute or Regulation
|
||||
---------------------------------------------------
|
||||
|
||||
If it is impossible for You to comply with any of the terms of this
|
||||
License with respect to some or all of the Covered Software due to
|
||||
statute, judicial order, or regulation then You must: (a) comply with
|
||||
the terms of this License to the maximum extent possible; and (b)
|
||||
describe the limitations and the code they affect. Such description must
|
||||
be placed in a text file included with all distributions of the Covered
|
||||
Software under this License. Except to the extent prohibited by statute
|
||||
or regulation, such description must be sufficiently detailed for a
|
||||
recipient of ordinary skill to be able to understand it.
|
||||
|
||||
5. Termination
|
||||
--------------
|
||||
|
||||
5.1. The rights granted under this License will terminate automatically
|
||||
if You fail to comply with any of its terms. However, if You become
|
||||
compliant, then the rights granted under this License from a particular
|
||||
Contributor are reinstated (a) provisionally, unless and until such
|
||||
Contributor explicitly and finally terminates Your grants, and (b) on an
|
||||
ongoing basis, if such Contributor fails to notify You of the
|
||||
non-compliance by some reasonable means prior to 60 days after You have
|
||||
come back into compliance. Moreover, Your grants from a particular
|
||||
Contributor are reinstated on an ongoing basis if such Contributor
|
||||
notifies You of the non-compliance by some reasonable means, this is the
|
||||
first time You have received notice of non-compliance with this License
|
||||
from such Contributor, and You become compliant prior to 30 days after
|
||||
Your receipt of the notice.
|
||||
|
||||
5.2. If You initiate litigation against any entity by asserting a patent
|
||||
infringement claim (excluding declaratory judgment actions,
|
||||
counter-claims, and cross-claims) alleging that a Contributor Version
|
||||
directly or indirectly infringes any patent, then the rights granted to
|
||||
You by any and all Contributors for the Covered Software under Section
|
||||
2.1 of this License shall terminate.
|
||||
|
||||
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
|
||||
end user license agreements (excluding distributors and resellers) which
|
||||
have been validly granted by You or Your distributors under this License
|
||||
prior to termination shall survive termination.
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 6. Disclaimer of Warranty *
|
||||
* ------------------------- *
|
||||
* *
|
||||
* Covered Software is provided under this License on an "as is" *
|
||||
* basis, without warranty of any kind, either expressed, implied, or *
|
||||
* statutory, including, without limitation, warranties that the *
|
||||
* Covered Software is free of defects, merchantable, fit for a *
|
||||
* particular purpose or non-infringing. The entire risk as to the *
|
||||
* quality and performance of the Covered Software is with You. *
|
||||
* Should any Covered Software prove defective in any respect, You *
|
||||
* (not any Contributor) assume the cost of any necessary servicing, *
|
||||
* repair, or correction. This disclaimer of warranty constitutes an *
|
||||
* essential part of this License. No use of any Covered Software is *
|
||||
* authorized under this License except under this disclaimer. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 7. Limitation of Liability *
|
||||
* -------------------------- *
|
||||
* *
|
||||
* Under no circumstances and under no legal theory, whether tort *
|
||||
* (including negligence), contract, or otherwise, shall any *
|
||||
* Contributor, or anyone who distributes Covered Software as *
|
||||
* permitted above, be liable to You for any direct, indirect, *
|
||||
* special, incidental, or consequential damages of any character *
|
||||
* including, without limitation, damages for lost profits, loss of *
|
||||
* goodwill, work stoppage, computer failure or malfunction, or any *
|
||||
* and all other commercial damages or losses, even if such party *
|
||||
* shall have been informed of the possibility of such damages. This *
|
||||
* limitation of liability shall not apply to liability for death or *
|
||||
* personal injury resulting from such party's negligence to the *
|
||||
* extent applicable law prohibits such limitation. Some *
|
||||
* jurisdictions do not allow the exclusion or limitation of *
|
||||
* incidental or consequential damages, so this exclusion and *
|
||||
* limitation may not apply to You. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
8. Litigation
|
||||
-------------
|
||||
|
||||
Any litigation relating to this License may be brought only in the
|
||||
courts of a jurisdiction where the defendant maintains its principal
|
||||
place of business and such litigation shall be governed by laws of that
|
||||
jurisdiction, without reference to its conflict-of-law provisions.
|
||||
Nothing in this Section shall prevent a party's ability to bring
|
||||
cross-claims or counter-claims.
|
||||
|
||||
9. Miscellaneous
|
||||
----------------
|
||||
|
||||
This License represents the complete agreement concerning the subject
|
||||
matter hereof. If any provision of this License is held to be
|
||||
unenforceable, such provision shall be reformed only to the extent
|
||||
necessary to make it enforceable. Any law or regulation which provides
|
||||
that the language of a contract shall be construed against the drafter
|
||||
shall not be used to construe this License against a Contributor.
|
||||
|
||||
10. Versions of the License
|
||||
---------------------------
|
||||
|
||||
10.1. New Versions
|
||||
|
||||
Mozilla Foundation is the license steward. Except as provided in Section
|
||||
10.3, no one other than the license steward has the right to modify or
|
||||
publish new versions of this License. Each version will be given a
|
||||
distinguishing version number.
|
||||
|
||||
10.2. Effect of New Versions
|
||||
|
||||
You may distribute the Covered Software under the terms of the version
|
||||
of the License under which You originally received the Covered Software,
|
||||
or under the terms of any subsequent version published by the license
|
||||
steward.
|
||||
|
||||
10.3. Modified Versions
|
||||
|
||||
If you create software not governed by this License, and you want to
|
||||
create a new license for such software, you may create and use a
|
||||
modified version of this License if you rename the license and remove
|
||||
any references to the name of the license steward (except to note that
|
||||
such modified license differs from this License).
|
||||
|
||||
10.4. Distributing Source Code Form that is Incompatible With Secondary
|
||||
Licenses
|
||||
|
||||
If You choose to distribute Source Code Form that is Incompatible With
|
||||
Secondary Licenses under the terms of this version of the License, the
|
||||
notice described in Exhibit B of this License must be attached.
|
||||
|
||||
Exhibit A - Source Code Form License Notice
|
||||
-------------------------------------------
|
||||
|
||||
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/.
|
||||
|
||||
If it is not possible or desirable to put the notice in a particular
|
||||
file, then You may include the notice in a location (such as a LICENSE
|
||||
file in a relevant directory) where a recipient would be likely to look
|
||||
for such a notice.
|
||||
|
||||
You may add additional accurate notices of copyright ownership.
|
||||
|
||||
Exhibit B - "Incompatible With Secondary Licenses" Notice
|
||||
---------------------------------------------------------
|
||||
|
||||
This Source Code Form is "Incompatible With Secondary Licenses", as
|
||||
defined by the Mozilla Public License, v. 2.0.
|
||||
@@ -0,0 +1 @@
|
||||
todo
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
|
||||
"mainEntryPointFilePath": "dist/modules/src/index.d.ts",
|
||||
"bundledPackages": [],
|
||||
"compiler": {},
|
||||
"apiReport": {
|
||||
"enabled": false
|
||||
},
|
||||
"docModel": {
|
||||
"enabled": false
|
||||
},
|
||||
"dtsRollup": {
|
||||
"enabled": true,
|
||||
"untrimmedFilePath": "dist/mediabunny-flac-encoder.d.ts"
|
||||
},
|
||||
"tsdocMetadata": {
|
||||
"enabled": false
|
||||
},
|
||||
"messages": {
|
||||
"compilerMessageReporting": {
|
||||
"default": {
|
||||
"logLevel": "warning"
|
||||
}
|
||||
},
|
||||
"extractorMessageReporting": {
|
||||
"default": {
|
||||
"logLevel": "warning"
|
||||
}
|
||||
},
|
||||
"tsdocMessageReporting": {
|
||||
"default": {
|
||||
"logLevel": "warning"
|
||||
}
|
||||
}
|
||||
},
|
||||
"newlineKind": "lf"
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "@mediabunny/server",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.43.1",
|
||||
"description": "FLAC encoder extension for Mediabunny, based on libFLAC.",
|
||||
"main": "./dist/bundles/mediabunny-server.cjs",
|
||||
"module": "./dist/bundles/mediabunny-server.mjs",
|
||||
"types": "./dist/modules/src/index.d.ts",
|
||||
"exports": {
|
||||
"types": "./dist/modules/src/index.d.ts",
|
||||
"import": "./dist/bundles/mediabunny-server.mjs",
|
||||
"require": "./dist/bundles/mediabunny-server.cjs"
|
||||
},
|
||||
"files": [
|
||||
"README.md",
|
||||
"package.json",
|
||||
"LICENSE",
|
||||
"dist",
|
||||
"src"
|
||||
],
|
||||
"browser": {
|
||||
"worker_threads": false
|
||||
},
|
||||
"sideEffects": false,
|
||||
"license": "MPL-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/Vanilagy/mediabunny.git",
|
||||
"directory": "packages/server"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/Vanilagy/mediabunny/issues"
|
||||
},
|
||||
"homepage": "https://mediabunny.dev/guide/extensions/server",
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/Vanilagy"
|
||||
},
|
||||
"dependencies": {
|
||||
"node-av": "^5.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"mediabunny": "^1.0.0"
|
||||
},
|
||||
"keywords": [
|
||||
"flac",
|
||||
"encoding",
|
||||
"codec",
|
||||
"mediabunny",
|
||||
"lossless",
|
||||
"browser",
|
||||
"wasm"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { registerDecoder, registerEncoder } from 'mediabunny';
|
||||
import { NodeAvVideoDecoder } from './video-decoder';
|
||||
import * as NodeAv from 'node-av';
|
||||
import { NodeAvVideoEncoder } from './video-encoder';
|
||||
|
||||
const SERVER_LOADED_SYMBOL = Symbol.for('@mediabunny/server loaded');
|
||||
if ((globalThis as Record<symbol, unknown>)[SERVER_LOADED_SYMBOL]) {
|
||||
console.error(
|
||||
'[WARNING]\n@mediabunny/server was loaded twice.'
|
||||
+ ' This will likely cause the package not to work correctly.'
|
||||
+ ' Check if multiple dependencies are importing different versions of @mediabunny/server,'
|
||||
+ ' or if something is being bundled incorrectly.',
|
||||
);
|
||||
}
|
||||
(globalThis as Record<symbol, unknown>)[SERVER_LOADED_SYMBOL] = true;
|
||||
|
||||
let registered = false;
|
||||
export const registerMediabunnyServer = () => {
|
||||
if (registered) {
|
||||
return;
|
||||
}
|
||||
registered = true;
|
||||
|
||||
NodeAv.Log.setLevel(NodeAv.AV_LOG_ERROR);
|
||||
|
||||
registerDecoder(NodeAvVideoDecoder);
|
||||
registerEncoder(NodeAvVideoEncoder);
|
||||
};
|
||||
@@ -0,0 +1,182 @@
|
||||
import { VideoSamplePixelFormat, VideoCodec } from 'mediabunny';
|
||||
import * as NodeAv from 'node-av';
|
||||
|
||||
export const CODEC_TO_CODEC_ID: Record<VideoCodec, NodeAv.AVCodecID> = {
|
||||
avc: NodeAv.AV_CODEC_ID_H264,
|
||||
hevc: NodeAv.AV_CODEC_ID_HEVC,
|
||||
vp8: NodeAv.AV_CODEC_ID_VP8,
|
||||
vp9: NodeAv.AV_CODEC_ID_VP9,
|
||||
av1: NodeAv.AV_CODEC_ID_AV1,
|
||||
};
|
||||
|
||||
let cachedHardwareContext: NodeAv.HardwareContext | null | undefined = undefined;
|
||||
export const getHardwareContext = (): NodeAv.HardwareContext | null => {
|
||||
if (cachedHardwareContext === undefined) {
|
||||
cachedHardwareContext = NodeAv.HardwareContext.auto();
|
||||
}
|
||||
return cachedHardwareContext;
|
||||
};
|
||||
|
||||
const hardwareDecoderCodecCache = new Map<NodeAv.AVCodecID, NodeAv.Codec | null>();
|
||||
export const getHardwareDecoderCodec = (codecId: NodeAv.AVCodecID): NodeAv.Codec | null => {
|
||||
if (!hardwareDecoderCodecCache.has(codecId)) {
|
||||
const hw = getHardwareContext();
|
||||
hardwareDecoderCodecCache.set(codecId, hw?.getDecoderCodec(codecId) ?? null);
|
||||
}
|
||||
return hardwareDecoderCodecCache.get(codecId)!;
|
||||
};
|
||||
|
||||
const hardwareEncoderCodecCache = new Map<NodeAv.AVCodecID, NodeAv.Codec | null>();
|
||||
export const getHardwareEncoderCodec = (codecId: NodeAv.AVCodecID): NodeAv.Codec | null => {
|
||||
if (!hardwareEncoderCodecCache.has(codecId)) {
|
||||
const hw = getHardwareContext();
|
||||
hardwareEncoderCodecCache.set(codecId, hw?.getEncoderCodec(codecId) ?? null);
|
||||
}
|
||||
return hardwareEncoderCodecCache.get(codecId)!;
|
||||
};
|
||||
|
||||
export const mapColorPrimaries = (primaries: string) => {
|
||||
switch (primaries) {
|
||||
case 'bt709': return NodeAv.AVCOL_PRI_BT709;
|
||||
case 'bt470bg': return NodeAv.AVCOL_PRI_BT470BG;
|
||||
case 'smpte170m': return NodeAv.AVCOL_PRI_SMPTE170M;
|
||||
case 'bt2020': return NodeAv.AVCOL_PRI_BT2020;
|
||||
case 'smpte432': return NodeAv.AVCOL_PRI_SMPTE432;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const unmapColorPrimaries = (primaries: number) => {
|
||||
switch (primaries) {
|
||||
case NodeAv.AVCOL_PRI_BT709: return 'bt709';
|
||||
case NodeAv.AVCOL_PRI_BT470BG: return 'bt470bg';
|
||||
case NodeAv.AVCOL_PRI_SMPTE170M: return 'smpte170m';
|
||||
case NodeAv.AVCOL_PRI_BT2020: return 'bt2020';
|
||||
case NodeAv.AVCOL_PRI_SMPTE432: return 'smpte432';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const mapTransferCharacteristics = (transfer: string) => {
|
||||
switch (transfer) {
|
||||
case 'bt709': return NodeAv.AVCOL_TRC_BT709;
|
||||
case 'smpte170m': return NodeAv.AVCOL_TRC_SMPTE170M;
|
||||
case 'iec61966-2-1': return NodeAv.AVCOL_TRC_IEC61966_2_1;
|
||||
case 'linear': return NodeAv.AVCOL_TRC_LINEAR;
|
||||
case 'pq': return NodeAv.AVCOL_TRC_SMPTE2084;
|
||||
case 'hlg': return NodeAv.AVCOL_TRC_ARIB_STD_B67;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const unmapTransferCharacteristics = (transfer: number) => {
|
||||
switch (transfer) {
|
||||
case NodeAv.AVCOL_TRC_BT709: return 'bt709';
|
||||
case NodeAv.AVCOL_TRC_SMPTE170M: return 'smpte170m';
|
||||
case NodeAv.AVCOL_TRC_IEC61966_2_1: return 'iec61966-2-1';
|
||||
case NodeAv.AVCOL_TRC_LINEAR: return 'linear';
|
||||
case NodeAv.AVCOL_TRC_SMPTE2084: return 'pq';
|
||||
case NodeAv.AVCOL_TRC_ARIB_STD_B67: return 'hlg';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const mapMatrixCoefficients = (matrix: string) => {
|
||||
switch (matrix) {
|
||||
case 'rgb': return NodeAv.AVCOL_SPC_RGB;
|
||||
case 'bt709': return NodeAv.AVCOL_SPC_BT709;
|
||||
case 'bt470bg': return NodeAv.AVCOL_SPC_BT470BG;
|
||||
case 'smpte170m': return NodeAv.AVCOL_SPC_SMPTE170M;
|
||||
case 'bt2020-ncl': return NodeAv.AVCOL_SPC_BT2020_NCL;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const unmapMatrixCoefficients = (matrix: number) => {
|
||||
switch (matrix) {
|
||||
case NodeAv.AVCOL_SPC_RGB: return 'rgb';
|
||||
case NodeAv.AVCOL_SPC_BT709: return 'bt709';
|
||||
case NodeAv.AVCOL_SPC_BT470BG: return 'bt470bg';
|
||||
case NodeAv.AVCOL_SPC_SMPTE170M: return 'smpte170m';
|
||||
case NodeAv.AVCOL_SPC_BT2020_NCL: return 'bt2020-ncl';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const toPixelFormat = (ffmpegPixelFormat: NodeAv.AVPixelFormat): VideoSamplePixelFormat | null => {
|
||||
switch (ffmpegPixelFormat) {
|
||||
case NodeAv.AV_PIX_FMT_YUV420P: return 'I420';
|
||||
// "deprecated in favor of AV_PIX_FMT_YUV420P and setting color_range"
|
||||
case NodeAv.AV_PIX_FMT_YUVJ420P: return 'I420';
|
||||
case NodeAv.AV_PIX_FMT_YUV420P10LE: return 'I420P10';
|
||||
case NodeAv.AV_PIX_FMT_YUV420P12LE: return 'I420P12';
|
||||
case NodeAv.AV_PIX_FMT_YUVA420P: return 'I420A';
|
||||
case NodeAv.AV_PIX_FMT_YUVA420P10LE: return 'I420AP10';
|
||||
|
||||
case NodeAv.AV_PIX_FMT_YUV422P: return 'I422';
|
||||
// "deprecated in favor of AV_PIX_FMT_YUV422P and setting color_range"
|
||||
case NodeAv.AV_PIX_FMT_YUVJ422P: return 'I422';
|
||||
case NodeAv.AV_PIX_FMT_YUV422P10LE: return 'I422P10';
|
||||
case NodeAv.AV_PIX_FMT_YUV422P12LE: return 'I422P12';
|
||||
case NodeAv.AV_PIX_FMT_YUVA422P: return 'I422A';
|
||||
case NodeAv.AV_PIX_FMT_YUVA422P10LE: return 'I422AP10';
|
||||
case NodeAv.AV_PIX_FMT_YUVA422P12LE: return 'I422AP12';
|
||||
|
||||
case NodeAv.AV_PIX_FMT_YUV444P: return 'I444';
|
||||
// "deprecated in favor of AV_PIX_FMT_YUV444P and setting color_range"
|
||||
case NodeAv.AV_PIX_FMT_YUVJ444P: return 'I444';
|
||||
case NodeAv.AV_PIX_FMT_YUV444P10LE: return 'I444P10';
|
||||
case NodeAv.AV_PIX_FMT_YUV444P12LE: return 'I444P12';
|
||||
case NodeAv.AV_PIX_FMT_YUVA444P: return 'I444A';
|
||||
case NodeAv.AV_PIX_FMT_YUVA444P10LE: return 'I444AP10';
|
||||
case NodeAv.AV_PIX_FMT_YUVA444P12LE: return 'I444AP12';
|
||||
|
||||
case NodeAv.AV_PIX_FMT_NV12: return 'NV12';
|
||||
|
||||
case NodeAv.AV_PIX_FMT_RGBA: return 'RGBA';
|
||||
case NodeAv.AV_PIX_FMT_RGB0: return 'RGBX';
|
||||
case NodeAv.AV_PIX_FMT_BGRA: return 'BGRA';
|
||||
case NodeAv.AV_PIX_FMT_BGR0: return 'BGRX';
|
||||
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const fromPixelFormat = (pixelFormat: VideoSamplePixelFormat) => {
|
||||
switch (pixelFormat) {
|
||||
case 'I420': return NodeAv.AV_PIX_FMT_YUV420P;
|
||||
case 'I420P10': return NodeAv.AV_PIX_FMT_YUV420P10LE;
|
||||
case 'I420P12': return NodeAv.AV_PIX_FMT_YUV420P12LE;
|
||||
case 'I420A': return NodeAv.AV_PIX_FMT_YUVA420P;
|
||||
case 'I420AP10': return NodeAv.AV_PIX_FMT_YUVA420P10LE;
|
||||
|
||||
case 'I422': return NodeAv.AV_PIX_FMT_YUV422P;
|
||||
case 'I422P10': return NodeAv.AV_PIX_FMT_YUV422P10LE;
|
||||
case 'I422P12': return NodeAv.AV_PIX_FMT_YUV422P12LE;
|
||||
case 'I422A': return NodeAv.AV_PIX_FMT_YUVA422P;
|
||||
case 'I422AP10': return NodeAv.AV_PIX_FMT_YUVA422P10LE;
|
||||
case 'I422AP12': return NodeAv.AV_PIX_FMT_YUVA422P12LE;
|
||||
|
||||
case 'I444': return NodeAv.AV_PIX_FMT_YUV444P;
|
||||
case 'I444P10': return NodeAv.AV_PIX_FMT_YUV444P10LE;
|
||||
case 'I444P12': return NodeAv.AV_PIX_FMT_YUV444P12LE;
|
||||
case 'I444A': return NodeAv.AV_PIX_FMT_YUVA444P;
|
||||
case 'I444AP10': return NodeAv.AV_PIX_FMT_YUVA444P10LE;
|
||||
case 'I444AP12': return NodeAv.AV_PIX_FMT_YUVA444P12LE;
|
||||
|
||||
case 'NV12': return NodeAv.AV_PIX_FMT_NV12;
|
||||
|
||||
case 'RGBA': return NodeAv.AV_PIX_FMT_RGBA;
|
||||
case 'RGBX': return NodeAv.AV_PIX_FMT_RGB0;
|
||||
case 'BGRA': return NodeAv.AV_PIX_FMT_BGRA;
|
||||
case 'BGRX': return NodeAv.AV_PIX_FMT_BGR0;
|
||||
|
||||
default: return NodeAv.AV_PIX_FMT_NONE;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,211 @@
|
||||
import { CustomVideoDecoder, VideoCodec, EncodedPacket, VideoSample, MaybePromise, Rational } from 'mediabunny';
|
||||
import * as NodeAv from 'node-av';
|
||||
import {
|
||||
CODEC_TO_CODEC_ID,
|
||||
getHardwareDecoderCodec,
|
||||
mapColorPrimaries,
|
||||
mapMatrixCoefficients,
|
||||
mapTransferCharacteristics,
|
||||
} from './misc';
|
||||
import { binarySearchLessOrEqual, simplifyRational, toUint8Array } from '../../../src/misc';
|
||||
import { NodeAvFrameVideoSampleResource } from './video-sample';
|
||||
|
||||
export class NodeAvVideoDecoder extends CustomVideoDecoder {
|
||||
frame!: NodeAv.Frame;
|
||||
packet!: NodeAv.Packet;
|
||||
codecContext!: NodeAv.CodecContext;
|
||||
pixelAspectRatio!: Rational;
|
||||
|
||||
// Bookkeeping to restore the original timing information
|
||||
preciseTimings: {
|
||||
microsecondTimestamp: number;
|
||||
timestamp: number;
|
||||
duration: number;
|
||||
timestampIsValid: boolean;
|
||||
durationIsValid: boolean;
|
||||
}[] = [];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
static override supports(codec: VideoCodec, config: VideoDecoderConfig): boolean {
|
||||
return codec === 'avc' || codec === 'hevc' || codec === 'vp8' || codec === 'vp9' || codec === 'av1';
|
||||
}
|
||||
|
||||
async init() {
|
||||
this.frame = new NodeAv.Frame();
|
||||
this.frame.alloc();
|
||||
this.packet = new NodeAv.Packet();
|
||||
this.packet.alloc();
|
||||
|
||||
const codecId = CODEC_TO_CODEC_ID[this.codec];
|
||||
|
||||
let codec: NodeAv.Codec | null;
|
||||
if (this.config.hardwareAcceleration !== 'prefer-hardware' || this.codec === 'av1') {
|
||||
// https://github.com/opencv/opencv/issues/24430
|
||||
codec = NodeAv.Codec.findDecoder(codecId);
|
||||
} else {
|
||||
codec = getHardwareDecoderCodec(codecId) ?? NodeAv.Codec.findDecoder(codecId);
|
||||
}
|
||||
|
||||
if (!codec) {
|
||||
throw new Error(`Unable to obtain libav codec for '${this.codec}'.`);
|
||||
}
|
||||
|
||||
const codecContext = new NodeAv.CodecContext();
|
||||
codecContext.allocContext3(codec);
|
||||
|
||||
this.pixelAspectRatio = simplifyRational({
|
||||
num: (this.config.displayAspectWidth ?? this.config.codedWidth ?? 0) * (this.config.codedHeight ?? 0),
|
||||
den: (this.config.displayAspectHeight ?? this.config.codedHeight ?? 0) * (this.config.codedWidth ?? 0) || 1,
|
||||
});
|
||||
|
||||
codecContext.width = this.config.codedWidth ?? 0; // Fucky that the dimensions can be optional, but oh well
|
||||
codecContext.height = this.config.codedHeight ?? 0;
|
||||
codecContext.codecType = NodeAv.AVMEDIA_TYPE_VIDEO;
|
||||
codecContext.codecId = codecId;
|
||||
codecContext.extraData = this.config.description
|
||||
? Buffer.from(toUint8Array(this.config.description))
|
||||
: null;
|
||||
codecContext.sampleAspectRatio = new NodeAv.Rational(this.pixelAspectRatio.num, this.pixelAspectRatio.den);
|
||||
|
||||
if (this.config.colorSpace?.primaries) {
|
||||
const mapped = mapColorPrimaries(this.config.colorSpace.primaries);
|
||||
if (mapped !== null) {
|
||||
codecContext.colorPrimaries = mapped;
|
||||
}
|
||||
}
|
||||
if (this.config.colorSpace?.transfer) {
|
||||
const mapped = mapTransferCharacteristics(this.config.colorSpace.transfer);
|
||||
if (mapped !== null) {
|
||||
codecContext.colorTrc = mapped;
|
||||
}
|
||||
}
|
||||
if (this.config.colorSpace?.matrix) {
|
||||
const mapped = mapMatrixCoefficients(this.config.colorSpace.matrix);
|
||||
if (mapped !== null) {
|
||||
codecContext.colorSpace = mapped;
|
||||
}
|
||||
}
|
||||
if (this.config.colorSpace?.fullRange != null) {
|
||||
codecContext.colorRange = this.config.colorSpace.fullRange
|
||||
? NodeAv.AVCOL_RANGE_JPEG
|
||||
: NodeAv.AVCOL_RANGE_MPEG;
|
||||
}
|
||||
|
||||
const ret = await codecContext.open2();
|
||||
NodeAv.FFmpegError.throwIfError(ret, 'Open codec context');
|
||||
|
||||
this.codecContext = codecContext;
|
||||
}
|
||||
|
||||
async decode(packet: EncodedPacket) {
|
||||
this.packet.isKeyframe = packet.type === 'key';
|
||||
this.packet.data = Buffer.from(packet.data);
|
||||
this.packet.timeBase = { num: 1, den: 1e6 };
|
||||
this.packet.pts = BigInt(packet.microsecondTimestamp);
|
||||
this.packet.dts = NodeAv.AV_NOPTS_VALUE;
|
||||
this.packet.duration = BigInt(packet.microsecondDuration);
|
||||
|
||||
const preciseTimingIndex = binarySearchLessOrEqual(
|
||||
this.preciseTimings,
|
||||
packet.microsecondTimestamp,
|
||||
x => x.microsecondTimestamp,
|
||||
);
|
||||
const existingEntry = preciseTimingIndex !== -1
|
||||
? this.preciseTimings[preciseTimingIndex]
|
||||
: null;
|
||||
if (existingEntry && existingEntry.microsecondTimestamp === packet.microsecondTimestamp) {
|
||||
if (existingEntry.timestamp !== packet.timestamp) {
|
||||
// Mapping isn't unique, can't use the timestamp
|
||||
existingEntry.timestampIsValid = false;
|
||||
}
|
||||
if (existingEntry.duration !== packet.duration) {
|
||||
// Mapping isn't unique, can't use the duration
|
||||
existingEntry.durationIsValid = false;
|
||||
}
|
||||
} else {
|
||||
this.preciseTimings.splice(preciseTimingIndex + 1, 0, {
|
||||
microsecondTimestamp: packet.microsecondTimestamp,
|
||||
timestamp: packet.timestamp,
|
||||
duration: packet.duration,
|
||||
timestampIsValid: true,
|
||||
durationIsValid: true,
|
||||
});
|
||||
|
||||
// Make sure it doesn't grow indefinitely
|
||||
if (this.preciseTimings.length > 128) {
|
||||
this.preciseTimings.shift();
|
||||
}
|
||||
}
|
||||
|
||||
const ret = await this.codecContext.sendPacket(this.packet);
|
||||
NodeAv.FFmpegError.throwIfError(ret, 'Send packet');
|
||||
|
||||
while (true) {
|
||||
const receiveRet = await this.codecContext.receiveFrame(this.frame);
|
||||
if (receiveRet === NodeAv.AVERROR_EAGAIN || receiveRet === NodeAv.AVERROR_EOF) {
|
||||
break;
|
||||
}
|
||||
|
||||
this.receiveFrame(receiveRet);
|
||||
}
|
||||
}
|
||||
|
||||
receiveFrame(ret: number) {
|
||||
NodeAv.FFmpegError.throwIfError(ret, 'Receive frame');
|
||||
|
||||
this.frame.sampleAspectRatio = new NodeAv.Rational(this.pixelAspectRatio.num, this.pixelAspectRatio.den);
|
||||
|
||||
let timestamp = Number(this.frame.pts) / 1e6;
|
||||
let duration = Number(this.frame.duration) / 1e6;
|
||||
|
||||
const preciseTimingIndex = binarySearchLessOrEqual(
|
||||
this.preciseTimings,
|
||||
Number(this.frame.pts),
|
||||
x => x.microsecondTimestamp,
|
||||
);
|
||||
const entry = preciseTimingIndex !== -1
|
||||
? this.preciseTimings[preciseTimingIndex]
|
||||
: null;
|
||||
|
||||
// If there's a relevant timing entry, refine the frame's timing data to get better accuracy than
|
||||
// microseconds
|
||||
if (entry && entry.microsecondTimestamp === Number(this.frame.pts)) {
|
||||
if (entry.timestampIsValid) {
|
||||
timestamp = entry.timestamp;
|
||||
}
|
||||
if (entry.durationIsValid) {
|
||||
duration = entry.duration;
|
||||
}
|
||||
}
|
||||
|
||||
this.onSample(new VideoSample(new NodeAvFrameVideoSampleResource(this.frame), {
|
||||
timestamp,
|
||||
duration,
|
||||
}));
|
||||
}
|
||||
|
||||
async flush() {
|
||||
// Send null packet to signal flush
|
||||
const ret = await this.codecContext.sendPacket(null);
|
||||
NodeAv.FFmpegError.throwIfError(ret, 'Flush decoder');
|
||||
|
||||
// Keep receiving frames until no more are available
|
||||
while (true) {
|
||||
const receiveRet = await this.codecContext.receiveFrame(this.frame);
|
||||
if (receiveRet === NodeAv.AVERROR_EAGAIN || receiveRet === NodeAv.AVERROR_EOF) {
|
||||
// No more frames available
|
||||
break;
|
||||
}
|
||||
|
||||
this.receiveFrame(receiveRet);
|
||||
}
|
||||
|
||||
this.codecContext.flushBuffers();
|
||||
}
|
||||
|
||||
close(): MaybePromise<void> {
|
||||
this.codecContext.freeContext();
|
||||
this.frame.free();
|
||||
this.packet.free();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
import { CustomVideoEncoder, MaybePromise, QUALITY_MEDIUM, VideoCodec, VideoSample, EncodedPacket } from 'mediabunny';
|
||||
import * as NodeAv from 'node-av';
|
||||
import {
|
||||
CODEC_TO_CODEC_ID,
|
||||
fromPixelFormat,
|
||||
getHardwareEncoderCodec,
|
||||
mapColorPrimaries,
|
||||
mapMatrixCoefficients,
|
||||
mapTransferCharacteristics,
|
||||
unmapColorPrimaries,
|
||||
unmapMatrixCoefficients,
|
||||
unmapTransferCharacteristics,
|
||||
} from './misc';
|
||||
import { NodeAvFrameVideoSampleResource } from './video-sample';
|
||||
import {
|
||||
AvcNalUnitType,
|
||||
extractAv1CodecInfoFromPacket,
|
||||
extractAvcDecoderConfigurationRecord,
|
||||
extractHevcDecoderConfigurationRecord,
|
||||
extractNalUnitTypeForAvc,
|
||||
extractNalUnitTypeForHevc,
|
||||
extractVp9CodecInfoFromPacket,
|
||||
HevcNalUnitType,
|
||||
iterateNalUnitsInAnnexB,
|
||||
NalUnitLocation,
|
||||
serializeAvcDecoderConfigurationRecord,
|
||||
serializeHevcDecoderConfigurationRecord,
|
||||
} from '../../../src/codec-data';
|
||||
import { extractVideoCodecString } from '../../../src/codec';
|
||||
import { assert, binarySearchLessOrEqual, simplifyRational, toUint8Array } from '../../../src/misc';
|
||||
|
||||
export class NodeAvVideoEncoder extends CustomVideoEncoder {
|
||||
frame!: NodeAv.Frame;
|
||||
packet!: NodeAv.Packet;
|
||||
avCodec!: NodeAv.Codec;
|
||||
codecContext: NodeAv.CodecContext | null = null;
|
||||
lastBuffer: Buffer | null = null;
|
||||
colorSpaceSet = false;
|
||||
packetEmitted = false;
|
||||
|
||||
scaler: NodeAv.SoftwareScaleContext | null = null;
|
||||
lastScalerKey: string | null = null;
|
||||
dstFrame: NodeAv.Frame | null = null;
|
||||
|
||||
// Bookkeeping to restore the original timing information
|
||||
preciseTimings: {
|
||||
microsecondTimestamp: number;
|
||||
timestamp: number;
|
||||
duration: number;
|
||||
timestampIsValid: boolean;
|
||||
durationIsValid: boolean;
|
||||
}[] = [];
|
||||
|
||||
static override supports(codec: VideoCodec, config: VideoEncoderConfig): boolean {
|
||||
return (codec === 'avc' || codec === 'hevc' || codec === 'vp8' || codec === 'vp9' || codec === 'av1')
|
||||
&& !(config.alpha === 'keep' || config.bitrateMode === 'quantizer');
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
this.frame = new NodeAv.Frame();
|
||||
this.frame.alloc();
|
||||
this.frame.timeBase = new NodeAv.Rational(1, 1e6);
|
||||
|
||||
this.packet = new NodeAv.Packet();
|
||||
this.packet.alloc();
|
||||
|
||||
const codecId = CODEC_TO_CODEC_ID[this.codec];
|
||||
|
||||
let codec: NodeAv.Codec | null = null;
|
||||
if (this.config.hardwareAcceleration === 'prefer-software') {
|
||||
codec = NodeAv.Codec.findEncoder(codecId);
|
||||
} else {
|
||||
codec = getHardwareEncoderCodec(codecId) ?? NodeAv.Codec.findEncoder(codecId);
|
||||
}
|
||||
|
||||
if (!codec) {
|
||||
throw new Error(`Unable to obtain libav codec for '${this.codec}'.`);
|
||||
}
|
||||
|
||||
this.avCodec = codec;
|
||||
|
||||
await this.createCodecContext();
|
||||
}
|
||||
|
||||
async createCodecContext() {
|
||||
assert(this.codecContext === null);
|
||||
|
||||
const codecContext = new NodeAv.CodecContext();
|
||||
codecContext.allocContext3(this.avCodec);
|
||||
|
||||
let pixelFormat = NodeAv.AV_PIX_FMT_YUV420P;
|
||||
if (this.avCodec.pixelFormats && !this.avCodec.pixelFormats.includes(NodeAv.AV_PIX_FMT_YUV420P)) {
|
||||
pixelFormat = this.avCodec.pixelFormats[0]!;
|
||||
}
|
||||
|
||||
const pixelAspectRatio = simplifyRational({
|
||||
num: (this.config.displayWidth ?? this.config.width) * this.config.height,
|
||||
den: (this.config.displayHeight ?? this.config.height) * this.config.width,
|
||||
});
|
||||
|
||||
codecContext.width = this.config.width;
|
||||
codecContext.height = this.config.height;
|
||||
codecContext.pixelFormat = pixelFormat;
|
||||
codecContext.timeBase = new NodeAv.Rational(1, 1e6);
|
||||
codecContext.gopSize = 60;
|
||||
codecContext.framerate = new NodeAv.Rational(Math.round(this.config.framerate ?? 0) || 30, 1);
|
||||
codecContext.bitRate = BigInt(
|
||||
this.config.bitrate ?? QUALITY_MEDIUM._toVideoBitrate(this.codec, this.config.width, this.config.height),
|
||||
);
|
||||
codecContext.sampleAspectRatio = new NodeAv.Rational(pixelAspectRatio.num, pixelAspectRatio.den);
|
||||
|
||||
if (this.config.bitrateMode === 'constant') {
|
||||
codecContext.rcMinRate = codecContext.bitRate;
|
||||
codecContext.rcMaxRate = codecContext.bitRate;
|
||||
}
|
||||
|
||||
const isRealtime = this.config.latencyMode === 'realtime';
|
||||
|
||||
if (this.avCodec.name === 'libx264') {
|
||||
if (isRealtime) {
|
||||
codecContext.setOption('tune', 'zerolatency');
|
||||
codecContext.setOption('preset', 'ultrafast');
|
||||
}
|
||||
} else if (this.avCodec.name === 'libx265') {
|
||||
codecContext.setOption('x265-params', 'log-level=error');
|
||||
|
||||
if (isRealtime) {
|
||||
codecContext.setOption('tune', 'zerolatency');
|
||||
codecContext.setOption('preset', 'ultrafast');
|
||||
}
|
||||
} else if (this.avCodec.name === 'libvpx') {
|
||||
if (isRealtime) {
|
||||
codecContext.setOption('deadline', 'realtime');
|
||||
codecContext.setOption('cpu-used', '8');
|
||||
} else {
|
||||
codecContext.setOption('cpu-used', '8');
|
||||
}
|
||||
} else if (this.avCodec.name === 'libvpx-vp9') {
|
||||
codecContext.setOption('deadline', 'realtime');
|
||||
|
||||
if (isRealtime) {
|
||||
codecContext.setOption('cpu-used', '8');
|
||||
} else {
|
||||
codecContext.setOption('cpu-used', '5');
|
||||
}
|
||||
} else if (this.avCodec.name === 'libsvtav1') {
|
||||
// SVTAV1 can be silenced by setting an environment variable:
|
||||
// https://superuser.com/questions/1775236/how-to-remove-svt-av1-information-from-ffmpeg-output
|
||||
process.env['SVT_LOG'] = '1';
|
||||
|
||||
if (isRealtime) {
|
||||
codecContext.setOption('preset', '12');
|
||||
}
|
||||
}
|
||||
|
||||
const ret = await codecContext.open2();
|
||||
NodeAv.FFmpegError.throwIfError(ret, 'Open codec context');
|
||||
|
||||
this.codecContext = codecContext;
|
||||
}
|
||||
|
||||
async encode(videoSample: VideoSample, options: VideoEncoderEncodeOptions): Promise<void> {
|
||||
if (this.codecContext === null) {
|
||||
await this.createCodecContext();
|
||||
assert(this.codecContext);
|
||||
}
|
||||
|
||||
const underlyingData = videoSample.getUnderlyingData();
|
||||
|
||||
if (underlyingData instanceof NodeAvFrameVideoSampleResource) {
|
||||
this.frame.ref(underlyingData.frame);
|
||||
} else {
|
||||
if (videoSample.format === null) {
|
||||
throw new Error('Cannot encode foreign VideoSample with unknown (null) format.');
|
||||
}
|
||||
|
||||
// Copy frame data from VideoSample to FFmpeg Frame
|
||||
this.frame.format = fromPixelFormat(videoSample.format);
|
||||
this.frame.width = videoSample.codedWidth;
|
||||
this.frame.height = videoSample.codedHeight;
|
||||
this.frame.pts = BigInt(videoSample.microsecondTimestamp);
|
||||
this.frame.duration = BigInt(videoSample.microsecondDuration);
|
||||
this.frame.colorPrimaries = mapColorPrimaries(videoSample.colorSpace.primaries ?? 'unknown')
|
||||
?? NodeAv.AVCOL_PRI_UNSPECIFIED;
|
||||
this.frame.colorSpace = mapMatrixCoefficients(videoSample.colorSpace.matrix ?? 'unknown')
|
||||
?? NodeAv.AVCOL_SPC_UNSPECIFIED;
|
||||
this.frame.colorTrc = mapTransferCharacteristics(videoSample.colorSpace.transfer ?? 'unknown')
|
||||
?? NodeAv.AVCOL_TRC_UNSPECIFIED;
|
||||
this.frame.colorRange = videoSample.colorSpace.fullRange === false
|
||||
? NodeAv.AVCOL_RANGE_MPEG
|
||||
: videoSample.colorSpace.fullRange === true
|
||||
? NodeAv.AVCOL_RANGE_JPEG
|
||||
: NodeAv.AVCOL_RANGE_UNSPECIFIED;
|
||||
|
||||
this.frame.keyFrame = options?.keyFrame ? 1 : 0;
|
||||
|
||||
const size = videoSample.allocationSize();
|
||||
if (!this.lastBuffer || this.lastBuffer.byteLength !== size) {
|
||||
this.lastBuffer = Buffer.from({ length: size });
|
||||
}
|
||||
|
||||
await videoSample.copyTo(this.lastBuffer);
|
||||
this.frame.fromBuffer(this.lastBuffer);
|
||||
}
|
||||
|
||||
if (!this.colorSpaceSet) {
|
||||
this.codecContext.colorPrimaries = this.frame.colorPrimaries;
|
||||
this.codecContext.colorTrc = this.frame.colorTrc;
|
||||
this.codecContext.colorSpace = this.frame.colorSpace;
|
||||
this.codecContext.colorRange = this.frame.colorRange;
|
||||
|
||||
this.colorSpaceSet = true;
|
||||
}
|
||||
|
||||
let frameToEncode = this.frame;
|
||||
|
||||
const requiresScaler
|
||||
= this.codecContext.pixelFormat !== this.frame.format
|
||||
|| this.codecContext.width !== this.frame.width
|
||||
|| this.codecContext.height !== this.frame.height;
|
||||
|
||||
if (requiresScaler) {
|
||||
if (!this.scaler) {
|
||||
this.scaler = new NodeAv.SoftwareScaleContext();
|
||||
}
|
||||
|
||||
const key = `${this.frame.width}x${this.frame.height}:${this.frame.format}`;
|
||||
const needsConfigure = key !== this.lastScalerKey;
|
||||
|
||||
if (needsConfigure) {
|
||||
this.scaler.getContext(
|
||||
this.frame.width, this.frame.height, this.frame.format as NodeAv.AVPixelFormat,
|
||||
this.codecContext.width, this.codecContext.height, this.codecContext.pixelFormat,
|
||||
NodeAv.SWS_FAST_BILINEAR,
|
||||
);
|
||||
|
||||
this.lastScalerKey = key;
|
||||
|
||||
const ret = this.scaler.initContext();
|
||||
NodeAv.FFmpegError.throwIfError(ret, 'initContext');
|
||||
}
|
||||
|
||||
if (!this.dstFrame) {
|
||||
this.dstFrame = new NodeAv.Frame();
|
||||
this.dstFrame.alloc();
|
||||
this.dstFrame.width = this.codecContext.width;
|
||||
this.dstFrame.height = this.codecContext.height;
|
||||
this.dstFrame.format = this.codecContext.pixelFormat;
|
||||
this.dstFrame.allocBuffer();
|
||||
}
|
||||
|
||||
await this.scaler.scaleFrame(this.dstFrame, this.frame);
|
||||
this.dstFrame.copyProps(this.frame);
|
||||
frameToEncode = this.dstFrame;
|
||||
}
|
||||
|
||||
const preciseTimingIndex = binarySearchLessOrEqual(
|
||||
this.preciseTimings,
|
||||
videoSample.microsecondTimestamp,
|
||||
x => x.microsecondTimestamp,
|
||||
);
|
||||
const existingEntry = preciseTimingIndex !== -1
|
||||
? this.preciseTimings[preciseTimingIndex]
|
||||
: null;
|
||||
if (existingEntry && existingEntry.microsecondTimestamp === videoSample.microsecondTimestamp) {
|
||||
if (existingEntry.timestamp !== videoSample.timestamp) {
|
||||
// Mapping isn't unique, can't use the timestamp
|
||||
existingEntry.timestampIsValid = false;
|
||||
}
|
||||
if (existingEntry.duration !== videoSample.duration) {
|
||||
// Mapping isn't unique, can't use the duration
|
||||
existingEntry.durationIsValid = false;
|
||||
}
|
||||
} else {
|
||||
this.preciseTimings.splice(preciseTimingIndex + 1, 0, {
|
||||
microsecondTimestamp: videoSample.microsecondTimestamp,
|
||||
timestamp: videoSample.timestamp,
|
||||
duration: videoSample.duration,
|
||||
timestampIsValid: true,
|
||||
durationIsValid: true,
|
||||
});
|
||||
|
||||
// Make sure it doesn't grow indefinitely
|
||||
if (this.preciseTimings.length > 128) {
|
||||
this.preciseTimings.shift();
|
||||
}
|
||||
}
|
||||
|
||||
const ret = await this.codecContext.sendFrame(frameToEncode);
|
||||
NodeAv.FFmpegError.throwIfError(ret, 'Send frame');
|
||||
|
||||
// Keep receiving packets until no more are available for this frame
|
||||
while (true) {
|
||||
const receiveRet = await this.codecContext.receivePacket(this.packet);
|
||||
if (receiveRet === NodeAv.AVERROR_EAGAIN || receiveRet === NodeAv.AVERROR_EOF) {
|
||||
break;
|
||||
}
|
||||
|
||||
this.receivePacket(ret);
|
||||
}
|
||||
}
|
||||
|
||||
receivePacket(ret: number) {
|
||||
assert(this.codecContext);
|
||||
NodeAv.FFmpegError.throwIfError(ret, 'Receive packet');
|
||||
|
||||
if (!this.packet.data) {
|
||||
return;
|
||||
}
|
||||
let packetData = toUint8Array(this.packet.data);
|
||||
|
||||
let timestamp = Number(this.packet.pts) / 1e6;
|
||||
let duration = Number(this.packet.duration) / 1e6;
|
||||
|
||||
const preciseTimingIndex = binarySearchLessOrEqual(
|
||||
this.preciseTimings,
|
||||
Number(this.packet.pts),
|
||||
x => x.microsecondTimestamp,
|
||||
);
|
||||
const entry = preciseTimingIndex !== -1
|
||||
? this.preciseTimings[preciseTimingIndex]
|
||||
: null;
|
||||
|
||||
// If there's a relevant timing entry, refine the packet's timing data to get better accuracy than
|
||||
// microseconds
|
||||
if (entry && entry.microsecondTimestamp === Number(this.packet.pts)) {
|
||||
if (entry.timestampIsValid) {
|
||||
timestamp = entry.timestamp;
|
||||
}
|
||||
if (entry.durationIsValid) {
|
||||
duration = entry.duration;
|
||||
}
|
||||
}
|
||||
|
||||
const metadata: EncodedVideoChunkMetadata = {};
|
||||
let decoderConfigCodecString: string | null = null;
|
||||
let decoderConfigDescription: Uint8Array | null = null;
|
||||
|
||||
if (this.codec === 'avc' || this.codec === 'hevc') {
|
||||
let expectsAnnexB = false;
|
||||
if (this.codec === 'avc') {
|
||||
expectsAnnexB = this.config.avc?.format === 'annexb';
|
||||
} else {
|
||||
// eslint-disable-next-line @stylistic/max-len
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
|
||||
expectsAnnexB = (this.config as any).hevc?.format === 'annexb';
|
||||
}
|
||||
|
||||
if (!this.packetEmitted) {
|
||||
let serializedRecord: Uint8Array;
|
||||
|
||||
if (this.codec === 'avc') {
|
||||
const record = extractAvcDecoderConfigurationRecord(this.packet.data);
|
||||
if (!record) {
|
||||
throw new Error('Invalid AVC data, could not extract decoder configuration record.');
|
||||
}
|
||||
|
||||
serializedRecord = serializeAvcDecoderConfigurationRecord(record);
|
||||
} else {
|
||||
const record = extractHevcDecoderConfigurationRecord(this.packet.data);
|
||||
if (!record) {
|
||||
throw new Error('Invalid HEVC data, could not extract decoder configuration record.');
|
||||
}
|
||||
|
||||
serializedRecord = serializeHevcDecoderConfigurationRecord(record);
|
||||
}
|
||||
|
||||
decoderConfigCodecString = extractVideoCodecString({
|
||||
width: this.config.width,
|
||||
height: this.config.height,
|
||||
codec: this.codec,
|
||||
codecDescription: serializedRecord,
|
||||
colorSpace: null,
|
||||
avcType: 1,
|
||||
avcCodecInfo: null,
|
||||
hevcCodecInfo: null,
|
||||
vp9CodecInfo: null,
|
||||
av1CodecInfo: null,
|
||||
});
|
||||
|
||||
if (!expectsAnnexB) {
|
||||
decoderConfigDescription = serializedRecord;
|
||||
}
|
||||
}
|
||||
|
||||
if (!expectsAnnexB) {
|
||||
const NAL_UNIT_LENGTH_SIZE = 4;
|
||||
|
||||
const nalUnits: NalUnitLocation[] = [];
|
||||
for (const loc of iterateNalUnitsInAnnexB(packetData)) {
|
||||
if (this.codec === 'avc') {
|
||||
const naluType = extractNalUnitTypeForAvc(packetData[loc.offset]!);
|
||||
|
||||
// Certain NALUs get stripped
|
||||
if (
|
||||
naluType !== AvcNalUnitType.SPS
|
||||
&& naluType !== AvcNalUnitType.PPS
|
||||
&& naluType !== AvcNalUnitType.SPS_EXT
|
||||
) {
|
||||
nalUnits.push(loc);
|
||||
}
|
||||
} else {
|
||||
const naluType = extractNalUnitTypeForHevc(packetData[loc.offset]!);
|
||||
|
||||
// Certain NALUs get stripped
|
||||
if (
|
||||
naluType !== HevcNalUnitType.SPS_NUT
|
||||
&& naluType !== HevcNalUnitType.PPS_NUT
|
||||
&& naluType !== HevcNalUnitType.VPS_NUT
|
||||
) {
|
||||
nalUnits.push(loc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let totalSize = 0;
|
||||
for (const nalUnit of nalUnits) {
|
||||
totalSize += NAL_UNIT_LENGTH_SIZE + nalUnit.length;
|
||||
}
|
||||
|
||||
const lengthPrefixedData = new Uint8Array(totalSize);
|
||||
const dataView = new DataView(lengthPrefixedData.buffer);
|
||||
let offset = 0;
|
||||
|
||||
// Write each NAL unit with its length prefix
|
||||
for (const nalUnit of nalUnits) {
|
||||
const length = nalUnit.length;
|
||||
|
||||
dataView.setUint32(offset, length, false);
|
||||
offset += 4;
|
||||
|
||||
lengthPrefixedData.set(
|
||||
packetData.subarray(nalUnit.offset, nalUnit.offset + nalUnit.length),
|
||||
offset,
|
||||
);
|
||||
offset += nalUnit.length;
|
||||
}
|
||||
|
||||
packetData = lengthPrefixedData;
|
||||
}
|
||||
} else if (this.codec === 'vp8') {
|
||||
if (!this.packetEmitted) {
|
||||
decoderConfigCodecString = extractVideoCodecString({
|
||||
width: this.config.width,
|
||||
height: this.config.height,
|
||||
codec: 'vp8',
|
||||
codecDescription: null,
|
||||
colorSpace: null,
|
||||
avcType: null,
|
||||
avcCodecInfo: null,
|
||||
hevcCodecInfo: null,
|
||||
vp9CodecInfo: null,
|
||||
av1CodecInfo: null,
|
||||
});
|
||||
}
|
||||
} else if (this.codec === 'vp9') {
|
||||
if (!this.packetEmitted) {
|
||||
const vp9CodecInfo = extractVp9CodecInfoFromPacket(packetData);
|
||||
|
||||
decoderConfigCodecString = extractVideoCodecString({
|
||||
width: this.config.width,
|
||||
height: this.config.height,
|
||||
codec: 'vp9',
|
||||
codecDescription: null,
|
||||
colorSpace: null,
|
||||
avcType: null,
|
||||
avcCodecInfo: null,
|
||||
hevcCodecInfo: null,
|
||||
vp9CodecInfo,
|
||||
av1CodecInfo: null,
|
||||
});
|
||||
}
|
||||
} else if (this.codec === 'av1') {
|
||||
if (!this.packetEmitted) {
|
||||
const av1CodecInfo = extractAv1CodecInfoFromPacket(packetData);
|
||||
|
||||
decoderConfigCodecString = extractVideoCodecString({
|
||||
width: this.config.width,
|
||||
height: this.config.height,
|
||||
codec: 'av1',
|
||||
codecDescription: null,
|
||||
colorSpace: null,
|
||||
avcType: null,
|
||||
avcCodecInfo: null,
|
||||
hevcCodecInfo: null,
|
||||
vp9CodecInfo: null,
|
||||
av1CodecInfo,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
throw new Error('Unreachable.');
|
||||
}
|
||||
|
||||
const packet = new EncodedPacket(
|
||||
packetData,
|
||||
this.packet.isKeyframe ? 'key' : 'delta',
|
||||
timestamp,
|
||||
duration,
|
||||
);
|
||||
|
||||
if (decoderConfigCodecString !== null) {
|
||||
// Create the decoder config
|
||||
metadata.decoderConfig = {
|
||||
codec: decoderConfigCodecString,
|
||||
codedWidth: this.codecContext.width,
|
||||
codedHeight: this.codecContext.height,
|
||||
displayAspectWidth: this.config.displayWidth ?? this.codecContext.width,
|
||||
displayAspectHeight: this.config.displayHeight ?? this.codecContext.height,
|
||||
description: decoderConfigDescription ?? undefined,
|
||||
colorSpace: {
|
||||
primaries:
|
||||
unmapColorPrimaries(this.codecContext.colorPrimaries) as VideoColorPrimaries,
|
||||
matrix:
|
||||
unmapMatrixCoefficients(this.codecContext.colorSpace) as VideoMatrixCoefficients,
|
||||
transfer:
|
||||
unmapTransferCharacteristics(this.codecContext.colorTrc) as VideoTransferCharacteristics,
|
||||
fullRange: this.codecContext.colorRange === NodeAv.AVCOL_RANGE_JPEG
|
||||
? true
|
||||
: this.codecContext.colorRange === NodeAv.AVCOL_RANGE_MPEG
|
||||
? false
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
this.onPacket(packet, metadata);
|
||||
this.packetEmitted = true;
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
if (this.codecContext) {
|
||||
// Send null frame to signal flush
|
||||
const ret = await this.codecContext.sendFrame(null);
|
||||
NodeAv.FFmpegError.throwIfError(ret, 'Send frame');
|
||||
|
||||
// Keep receiving packets until no more are available
|
||||
while (true) {
|
||||
const receiveRet = await this.codecContext.receivePacket(this.packet);
|
||||
if (receiveRet === NodeAv.AVERROR_EAGAIN || receiveRet === NodeAv.AVERROR_EOF) {
|
||||
break;
|
||||
}
|
||||
|
||||
this.receivePacket(receiveRet);
|
||||
}
|
||||
|
||||
this.codecContext.freeContext();
|
||||
this.codecContext = null;
|
||||
// The codec is done now and can't be reused. Any subsequence encode call will first need to recreate a
|
||||
// codec context.
|
||||
}
|
||||
|
||||
this.packetEmitted = false;
|
||||
this.colorSpaceSet = false;
|
||||
}
|
||||
|
||||
close(): MaybePromise<void> {
|
||||
this.codecContext?.freeContext();
|
||||
this.frame.free();
|
||||
this.packet.free();
|
||||
this.scaler?.freeContext();
|
||||
this.dstFrame?.free();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
import assert from 'assert';
|
||||
import { Rectangle, VideoSamplePixelFormat } from 'mediabunny';
|
||||
import { VideoSampleColorSpace } from 'mediabunny';
|
||||
import { VideoSampleResource } from 'mediabunny';
|
||||
import * as NodeAv from 'node-av';
|
||||
import { toUint8Array } from '../../../src/misc';
|
||||
import { getPlaneConfigs } from '../../../src/sample';
|
||||
import {
|
||||
toPixelFormat,
|
||||
unmapColorPrimaries,
|
||||
unmapTransferCharacteristics,
|
||||
unmapMatrixCoefficients,
|
||||
fromPixelFormat,
|
||||
mapColorPrimaries,
|
||||
mapTransferCharacteristics,
|
||||
mapMatrixCoefficients,
|
||||
} from './misc';
|
||||
|
||||
export class NodeAvFrameVideoSampleResource extends VideoSampleResource {
|
||||
frame: NodeAv.Frame;
|
||||
|
||||
constructor(frame: NodeAv.Frame) {
|
||||
super();
|
||||
|
||||
const clone = frame.clone();
|
||||
if (!clone) {
|
||||
throw new Error('Allocation failure during frame clone.');
|
||||
}
|
||||
|
||||
this.frame = clone;
|
||||
}
|
||||
|
||||
getFormat(): VideoSamplePixelFormat | null {
|
||||
return toPixelFormat(this.frame.format as NodeAv.AVPixelFormat);
|
||||
}
|
||||
|
||||
getCodedWidth(): number {
|
||||
return this.frame.width;
|
||||
}
|
||||
|
||||
getCodedHeight(): number {
|
||||
return this.frame.height;
|
||||
}
|
||||
|
||||
getSquarePixelWidth(): number {
|
||||
if (this.frame.sampleAspectRatio.num > this.frame.sampleAspectRatio.den) {
|
||||
return Math.round(this.frame.width * this.frame.sampleAspectRatio.num / this.frame.sampleAspectRatio.den);
|
||||
} else {
|
||||
return this.frame.width;
|
||||
}
|
||||
}
|
||||
|
||||
getSquarePixelHeight(): number {
|
||||
if (this.frame.sampleAspectRatio.num > this.frame.sampleAspectRatio.den) {
|
||||
return this.frame.height;
|
||||
} else {
|
||||
return Math.round(this.frame.height * this.frame.sampleAspectRatio.den / this.frame.sampleAspectRatio.num);
|
||||
}
|
||||
}
|
||||
|
||||
getColorSpace(): VideoSampleColorSpace {
|
||||
return new VideoSampleColorSpace({
|
||||
primaries: unmapColorPrimaries(this.frame.colorPrimaries) as VideoColorPrimaries | null,
|
||||
transfer: unmapTransferCharacteristics(this.frame.colorTrc) as VideoTransferCharacteristics | null,
|
||||
matrix: unmapMatrixCoefficients(this.frame.colorSpace) as VideoMatrixCoefficients | null,
|
||||
fullRange: this.frame.colorRange === NodeAv.AVCOL_RANGE_JPEG,
|
||||
});
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.frame.free();
|
||||
}
|
||||
|
||||
allocationSize(options: VideoFrameCopyToOptions): number {
|
||||
// 3. Let combinedLayout be the result of running the Parse VideoFrameCopyToOptions algorithm with options.
|
||||
// 4. If combinedLayout is an exception, throw combinedLayout.
|
||||
const combinedLayout = ParseVideoFrameCopyToOptions(this, options);
|
||||
|
||||
// 5. Return combinedLayout's allocationSize.
|
||||
return combinedLayout.allocationSize;
|
||||
}
|
||||
|
||||
async copyTo(
|
||||
destination: AllowSharedBufferSource,
|
||||
options: VideoFrameCopyToOptions,
|
||||
): Promise<PlaneLayout[]> {
|
||||
const format = this.getFormat();
|
||||
assert(format !== null);
|
||||
|
||||
// 3. Let combinedLayout be the result of running the Parse VideoFrameCopyToOptions algorithm with options.
|
||||
const combinedLayout = ParseVideoFrameCopyToOptions(this, options);
|
||||
|
||||
// 4. If destination.byteLength is less than combinedLayout’s allocationSize, return a promise rejected with
|
||||
// // eslint-disable-next-line @stylistic/max-len
|
||||
const destBytes = toUint8Array(destination);
|
||||
if (destBytes.byteLength < combinedLayout.allocationSize) {
|
||||
throw new TypeError(
|
||||
`Destination buffer too small. Required: ${combinedLayout.allocationSize},`
|
||||
+ ` Available: ${destBytes.byteLength}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 5. If options.format is equal to one of RGBA, RGBX, BGRA, BGRX then:
|
||||
if (options.format && ['RGBA', 'RGBX', 'BGRA', 'BGRX'].includes(options.format) && options.format !== format) {
|
||||
// Let newOptions be the result of running the Clone Configuration algorithm with options.
|
||||
// Assign undefined to newOptions.format.
|
||||
const newOptions = { ...options };
|
||||
delete newOptions.format;
|
||||
|
||||
// Let rgbFrame be the result of running the Convert to RGB frame algorithm with this, options.format,
|
||||
// and options.colorSpace.
|
||||
const rgbFrame = await ConvertToRGBFrame(this, options.format, options.colorSpace);
|
||||
|
||||
// Return the result of calling copyTo() on rgbFrame with destination and newOptions.
|
||||
try {
|
||||
const result = await rgbFrame.copyTo(destination, newOptions);
|
||||
rgbFrame.close();
|
||||
return result;
|
||||
} catch (e) {
|
||||
rgbFrame.close();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Let p be a new Promise. (Implicit)
|
||||
// 7. Let copyStepsQueue be the result of starting a new parallel queue. (Implicit)
|
||||
// 8. Let planeLayouts be a new list.
|
||||
const planeLayouts: PlaneLayout[] = [];
|
||||
|
||||
// Enqueue the following steps to copyStepsQueue: (fuck the queuing part)
|
||||
|
||||
// Let resource be the media resource referenced by [[resource reference]].
|
||||
// (this.data)
|
||||
|
||||
// Let numPlanes be the number of planes as defined by [[format]].
|
||||
const planes = getPlaneConfigs(format);
|
||||
const numPlanes = planes.length;
|
||||
|
||||
// Let planeIndex be 0.
|
||||
// While planeIndex is less than combinedLayout’s numPlanes:
|
||||
for (let planeIndex = 0; planeIndex < numPlanes; planeIndex++) {
|
||||
const computedLayout = combinedLayout.computedLayouts[planeIndex]!;
|
||||
|
||||
// Let sourceStride be the stride of the plane in resource as identified by planeIndex.
|
||||
let sourceStride = 0;
|
||||
let sourceStartOffset = 0;
|
||||
|
||||
sourceStride = this.frame.linesize[planeIndex]!;
|
||||
const planeBuffer = this.frame.data![planeIndex]!;
|
||||
if (!planeBuffer) {
|
||||
throw new Error(`Missing data for plane ${planeIndex}`);
|
||||
}
|
||||
const sourceData = new Uint8Array(planeBuffer.buffer, planeBuffer.byteOffset, planeBuffer.byteLength);
|
||||
sourceStartOffset = 0;
|
||||
|
||||
// Let sourceOffset be the product of multiplying computedLayout’s sourceTop by sourceStride
|
||||
let sourceOffset = computedLayout.sourceTop * sourceStride;
|
||||
|
||||
// Add computedLayout’s sourceLeftBytes to sourceOffset.
|
||||
sourceOffset += computedLayout.sourceLeftBytes;
|
||||
|
||||
// Adjust for the base offset of the plane in the source buffer
|
||||
sourceOffset += sourceStartOffset;
|
||||
|
||||
// Let destinationOffset be computedLayout’s destinationOffset.
|
||||
let destinationOffset = computedLayout.destinationOffset;
|
||||
|
||||
// Let rowBytes be computedLayout’s sourceWidthBytes.
|
||||
const rowBytes = computedLayout.sourceWidthBytes;
|
||||
|
||||
// Let layout be a new PlaneLayout, with offset set to destinationOffset and stride set to rowBytes.
|
||||
// This is a spec error actually (https://github.com/w3c/webcodecs/issues/918)
|
||||
const layout: PlaneLayout = {
|
||||
offset: destinationOffset,
|
||||
stride: computedLayout.destinationStride,
|
||||
};
|
||||
|
||||
// Let row be 0.
|
||||
// While row is less than computedLayout’s sourceHeight:
|
||||
for (let row = 0; row < computedLayout.sourceHeight; row++) {
|
||||
// Copy rowBytes bytes from resource starting at sourceOffset to destination starting
|
||||
// at destinationOffset.
|
||||
if (sourceOffset + rowBytes > sourceData.byteLength) {
|
||||
throw new Error(`Source buffer OOB read`);
|
||||
}
|
||||
if (destinationOffset + rowBytes > destBytes.byteLength) {
|
||||
throw new Error(`Destination buffer OOB write`);
|
||||
}
|
||||
|
||||
const srcSub = sourceData.subarray(sourceOffset, sourceOffset + rowBytes);
|
||||
destBytes.set(srcSub, destinationOffset);
|
||||
|
||||
// Increment sourceOffset by sourceStride.
|
||||
sourceOffset += sourceStride;
|
||||
|
||||
// Increment destinationOffset by computedLayout’s destinationStride.
|
||||
destinationOffset += computedLayout.destinationStride;
|
||||
}
|
||||
|
||||
// Append layout to planeLayouts.
|
||||
planeLayouts.push(layout);
|
||||
}
|
||||
|
||||
// Queue a task to resolve p with planeLayouts.
|
||||
return planeLayouts;
|
||||
}
|
||||
}
|
||||
|
||||
type CombinedBufferLayout = {
|
||||
allocationSize: number;
|
||||
computedLayouts: ComputedPlaneLayout[];
|
||||
};
|
||||
|
||||
type ComputedPlaneLayout = {
|
||||
destinationOffset: number;
|
||||
destinationStride: number;
|
||||
sourceTop: number;
|
||||
sourceHeight: number;
|
||||
sourceLeftBytes: number;
|
||||
sourceWidthBytes: number;
|
||||
};
|
||||
|
||||
// Taken from the WebCodecs spec
|
||||
const ParseVideoFrameCopyToOptions = (
|
||||
frame: VideoSampleResource,
|
||||
options: VideoFrameCopyToOptions,
|
||||
): CombinedBufferLayout => {
|
||||
// 1. Let defaultRect be the result of performing the getter steps for visibleRect.
|
||||
const defaultRect: Rectangle = {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: frame.getCodedWidth(),
|
||||
height: frame.getCodedHeight(),
|
||||
};
|
||||
|
||||
// 2. Let overrideRect be undefined.
|
||||
// 3. If options.rect exists, assign the value of options.rect to overrideRect.
|
||||
const overrideRect = options.rect;
|
||||
|
||||
// 4. Let parsedRect be the result of running the Parse Visible Rect algorithm...
|
||||
const parsedRect = ParseVisibleRect(
|
||||
defaultRect,
|
||||
overrideRect,
|
||||
frame.getCodedWidth(),
|
||||
frame.getCodedHeight(),
|
||||
frame.getFormat(),
|
||||
);
|
||||
|
||||
// 5. If parsedRect is an exception, return parsedRect. (Handled by throw)
|
||||
|
||||
// 6. Let optLayout be undefined.
|
||||
// 7. If options.layout exists, assign its value to optLayout.
|
||||
const optLayout = options.layout;
|
||||
|
||||
// 8. Let format be undefined.
|
||||
let format: VideoSamplePixelFormat | undefined;
|
||||
|
||||
// 9. If options.format does not exist, assign [[format]] to format.
|
||||
if (!options.format || options.format === frame.getFormat()) {
|
||||
format = frame.getFormat()!;
|
||||
} else if (['RGBA', 'RGBX', 'BGRA', 'BGRX'].includes(options.format)) {
|
||||
// 10. Otherwise, if options.format is equal to one of RGBA, RGBX, BGRA, BGRX, then assign options.format
|
||||
// to format...
|
||||
format = options.format;
|
||||
} else {
|
||||
throw new Error('NotSupportedError: Invalid destination format');
|
||||
}
|
||||
|
||||
// 11. Let combinedLayout be the result of running the Compute Layout and Allocation Size algorithm...
|
||||
return ComputeLayoutAndAllocationSize(parsedRect, format, optLayout);
|
||||
};
|
||||
|
||||
// Taken from the WebCodecs spec
|
||||
const ParseVisibleRect = (
|
||||
defaultRect: DOMRectInit,
|
||||
overrideRect: DOMRectInit | undefined,
|
||||
codedWidth: number,
|
||||
codedHeight: number,
|
||||
format: VideoSamplePixelFormat | null,
|
||||
): DOMRectInit => {
|
||||
// 1. Let sourceRect be defaultRect
|
||||
const sourceRect = { ...defaultRect };
|
||||
|
||||
// 2. If overrideRect is not undefined:
|
||||
if (overrideRect !== undefined) {
|
||||
// If either of overrideRect.width or height is 0, return a TypeError.
|
||||
if (overrideRect.width === 0 || overrideRect.height === 0) {
|
||||
throw new TypeError('visibleRect dimensions cannot be zero');
|
||||
}
|
||||
// If the sum of overrideRect.x and overrideRect.width is greater than codedWidth, return a TypeError.
|
||||
if ((overrideRect.x || 0) + (overrideRect.width || 0) > codedWidth) {
|
||||
throw new TypeError('visibleRect exceeds codedWidth');
|
||||
}
|
||||
// If the sum of overrideRect.y and overrideRect.height is greater than codedHeight, return a TypeError.
|
||||
if ((overrideRect.y || 0) + (overrideRect.height || 0) > codedHeight) {
|
||||
throw new TypeError('visibleRect exceeds codedHeight');
|
||||
}
|
||||
// Assign overrideRect to sourceRect.
|
||||
sourceRect.x = overrideRect.x || 0;
|
||||
sourceRect.y = overrideRect.y || 0;
|
||||
sourceRect.width = overrideRect.width || 0;
|
||||
sourceRect.height = overrideRect.height || 0;
|
||||
}
|
||||
|
||||
// 3. Let validAlignment be the result of running the Verify Rect Offset Alignment algorithm.
|
||||
const validAlignment = VerifyRectOffsetAlignment(format, sourceRect);
|
||||
|
||||
// 4. If validAlignment is false, throw a TypeError.
|
||||
if (!validAlignment) {
|
||||
throw new TypeError('visibleRect alignment is invalid for the format');
|
||||
}
|
||||
|
||||
// 5. Return sourceRect.
|
||||
return sourceRect;
|
||||
};
|
||||
|
||||
// Taken from the WebCodecs spec
|
||||
const VerifyRectOffsetAlignment = (format: VideoSamplePixelFormat | null, rect: DOMRectInit): boolean => {
|
||||
// 1. If format is null, return true.
|
||||
if (format === null) return true;
|
||||
|
||||
const planes = getPlaneConfigs(format);
|
||||
|
||||
// 2. Let planeIndex be 0.
|
||||
// 3. Let numPlanes be the number of planes as defined by format.
|
||||
// 4. While planeIndex is less than numPlanes:
|
||||
for (let planeIndex = 0; planeIndex < planes.length; planeIndex++) {
|
||||
const plane = planes[planeIndex]!;
|
||||
const sampleWidth = plane.widthDivisor;
|
||||
const sampleHeight = plane.heightDivisor;
|
||||
|
||||
// If rect.x is not a multiple of sampleWidth, return false.
|
||||
if ((rect.x || 0) % sampleWidth !== 0) return false;
|
||||
// If rect.y is not a multiple of sampleHeight, return false.
|
||||
if ((rect.y || 0) % sampleHeight !== 0) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Taken from the WebCodecs spec
|
||||
const ComputeLayoutAndAllocationSize = (
|
||||
parsedRect: DOMRectInit,
|
||||
format: VideoSamplePixelFormat,
|
||||
layout?: PlaneLayout[],
|
||||
): CombinedBufferLayout => {
|
||||
const planes = getPlaneConfigs(format);
|
||||
|
||||
// 1. Let numPlanes be the number of planes as defined by format.
|
||||
const numPlanes = planes.length;
|
||||
|
||||
// 2. If layout is not undefined and its length does not equal numPlanes, throw a TypeError.
|
||||
if (layout !== undefined && layout.length !== numPlanes) {
|
||||
throw new TypeError(`Layout must have ${numPlanes} planes`);
|
||||
}
|
||||
|
||||
// 3. Let minAllocationSize be 0.
|
||||
let minAllocationSize = 0;
|
||||
|
||||
// 4. Let computedLayouts be a new list.
|
||||
const computedLayouts: ComputedPlaneLayout[] = [];
|
||||
|
||||
// 5. Let endOffsets be a new list.
|
||||
const endOffsets: number[] = [];
|
||||
|
||||
// 6. Let planeIndex be 0.
|
||||
// 7. While planeIndex < numPlanes:
|
||||
for (let planeIndex = 0; planeIndex < numPlanes; planeIndex++) {
|
||||
const plane = planes[planeIndex]!;
|
||||
const sampleBytes = plane.sampleBytes;
|
||||
const sampleWidth = plane.widthDivisor;
|
||||
const sampleHeight = plane.heightDivisor;
|
||||
|
||||
// Let computedLayout be a new computed plane layout.
|
||||
const computedLayout: ComputedPlaneLayout = {
|
||||
destinationOffset: 0,
|
||||
destinationStride: 0,
|
||||
sourceTop: 0,
|
||||
sourceHeight: 0,
|
||||
sourceLeftBytes: 0,
|
||||
sourceWidthBytes: 0,
|
||||
};
|
||||
|
||||
// Set computedLayout’s sourceTop...
|
||||
computedLayout.sourceTop = Math.ceil(Math.trunc(parsedRect.y || 0) / sampleHeight);
|
||||
// Set computedLayout’s sourceHeight...
|
||||
computedLayout.sourceHeight = Math.ceil(Math.trunc(parsedRect.height || 0) / sampleHeight);
|
||||
// Set computedLayout’s sourceLeftBytes...
|
||||
computedLayout.sourceLeftBytes = Math.floor(Math.trunc(parsedRect.x || 0) / sampleWidth) * sampleBytes;
|
||||
// Set computedLayout’s sourceWidthBytes...
|
||||
computedLayout.sourceWidthBytes = Math.floor(Math.trunc(parsedRect.width || 0) / sampleWidth) * sampleBytes;
|
||||
|
||||
// If layout is not undefined:
|
||||
if (layout !== undefined) {
|
||||
const planeLayout = layout[planeIndex]!;
|
||||
// If planeLayout.stride is less than computedLayout’s sourceWidthBytes, return a TypeError.
|
||||
if (planeLayout.stride < computedLayout.sourceWidthBytes) {
|
||||
throw new TypeError(`Stride for plane ${planeIndex} is too small`);
|
||||
}
|
||||
// Assign planeLayout.offset to computedLayout’s destinationOffset.
|
||||
computedLayout.destinationOffset = planeLayout.offset;
|
||||
// Assign planeLayout.stride to computedLayout’s destinationStride.
|
||||
computedLayout.destinationStride = planeLayout.stride;
|
||||
} else {
|
||||
// Otherwise:
|
||||
// Assign minAllocationSize to computedLayout’s destinationOffset.
|
||||
computedLayout.destinationOffset = minAllocationSize;
|
||||
// Assign computedLayout’s sourceWidthBytes to computedLayout’s destinationStride.
|
||||
computedLayout.destinationStride = computedLayout.sourceWidthBytes;
|
||||
}
|
||||
|
||||
// Let planeSize be the product of multiplying computedLayout’s destinationStride and sourceHeight.
|
||||
const planeSize = computedLayout.destinationStride * computedLayout.sourceHeight;
|
||||
|
||||
// Let planeEnd be the sum of planeSize and computedLayout’s destinationOffset.
|
||||
const planeEnd = planeSize + computedLayout.destinationOffset;
|
||||
|
||||
// If planeSize or planeEnd is greater than maximum range of unsigned long, return a TypeError.
|
||||
if (planeEnd > 4294967295) throw new TypeError('Allocation size exceeds limit');
|
||||
|
||||
// Append planeEnd to endOffsets.
|
||||
endOffsets.push(planeEnd);
|
||||
|
||||
// Assign the maximum of minAllocationSize and planeEnd to minAllocationSize.
|
||||
minAllocationSize = Math.max(minAllocationSize, planeEnd);
|
||||
|
||||
// Check for overlap
|
||||
for (let earlierPlaneIndex = 0; earlierPlaneIndex < planeIndex; earlierPlaneIndex++) {
|
||||
const earlierLayout = computedLayouts[earlierPlaneIndex]!;
|
||||
// If plane A ends before plane B starts, they do not overlap.
|
||||
if (
|
||||
endOffsets[planeIndex]! <= earlierLayout.destinationOffset
|
||||
|| endOffsets[earlierPlaneIndex]! <= computedLayout.destinationOffset
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
throw new TypeError('Planes overlap');
|
||||
}
|
||||
|
||||
computedLayouts.push(computedLayout);
|
||||
}
|
||||
|
||||
// 12. Return combinedLayout.
|
||||
return {
|
||||
allocationSize: minAllocationSize,
|
||||
computedLayouts: computedLayouts,
|
||||
};
|
||||
};
|
||||
|
||||
// Taken from the WebCodecs spec
|
||||
const ConvertToRGBFrame = async (
|
||||
frame: NodeAvFrameVideoSampleResource,
|
||||
format: VideoPixelFormat,
|
||||
colorSpace?: PredefinedColorSpace,
|
||||
): Promise<NodeAvFrameVideoSampleResource> => {
|
||||
// 1. Let convertedFrame be a new VideoFrame...
|
||||
// (We construct it at the end, but we prepare the resource here)
|
||||
|
||||
const width = frame.getCodedWidth();
|
||||
const height = frame.getCodedHeight();
|
||||
|
||||
const scaler = new NodeAv.SoftwareScaleContext();
|
||||
const srcFmt = fromPixelFormat(frame.getFormat()!);
|
||||
const dstFmt = fromPixelFormat(format);
|
||||
|
||||
// Configure scaling: Source -> Destination (Visible Size)
|
||||
scaler.getContext(
|
||||
width, height, srcFmt,
|
||||
width, height, dstFmt,
|
||||
NodeAv.SWS_BILINEAR,
|
||||
);
|
||||
|
||||
// Allocate destination frame
|
||||
const dstFrame = new NodeAv.Frame();
|
||||
dstFrame.width = width;
|
||||
dstFrame.height = height;
|
||||
dstFrame.format = dstFmt;
|
||||
dstFrame.alloc();
|
||||
dstFrame.allocBuffer();
|
||||
|
||||
// Apply destination color space settings to dstFrame
|
||||
// If colorSpace is not provided, srgb is used
|
||||
const targetColorSpace = colorSpace || 'srgb';
|
||||
if (targetColorSpace === 'srgb') {
|
||||
dstFrame.colorPrimaries = NodeAv.AVCOL_PRI_BT709;
|
||||
dstFrame.colorTrc = NodeAv.AVCOL_TRC_IEC61966_2_1;
|
||||
dstFrame.colorSpace = NodeAv.AVCOL_SPC_RGB;
|
||||
dstFrame.colorRange = NodeAv.AVCOL_RANGE_JPEG;
|
||||
} else if (targetColorSpace === 'display-p3') {
|
||||
dstFrame.colorPrimaries = NodeAv.AVCOL_PRI_SMPTE432;
|
||||
dstFrame.colorTrc = NodeAv.AVCOL_TRC_IEC61966_2_1;
|
||||
dstFrame.colorSpace = NodeAv.AVCOL_SPC_RGB;
|
||||
dstFrame.colorRange = NodeAv.AVCOL_RANGE_JPEG;
|
||||
}
|
||||
|
||||
const srcFrame = frame.frame;
|
||||
|
||||
// Apply source color space settings to srcFrame (if known)
|
||||
// This ensures the scaler knows the input color space for correct conversion
|
||||
const frameColorSpace = frame.getColorSpace();
|
||||
if (colorSpace) {
|
||||
srcFrame.colorPrimaries = mapColorPrimaries(frameColorSpace.primaries ?? 'unknown')
|
||||
?? NodeAv.AVCOL_PRI_UNSPECIFIED;
|
||||
srcFrame.colorTrc = mapTransferCharacteristics(frameColorSpace.transfer ?? 'unknown')
|
||||
?? NodeAv.AVCOL_TRC_UNSPECIFIED;
|
||||
srcFrame.colorSpace = mapMatrixCoefficients(frameColorSpace.matrix ?? 'unknown')
|
||||
?? NodeAv.AVCOL_SPC_UNSPECIFIED;
|
||||
srcFrame.colorRange = frameColorSpace.fullRange
|
||||
? NodeAv.AVCOL_RANGE_JPEG
|
||||
: NodeAv.AVCOL_RANGE_MPEG;
|
||||
}
|
||||
|
||||
try {
|
||||
await scaler.scaleFrame(dstFrame, srcFrame);
|
||||
} finally {
|
||||
scaler.freeContext();
|
||||
}
|
||||
|
||||
return new NodeAvFrameVideoSampleResource(dstFrame);
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"outDir": "./dist/modules",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"stripInternal": true,
|
||||
"noEmit": false,
|
||||
"moduleResolution": "nodenext",
|
||||
"module": "nodenext",
|
||||
"allowJs": true,
|
||||
"paths": {
|
||||
"mediabunny": ["../../src/index.ts"],
|
||||
},
|
||||
},
|
||||
"include": [
|
||||
"./src/**/*",
|
||||
"./build/**/*",
|
||||
],
|
||||
"references": [
|
||||
{ "path": "../../src" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json",
|
||||
"extends": ["../../tsdoc.json"]
|
||||
}
|
||||
Reference in New Issue
Block a user