feat: publish av-tools 0.1.0

This commit is contained in:
2026-07-24 14:58:03 +02:00
commit 9e27b914ea
254 changed files with 63270 additions and 0 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
LICENSES/native/** whitespace=-trailing-space,-space-before-tab

29
.gitignore vendored Normal file
View File

@@ -0,0 +1,29 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
release
test-results
playwright-report
coverage
public/vendor/ffmpeg
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
@add-ideas:registry=https://git.add-ideas.de/api/packages/zemion/npm/

10
.prettierignore Normal file
View File

@@ -0,0 +1,10 @@
node_modules
coverage
dist
release
test-results
playwright-report
public/vendor/ffmpeg
public/toolbox-app.json
LICENSES/native
*.zip

6
.prettierrc.json Normal file
View File

@@ -0,0 +1,6 @@
{
"singleQuote": true,
"semi": true,
"trailingComma": "es5",
"printWidth": 80
}

53
CHANGELOG.md Normal file
View File

@@ -0,0 +1,53 @@
# Changelog
All notable changes to av-tools are documented here. The project follows
semantic versioning once a first release is legally publishable.
## 0.1.0 — 2026-07-24
- Scaffold a local-first React/Vite audio and video toolbox application.
- Integrate Toolbox SDK 0.2.2 for standalone and Toolbox-context operation.
- Self-host exact ffmpeg.wasm single-thread and multithread core 0.12.10
assets; add capability detection and single-thread fallback.
- Add typed media, project, command-plan and preset foundations for conversion
and light sequential editing.
- Wire the Edit workspace to fast and accurate trim plus compatibility-checked
fast concat and preset-backed normalized concat with an explicit
missing-audio policy.
- Add a bounded in-memory application queue for eight scheduled operations,
with one FFmpeg execution at a time, progress, cleanup, cancellation and
recovery behavior.
- Bound long-session native heap retention by refreshing the idle
ffmpeg.wasm core before its seventeenth media execution, before a fourth
contact sheet, and after a bare core abort, without discarding queued work or
completed results.
- Add configurable bounded preview proxies and reuse matching derivatives from
the local cache.
- Add storage, waveform, inspection, timeline, project, result verification
and export workflows.
- Add deterministic generated AV fixtures, deployment examples, security and
architecture documentation, deterministic release packaging and a temporary
Toolbox Portal assembly smoke.
- Record a 2026-07-24 local Vitest result of 303 passing tests and one
conditional native subtitle-filter skip.
- Exercise the real pinned ST core in Chromium across a broad conversion,
structural-editing, analysis, image, metadata, chapter, subtitle, queue and
recovery matrix; verify a real MT H.264/AAC conversion and MT-to-ST fallback.
- Record a pinned ST/MT Chromium performance baseline for 2-second 160×90 and
6-second 640×360 H.264/AAC inputs. This is a one-run workstation baseline,
not a universal performance claim.
- Record the pinned-core runtime inventory: 341 demuxers, 173 muxers, 515
decoders, 204 encoders and 465 filters (1,698 entries).
- Verify the checksums and native probes for all 15 generated CC0 fixtures.
### Release assets
- Identify the exact reviewed GPL-3.0-or-later source with the annotated
`v0.1.0` tag.
- Distribute the reviewed Corresponding Source and third-party notices for the
GPL-enabled FFmpeg cores and linked codecs beside the binary. The x264 and
LAME branch inputs are pinned to immutable commits in `SOURCE.md`.
- Browser feature/format claims beyond the recorded Chromium ST matrix and
narrow MT H.264/AAC evidence require cross-browser and exhaustive MT runs.
- Mobile and cross-browser performance is unrecorded; the Chromium workstation
baseline needs cold-cache repetitions and statistical ranges.

232
LICENSE Normal file
View File

@@ -0,0 +1,232 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright © 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for software and other kinds of works.
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and modification follow.
TERMS AND CONDITIONS
0. Definitions.
“This License” refers to version 3 of the GNU General Public License.
“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.
To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.
A “covered work” means either the unmodified Program or a work based on the Program.
To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
1. Source Code.
The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
The Corresponding Source for a work in source code form is that same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
7. Additional Terms.
“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
11. Patents.
A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.
You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see <https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read <https://www.gnu.org/philosophy/why-not-lgpl.html>.

121
LICENSES/CC0-1.0.txt Normal file
View File

@@ -0,0 +1,121 @@
Creative Commons Legal Code
CC0 1.0 Universal
CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
HEREUNDER.
Statement of Purpose
The laws of most jurisdictions throughout the world automatically confer
exclusive Copyright and Related Rights (defined below) upon the creator
and subsequent owner(s) (each and all, an "owner") of an original work of
authorship and/or a database (each, a "Work").
Certain owners wish to permanently relinquish those rights to a Work for
the purpose of contributing to a commons of creative, cultural and
scientific works ("Commons") that the public can reliably and without fear
of later claims of infringement build upon, modify, incorporate in other
works, reuse and redistribute as freely as possible in any form whatsoever
and for any purposes, including without limitation commercial purposes.
These owners may contribute to the Commons to promote the ideal of a free
culture and the further production of creative, cultural and scientific
works, or to gain reputation or greater distribution for their Work in
part through the use and efforts of others.
For these and/or other purposes and motivations, and without any
expectation of additional consideration or compensation, the person
associating CC0 with a Work (the "Affirmer"), to the extent that he or she
is an owner of Copyright and Related Rights in the Work, voluntarily
elects to apply CC0 to the Work and publicly distribute the Work under its
terms, with knowledge of his or her Copyright and Related Rights in the
Work and the meaning and intended legal effect of CC0 on those rights.
1. Copyright and Related Rights. A Work made available under CC0 may be
protected by copyright and related or neighboring rights ("Copyright and
Related Rights"). Copyright and Related Rights include, but are not
limited to, the following:
i. the right to reproduce, adapt, distribute, perform, display,
communicate, and translate a Work;
ii. moral rights retained by the original author(s) and/or performer(s);
iii. publicity and privacy rights pertaining to a person's image or
likeness depicted in a Work;
iv. rights protecting against unfair competition in regards to a Work,
subject to the limitations in paragraph 4(a), below;
v. rights protecting the extraction, dissemination, use and reuse of data
in a Work;
vi. database rights (such as those arising under Directive 96/9/EC of the
European Parliament and of the Council of 11 March 1996 on the legal
protection of databases, and under any national implementation
thereof, including any amended or successor version of such
directive); and
vii. other similar, equivalent or corresponding rights throughout the
world based on applicable law or treaty, and any national
implementations thereof.
2. Waiver. To the greatest extent permitted by, but not in contravention
of, applicable law, Affirmer hereby overtly, fully, permanently,
irrevocably and unconditionally waives, abandons, and surrenders all of
Affirmer's Copyright and Related Rights and associated claims and causes
of action, whether now known or unknown (including existing as well as
future claims and causes of action), in the Work (i) in all territories
worldwide, (ii) for the maximum duration provided by applicable law or
treaty (including future time extensions), (iii) in any current or future
medium and for any number of copies, and (iv) for any purpose whatsoever,
including without limitation commercial, advertising or promotional
purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
member of the public at large and to the detriment of Affirmer's heirs and
successors, fully intending that such Waiver shall not be subject to
revocation, rescission, cancellation, termination, or any other legal or
equitable action to disrupt the quiet enjoyment of the Work by the public
as contemplated by Affirmer's express Statement of Purpose.
3. Public License Fallback. Should any part of the Waiver for any reason
be judged legally invalid or ineffective under applicable law, then the
Waiver shall be preserved to the maximum extent permitted taking into
account Affirmer's express Statement of Purpose. In addition, to the
extent the Waiver is so judged Affirmer hereby grants to each affected
person a royalty-free, non transferable, non sublicensable, non exclusive,
irrevocable and unconditional license to exercise Affirmer's Copyright and
Related Rights in the Work (i) in all territories worldwide, (ii) for the
maximum duration provided by applicable law or treaty (including future
time extensions), (iii) in any current or future medium and for any number
of copies, and (iv) for any purpose whatsoever, including without
limitation commercial, advertising or promotional purposes (the
"License"). The License shall be deemed effective as of the date CC0 was
applied by Affirmer to the Work. Should any part of the License for any
reason be judged legally invalid or ineffective under applicable law, such
partial invalidity or ineffectiveness shall not invalidate the remainder
of the License, and in such case Affirmer hereby affirms that he or she
will not (i) exercise any of his or her remaining Copyright and Related
Rights in the Work or (ii) assert any associated claims and causes of
action with respect to the Work, in either case contrary to Affirmer's
express Statement of Purpose.
4. Limitations and Disclaimers.
a. No trademark or patent rights held by Affirmer are waived, abandoned,
surrendered, licensed or otherwise affected by this document.
b. Affirmer offers the Work as-is and makes no representations or
warranties of any kind concerning the Work, express, implied,
statutory or otherwise, including without limitation warranties of
title, merchantability, fitness for a particular purpose, non
infringement, or the absence of latent or other defects, accuracy, or
the present or absence of errors, whether or not discoverable, all to
the greatest extent permissible under applicable law.
c. Affirmer disclaims responsibility for clearing rights of other persons
that may apply to the Work or any use thereof, including without
limitation any person's Copyright and Related Rights in the Work.
Further, Affirmer disclaims responsibility for obtaining any necessary
consents, permissions or other rights required for any use of the
Work.
d. Affirmer understands and acknowledges that Creative Commons is not a
party to this document and has no duty or obligation with respect to
this CC0 or use of the Work.

View File

@@ -0,0 +1,339 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it in
new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if
you distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying the
Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License may
add an explicit geographical distribution limitation excluding those
countries, so that distribution is permitted only in or among countries
not thus excluded. In such case, this License incorporates the
limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least the
"copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Ty Coon>, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General Public
License instead of this License.

32
LICENSES/README.md Normal file
View File

@@ -0,0 +1,32 @@
# Licence files
This directory records terms for third-party components and generated test
data. The application itself is licensed under **GPL-3.0-or-later** by
Albrecht Degering; the complete GNU GPL version 3 text is in the repository
root `LICENSE`.
The root licence applies to av-tools-owned code and documentation. It does not
replace or narrow the terms, copyright notices, source-delivery requirements
or patent considerations of bundled third-party components.
- `ffmpeg.wasm-MIT.txt` is the exact MIT text from the inspected ffmpeg.wasm
`v12.15` source at revision
`71aa99d37c02a7b4c435275ca9ef50e612f6efa1`; it covers the wrapper/util
source.
- `GPL-2.0-or-later.txt` reproduces GNU GPL version 2. The published
`@ffmpeg/core` and `@ffmpeg/core-mt` 0.12.10 package manifests declare
`GPL-2.0-or-later`; “or later” comes from those manifests. The legal code
was checked against the standard GPL-2.0 text in the installed Freedesktop
SDK licence corpus and the SPDX licence-list text.
- `CC0-1.0.txt` applies only to generated bytes under
`tests/fixtures/generated/`; it is byte-identical to the CC0 legal code in
the installed Freedesktop SDK licence corpus.
- `native/` preserves the exact licence, notice and patent files from the
pinned FFmpeg and linked-codec source trees. Their source revisions are
recorded in `scripts/ffmpeg-source-lock.json`; every file was copied
byte-for-byte from the corresponding source bundle.
Release packaging also copies the legal files shipped by installed runtime npm
dependencies into `LICENSES/npm/` inside the ZIP. See
`THIRD_PARTY_NOTICES.md` and `SOURCE.md` for versions, provenance and release
source-delivery requirements.

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 Jerome Wu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,129 @@
# License
Most files in FFmpeg are under the GNU Lesser General Public License version 2.1
or later (LGPL v2.1+). Read the file `COPYING.LGPLv2.1` for details. Some other
files have MIT/X11/BSD-style licenses. In combination the LGPL v2.1+ applies to
FFmpeg.
Some optional parts of FFmpeg are licensed under the GNU General Public License
version 2 or later (GPL v2+). See the file `COPYING.GPLv2` for details. None of
these parts are used by default, you have to explicitly pass `--enable-gpl` to
configure to activate them. In this case, FFmpeg's license changes to GPL v2+.
Specifically, the GPL parts of FFmpeg are:
- libpostproc
- optional x86 optimization in the files
- `libavcodec/x86/flac_dsp_gpl.asm`
- `libavcodec/x86/idct_mmx.c`
- `libavfilter/x86/vf_removegrain.asm`
- the following building and testing tools
- `compat/solaris/make_sunver.pl`
- `doc/t2h.pm`
- `doc/texi2pod.pl`
- `libswresample/tests/swresample.c`
- `tests/checkasm/*`
- `tests/tiny_ssim.c`
- the following filters in libavfilter:
- `signature_lookup.c`
- `vf_blackframe.c`
- `vf_boxblur.c`
- `vf_colormatrix.c`
- `vf_cover_rect.c`
- `vf_cropdetect.c`
- `vf_delogo.c`
- `vf_eq.c`
- `vf_find_rect.c`
- `vf_fspp.c`
- `vf_histeq.c`
- `vf_hqdn3d.c`
- `vf_kerndeint.c`
- `vf_lensfun.c` (GPL version 3 or later)
- `vf_mcdeint.c`
- `vf_mpdecimate.c`
- `vf_nnedi.c`
- `vf_owdenoise.c`
- `vf_perspective.c`
- `vf_phase.c`
- `vf_pp.c`
- `vf_pp7.c`
- `vf_pullup.c`
- `vf_repeatfields.c`
- `vf_sab.c`
- `vf_signature.c`
- `vf_smartblur.c`
- `vf_spp.c`
- `vf_stereo3d.c`
- `vf_super2xsai.c`
- `vf_tinterlace.c`
- `vf_uspp.c`
- `vf_vaguedenoiser.c`
- `vsrc_mptestsrc.c`
Should you, for whatever reason, prefer to use version 3 of the (L)GPL, then
the configure parameter `--enable-version3` will activate this licensing option
for you. Read the file `COPYING.LGPLv3` or, if you have enabled GPL parts,
`COPYING.GPLv3` to learn the exact legal terms that apply in this case.
There are a handful of files under other licensing terms, namely:
* The files `libavcodec/jfdctfst.c`, `libavcodec/jfdctint_template.c` and
`libavcodec/jrevdct.c` are taken from libjpeg, see the top of the files for
licensing details. Specifically note that you must credit the IJG in the
documentation accompanying your program if you only distribute executables.
You must also indicate any changes including additions and deletions to
those three files in the documentation.
* `tests/reference.pnm` is under the expat license.
## External libraries
FFmpeg can be combined with a number of external libraries, which sometimes
affect the licensing of binaries resulting from the combination.
### Compatible libraries
The following libraries are under GPL version 2:
- avisynth
- frei0r
- libcdio
- libdavs2
- librubberband
- libvidstab
- libx264
- libx265
- libxavs
- libxavs2
- libxvid
When combining them with FFmpeg, FFmpeg needs to be licensed as GPL as well by
passing `--enable-gpl` to configure.
The following libraries are under LGPL version 3:
- gmp
- libaribb24
- liblensfun
When combining them with FFmpeg, use the configure option `--enable-version3` to
upgrade FFmpeg to the LGPL v3.
The VMAF, mbedTLS, RK MPI, OpenCORE and VisualOn libraries are under the Apache License
2.0. That license is incompatible with the LGPL v2.1 and the GPL v2, but not with
version 3 of those licenses. So to combine these libraries with FFmpeg, the
license version needs to be upgraded by passing `--enable-version3` to configure.
The smbclient library is under the GPL v3, to combine it with FFmpeg,
the options `--enable-gpl` and `--enable-version3` have to be passed to
configure to upgrade FFmpeg to the GPL v3.
### Incompatible libraries
There are certain libraries you can combine with FFmpeg whose licenses are not
compatible with the GPL and/or the LGPL. If you wish to enable these
libraries, even in circumstances that their license may be incompatible, pass
`--enable-nonfree` to configure. This will cause the resulting binary to be
unredistributable.
The Fraunhofer FDK AAC and OpenSSL libraries are under licenses which are
incompatible with the GPLv2 and v3. To the best of our knowledge, they are
compatible with the LGPL.

View File

@@ -0,0 +1,39 @@
The FreeType 2 font engine is copyrighted work and cannot be used
legally without a software license. In order to make this project
usable to a vast majority of developers, we distribute it under two
mutually exclusive open-source licenses.
This means that *you* must choose *one* of the two licenses described
below, then obey all its terms and conditions when using FreeType 2 in
any of your projects or products.
- The FreeType License, found in the file `FTL.TXT', which is similar
to the original BSD license *with* an advertising clause that forces
you to explicitly cite the FreeType project in your product's
documentation. All details are in the license file. This license
is suited to products which don't use the GNU General Public
License.
Note that this license is compatible to the GNU General Public
License version 3, but not version 2.
- The GNU General Public License version 2, found in `GPLv2.TXT' (any
later version can be used also), for programs which already use the
GPL. Note that the FTL is incompatible with GPLv2 due to its
advertisement clause.
The contributed BDF and PCF drivers come with a license similar to that
of the X Window System. It is compatible to the above two licenses (see
file src/bdf/README and src/pcf/README). The same holds for the files
`fthash.c' and `fthash.h'; their code was part of the BDF driver in
earlier FreeType versions.
The gzip module uses the zlib license (see src/gzip/zlib.h) which too is
compatible to the above two licenses.
The MD5 checksum support (only used for debugging in development builds)
is in the public domain.
--- end of LICENSE.TXT ---

View File

@@ -0,0 +1,504 @@
GNU LESSER GENERAL PUBLIC LICENSE
Version 2.1, February 1999
Copyright (C) 1991, 1999 Free Software Foundation, Inc.
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
[This is the first released version of the Lesser GPL. It also counts
as the successor of the GNU Library Public License, version 2, hence
the version number 2.1.]
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
Licenses are intended to guarantee your freedom to share and change
free software--to make sure the software is free for all its users.
This license, the Lesser General Public License, applies to some
specially designated software packages--typically libraries--of the
Free Software Foundation and other authors who decide to use it. You
can use it too, but we suggest you first think carefully about whether
this license or the ordinary General Public License is the better
strategy to use in any particular case, based on the explanations below.
When we speak of free software, we are referring to freedom of use,
not price. Our General Public Licenses are designed to make sure that
you have the freedom to distribute copies of free software (and charge
for this service if you wish); that you receive source code or can get
it if you want it; that you can change the software and use pieces of
it in new free programs; and that you are informed that you can do
these things.
To protect your rights, we need to make restrictions that forbid
distributors to deny you these rights or to ask you to surrender these
rights. These restrictions translate to certain responsibilities for
you if you distribute copies of the library or if you modify it.
For example, if you distribute copies of the library, whether gratis
or for a fee, you must give the recipients all the rights that we gave
you. You must make sure that they, too, receive or can get the source
code. If you link other code with the library, you must provide
complete object files to the recipients, so that they can relink them
with the library after making changes to the library and recompiling
it. And you must show them these terms so they know their rights.
We protect your rights with a two-step method: (1) we copyright the
library, and (2) we offer you this license, which gives you legal
permission to copy, distribute and/or modify the library.
To protect each distributor, we want to make it very clear that
there is no warranty for the free library. Also, if the library is
modified by someone else and passed on, the recipients should know
that what they have is not the original version, so that the original
author's reputation will not be affected by problems that might be
introduced by others.
Finally, software patents pose a constant threat to the existence of
any free program. We wish to make sure that a company cannot
effectively restrict the users of a free program by obtaining a
restrictive license from a patent holder. Therefore, we insist that
any patent license obtained for a version of the library must be
consistent with the full freedom of use specified in this license.
Most GNU software, including some libraries, is covered by the
ordinary GNU General Public License. This license, the GNU Lesser
General Public License, applies to certain designated libraries, and
is quite different from the ordinary General Public License. We use
this license for certain libraries in order to permit linking those
libraries into non-free programs.
When a program is linked with a library, whether statically or using
a shared library, the combination of the two is legally speaking a
combined work, a derivative of the original library. The ordinary
General Public License therefore permits such linking only if the
entire combination fits its criteria of freedom. The Lesser General
Public License permits more lax criteria for linking other code with
the library.
We call this license the "Lesser" General Public License because it
does Less to protect the user's freedom than the ordinary General
Public License. It also provides other free software developers Less
of an advantage over competing non-free programs. These disadvantages
are the reason we use the ordinary General Public License for many
libraries. However, the Lesser license provides advantages in certain
special circumstances.
For example, on rare occasions, there may be a special need to
encourage the widest possible use of a certain library, so that it becomes
a de-facto standard. To achieve this, non-free programs must be
allowed to use the library. A more frequent case is that a free
library does the same job as widely used non-free libraries. In this
case, there is little to gain by limiting the free library to free
software only, so we use the Lesser General Public License.
In other cases, permission to use a particular library in non-free
programs enables a greater number of people to use a large body of
free software. For example, permission to use the GNU C Library in
non-free programs enables many more people to use the whole GNU
operating system, as well as its variant, the GNU/Linux operating
system.
Although the Lesser General Public License is Less protective of the
users' freedom, it does ensure that the user of a program that is
linked with the Library has the freedom and the wherewithal to run
that program using a modified version of the Library.
The precise terms and conditions for copying, distribution and
modification follow. Pay close attention to the difference between a
"work based on the library" and a "work that uses the library". The
former contains code derived from the library, whereas the latter must
be combined with the library in order to run.
GNU LESSER GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License Agreement applies to any software library or other
program which contains a notice placed by the copyright holder or
other authorized party saying it may be distributed under the terms of
this Lesser General Public License (also called "this License").
Each licensee is addressed as "you".
A "library" means a collection of software functions and/or data
prepared so as to be conveniently linked with application programs
(which use some of those functions and data) to form executables.
The "Library", below, refers to any such software library or work
which has been distributed under these terms. A "work based on the
Library" means either the Library or any derivative work under
copyright law: that is to say, a work containing the Library or a
portion of it, either verbatim or with modifications and/or translated
straightforwardly into another language. (Hereinafter, translation is
included without limitation in the term "modification".)
"Source code" for a work means the preferred form of the work for
making modifications to it. For a library, complete source code means
all the source code for all modules it contains, plus any associated
interface definition files, plus the scripts used to control compilation
and installation of the library.
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running a program using the Library is not restricted, and output from
such a program is covered only if its contents constitute a work based
on the Library (independent of the use of the Library in a tool for
writing it). Whether that is true depends on what the Library does
and what the program that uses the Library does.
1. You may copy and distribute verbatim copies of the Library's
complete source code as you receive it, in any medium, provided that
you conspicuously and appropriately publish on each copy an
appropriate copyright notice and disclaimer of warranty; keep intact
all the notices that refer to this License and to the absence of any
warranty; and distribute a copy of this License along with the
Library.
You may charge a fee for the physical act of transferring a copy,
and you may at your option offer warranty protection in exchange for a
fee.
2. You may modify your copy or copies of the Library or any portion
of it, thus forming a work based on the Library, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) The modified work must itself be a software library.
b) You must cause the files modified to carry prominent notices
stating that you changed the files and the date of any change.
c) You must cause the whole of the work to be licensed at no
charge to all third parties under the terms of this License.
d) If a facility in the modified Library refers to a function or a
table of data to be supplied by an application program that uses
the facility, other than as an argument passed when the facility
is invoked, then you must make a good faith effort to ensure that,
in the event an application does not supply such function or
table, the facility still operates, and performs whatever part of
its purpose remains meaningful.
(For example, a function in a library to compute square roots has
a purpose that is entirely well-defined independent of the
application. Therefore, Subsection 2d requires that any
application-supplied function or table used by this function must
be optional: if the application does not supply it, the square
root function must still compute square roots.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Library,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Library, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote
it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Library.
In addition, mere aggregation of another work not based on the Library
with the Library (or with a work based on the Library) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may opt to apply the terms of the ordinary GNU General Public
License instead of this License to a given copy of the Library. To do
this, you must alter all the notices that refer to this License, so
that they refer to the ordinary GNU General Public License, version 2,
instead of to this License. (If a newer version than version 2 of the
ordinary GNU General Public License has appeared, then you can specify
that version instead if you wish.) Do not make any other change in
these notices.
Once this change is made in a given copy, it is irreversible for
that copy, so the ordinary GNU General Public License applies to all
subsequent copies and derivative works made from that copy.
This option is useful when you wish to copy part of the code of
the Library into a program that is not a library.
4. You may copy and distribute the Library (or a portion or
derivative of it, under Section 2) in object code or executable form
under the terms of Sections 1 and 2 above provided that you accompany
it with the complete corresponding machine-readable source code, which
must be distributed under the terms of Sections 1 and 2 above on a
medium customarily used for software interchange.
If distribution of object code is made by offering access to copy
from a designated place, then offering equivalent access to copy the
source code from the same place satisfies the requirement to
distribute the source code, even though third parties are not
compelled to copy the source along with the object code.
5. A program that contains no derivative of any portion of the
Library, but is designed to work with the Library by being compiled or
linked with it, is called a "work that uses the Library". Such a
work, in isolation, is not a derivative work of the Library, and
therefore falls outside the scope of this License.
However, linking a "work that uses the Library" with the Library
creates an executable that is a derivative of the Library (because it
contains portions of the Library), rather than a "work that uses the
library". The executable is therefore covered by this License.
Section 6 states terms for distribution of such executables.
When a "work that uses the Library" uses material from a header file
that is part of the Library, the object code for the work may be a
derivative work of the Library even though the source code is not.
Whether this is true is especially significant if the work can be
linked without the Library, or if the work is itself a library. The
threshold for this to be true is not precisely defined by law.
If such an object file uses only numerical parameters, data
structure layouts and accessors, and small macros and small inline
functions (ten lines or less in length), then the use of the object
file is unrestricted, regardless of whether it is legally a derivative
work. (Executables containing this object code plus portions of the
Library will still fall under Section 6.)
Otherwise, if the work is a derivative of the Library, you may
distribute the object code for the work under the terms of Section 6.
Any executables containing that work also fall under Section 6,
whether or not they are linked directly with the Library itself.
6. As an exception to the Sections above, you may also combine or
link a "work that uses the Library" with the Library to produce a
work containing portions of the Library, and distribute that work
under terms of your choice, provided that the terms permit
modification of the work for the customer's own use and reverse
engineering for debugging such modifications.
You must give prominent notice with each copy of the work that the
Library is used in it and that the Library and its use are covered by
this License. You must supply a copy of this License. If the work
during execution displays copyright notices, you must include the
copyright notice for the Library among them, as well as a reference
directing the user to the copy of this License. Also, you must do one
of these things:
a) Accompany the work with the complete corresponding
machine-readable source code for the Library including whatever
changes were used in the work (which must be distributed under
Sections 1 and 2 above); and, if the work is an executable linked
with the Library, with the complete machine-readable "work that
uses the Library", as object code and/or source code, so that the
user can modify the Library and then relink to produce a modified
executable containing the modified Library. (It is understood
that the user who changes the contents of definitions files in the
Library will not necessarily be able to recompile the application
to use the modified definitions.)
b) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (1) uses at run time a
copy of the library already present on the user's computer system,
rather than copying library functions into the executable, and (2)
will operate properly with a modified version of the library, if
the user installs one, as long as the modified version is
interface-compatible with the version that the work was made with.
c) Accompany the work with a written offer, valid for at
least three years, to give the same user the materials
specified in Subsection 6a, above, for a charge no more
than the cost of performing this distribution.
d) If distribution of the work is made by offering access to copy
from a designated place, offer equivalent access to copy the above
specified materials from the same place.
e) Verify that the user has already received a copy of these
materials or that you have already sent this user a copy.
For an executable, the required form of the "work that uses the
Library" must include any data and utility programs needed for
reproducing the executable from it. However, as a special exception,
the materials to be distributed need not include anything that is
normally distributed (in either source or binary form) with the major
components (compiler, kernel, and so on) of the operating system on
which the executable runs, unless that component itself accompanies
the executable.
It may happen that this requirement contradicts the license
restrictions of other proprietary libraries that do not normally
accompany the operating system. Such a contradiction means you cannot
use both them and the Library together in an executable that you
distribute.
7. You may place library facilities that are a work based on the
Library side-by-side in a single library together with other library
facilities not covered by this License, and distribute such a combined
library, provided that the separate distribution of the work based on
the Library and of the other library facilities is otherwise
permitted, and provided that you do these two things:
a) Accompany the combined library with a copy of the same work
based on the Library, uncombined with any other library
facilities. This must be distributed under the terms of the
Sections above.
b) Give prominent notice with the combined library of the fact
that part of it is a work based on the Library, and explaining
where to find the accompanying uncombined form of the same work.
8. You may not copy, modify, sublicense, link with, or distribute
the Library except as expressly provided under this License. Any
attempt otherwise to copy, modify, sublicense, link with, or
distribute the Library is void, and will automatically terminate your
rights under this License. However, parties who have received copies,
or rights, from you under this License will not have their licenses
terminated so long as such parties remain in full compliance.
9. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Library or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Library (or any work based on the
Library), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Library or works based on it.
10. Each time you redistribute the Library (or any work based on the
Library), the recipient automatically receives a license from the
original licensor to copy, distribute, link with or modify the Library
subject to these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties with
this License.
11. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Library at all. For example, if a patent
license would not permit royalty-free redistribution of the Library by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Library.
If any portion of this section is held invalid or unenforceable under any
particular circumstance, the balance of the section is intended to apply,
and the section as a whole is intended to apply in other circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
12. If the distribution and/or use of the Library is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Library under this License may add
an explicit geographical distribution limitation excluding those countries,
so that distribution is permitted only in or among countries not thus
excluded. In such case, this License incorporates the limitation as if
written in the body of this License.
13. The Free Software Foundation may publish revised and/or new
versions of the Lesser General Public License from time to time.
Such new versions will be similar in spirit to the present version,
but may differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Library
specifies a version number of this License which applies to it and
"any later version", you have the option of following the terms and
conditions either of that version or of any later version published by
the Free Software Foundation. If the Library does not specify a
license version number, you may choose any version ever published by
the Free Software Foundation.
14. If you wish to incorporate parts of the Library into other free
programs whose distribution conditions are incompatible with these,
write to the author to ask for permission. For software which is
copyrighted by the Free Software Foundation, write to the Free
Software Foundation; we sometimes make exceptions for this. Our
decision will be guided by the two goals of preserving the free status
of all derivatives of our free software and of promoting the sharing
and reuse of software generally.
NO WARRANTY
15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO
WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY
KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU
FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR
CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING
RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A
FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF
SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Libraries
If you develop a new library, and you want it to be of the greatest
possible use to the public, we recommend making it free software that
everyone can redistribute and change. You can do so by permitting
redistribution under these terms (or, alternatively, under the terms of the
ordinary General Public License).
To apply these terms, attach the following notices to the library. It is
safest to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least the
"copyright" line and a pointer to where the full notice is found.
<one line to give the library's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Also add information on how to contact you by electronic and paper mail.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the library, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the
library `Frob' (a library for tweaking knobs) written by James Random Hacker.
<signature of Ty Coon>, 1 April 1990
Ty Coon, President of Vice
That's all there is to it!

View File

@@ -0,0 +1,38 @@
HarfBuzz is licensed under the so-called "Old MIT" license. Details follow.
For parts of HarfBuzz that are licensed under different licenses see individual
files names COPYING in subdirectories where applicable.
Copyright © 2010,2011,2012,2013,2014,2015,2016,2017,2018,2019,2020 Google, Inc.
Copyright © 2018,2019,2020 Ebrahim Byagowi
Copyright © 2019,2020 Facebook, Inc.
Copyright © 2012 Mozilla Foundation
Copyright © 2011 Codethink Limited
Copyright © 2008,2010 Nokia Corporation and/or its subsidiary(-ies)
Copyright © 2009 Keith Stribley
Copyright © 2009 Martin Hosken and SIL International
Copyright © 2007 Chris Wilson
Copyright © 2005,2006,2020,2021 Behdad Esfahbod
Copyright © 2005 David Turner
Copyright © 2004,2007,2008,2009,2010 Red Hat, Inc.
Copyright © 1998-2004 David Turner and Werner Lemberg
For full copyright notices consult the individual files in the package.
Permission is hereby granted, without written agreement and without
license or royalty fees, to use, copy, modify, and distribute this
software and its documentation for any purpose, provided that the
above copyright notice and the following two paragraphs appear in
all copies of this software.
IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR
DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES
ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN
IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
DAMAGE.
THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND
FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS
ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO
PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.

View File

@@ -0,0 +1,481 @@
GNU LIBRARY GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1991 Free Software Foundation, Inc.
59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
[This is the first released version of the library GPL. It is
numbered 2 because it goes with version 2 of the ordinary GPL.]
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
Licenses are intended to guarantee your freedom to share and change
free software--to make sure the software is free for all its users.
This license, the Library General Public License, applies to some
specially designated Free Software Foundation software, and to any
other libraries whose authors decide to use it. You can use it for
your libraries, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if
you distribute copies of the library, or if you modify it.
For example, if you distribute copies of the library, whether gratis
or for a fee, you must give the recipients all the rights that we gave
you. You must make sure that they, too, receive or can get the source
code. If you link a program with the library, you must provide
complete object files to the recipients so that they can relink them
with the library, after making changes to the library and recompiling
it. And you must show them these terms so they know their rights.
Our method of protecting your rights has two steps: (1) copyright
the library, and (2) offer you this license which gives you legal
permission to copy, distribute and/or modify the library.
Also, for each distributor's protection, we want to make certain
that everyone understands that there is no warranty for this free
library. If the library is modified by someone else and passed on, we
want its recipients to know that what they have is not the original
version, so that any problems introduced by others will not reflect on
the original authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that companies distributing free
software will individually obtain patent licenses, thus in effect
transforming the program into proprietary software. To prevent this,
we have made it clear that any patent must be licensed for everyone's
free use or not licensed at all.
Most GNU software, including some libraries, is covered by the ordinary
GNU General Public License, which was designed for utility programs. This
license, the GNU Library General Public License, applies to certain
designated libraries. This license is quite different from the ordinary
one; be sure to read it in full, and don't assume that anything in it is
the same as in the ordinary license.
The reason we have a separate public license for some libraries is that
they blur the distinction we usually make between modifying or adding to a
program and simply using it. Linking a program with a library, without
changing the library, is in some sense simply using the library, and is
analogous to running a utility program or application program. However, in
a textual and legal sense, the linked executable is a combined work, a
derivative of the original library, and the ordinary General Public License
treats it as such.
Because of this blurred distinction, using the ordinary General
Public License for libraries did not effectively promote software
sharing, because most developers did not use the libraries. We
concluded that weaker conditions might promote sharing better.
However, unrestricted linking of non-free programs would deprive the
users of those programs of all benefit from the free status of the
libraries themselves. This Library General Public License is intended to
permit developers of non-free programs to use free libraries, while
preserving your freedom as a user of such programs to change the free
libraries that are incorporated in them. (We have not seen how to achieve
this as regards changes in header files, but we have achieved it as regards
changes in the actual functions of the Library.) The hope is that this
will lead to faster development of free libraries.
The precise terms and conditions for copying, distribution and
modification follow. Pay close attention to the difference between a
"work based on the library" and a "work that uses the library". The
former contains code derived from the library, while the latter only
works together with the library.
Note that it is possible for a library to be covered by the ordinary
General Public License rather than by this special one.
GNU LIBRARY GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License Agreement applies to any software library which
contains a notice placed by the copyright holder or other authorized
party saying it may be distributed under the terms of this Library
General Public License (also called "this License"). Each licensee is
addressed as "you".
A "library" means a collection of software functions and/or data
prepared so as to be conveniently linked with application programs
(which use some of those functions and data) to form executables.
The "Library", below, refers to any such software library or work
which has been distributed under these terms. A "work based on the
Library" means either the Library or any derivative work under
copyright law: that is to say, a work containing the Library or a
portion of it, either verbatim or with modifications and/or translated
straightforwardly into another language. (Hereinafter, translation is
included without limitation in the term "modification".)
"Source code" for a work means the preferred form of the work for
making modifications to it. For a library, complete source code means
all the source code for all modules it contains, plus any associated
interface definition files, plus the scripts used to control compilation
and installation of the library.
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running a program using the Library is not restricted, and output from
such a program is covered only if its contents constitute a work based
on the Library (independent of the use of the Library in a tool for
writing it). Whether that is true depends on what the Library does
and what the program that uses the Library does.
1. You may copy and distribute verbatim copies of the Library's
complete source code as you receive it, in any medium, provided that
you conspicuously and appropriately publish on each copy an
appropriate copyright notice and disclaimer of warranty; keep intact
all the notices that refer to this License and to the absence of any
warranty; and distribute a copy of this License along with the
Library.
You may charge a fee for the physical act of transferring a copy,
and you may at your option offer warranty protection in exchange for a
fee.
2. You may modify your copy or copies of the Library or any portion
of it, thus forming a work based on the Library, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) The modified work must itself be a software library.
b) You must cause the files modified to carry prominent notices
stating that you changed the files and the date of any change.
c) You must cause the whole of the work to be licensed at no
charge to all third parties under the terms of this License.
d) If a facility in the modified Library refers to a function or a
table of data to be supplied by an application program that uses
the facility, other than as an argument passed when the facility
is invoked, then you must make a good faith effort to ensure that,
in the event an application does not supply such function or
table, the facility still operates, and performs whatever part of
its purpose remains meaningful.
(For example, a function in a library to compute square roots has
a purpose that is entirely well-defined independent of the
application. Therefore, Subsection 2d requires that any
application-supplied function or table used by this function must
be optional: if the application does not supply it, the square
root function must still compute square roots.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Library,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Library, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote
it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Library.
In addition, mere aggregation of another work not based on the Library
with the Library (or with a work based on the Library) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may opt to apply the terms of the ordinary GNU General Public
License instead of this License to a given copy of the Library. To do
this, you must alter all the notices that refer to this License, so
that they refer to the ordinary GNU General Public License, version 2,
instead of to this License. (If a newer version than version 2 of the
ordinary GNU General Public License has appeared, then you can specify
that version instead if you wish.) Do not make any other change in
these notices.
Once this change is made in a given copy, it is irreversible for
that copy, so the ordinary GNU General Public License applies to all
subsequent copies and derivative works made from that copy.
This option is useful when you wish to copy part of the code of
the Library into a program that is not a library.
4. You may copy and distribute the Library (or a portion or
derivative of it, under Section 2) in object code or executable form
under the terms of Sections 1 and 2 above provided that you accompany
it with the complete corresponding machine-readable source code, which
must be distributed under the terms of Sections 1 and 2 above on a
medium customarily used for software interchange.
If distribution of object code is made by offering access to copy
from a designated place, then offering equivalent access to copy the
source code from the same place satisfies the requirement to
distribute the source code, even though third parties are not
compelled to copy the source along with the object code.
5. A program that contains no derivative of any portion of the
Library, but is designed to work with the Library by being compiled or
linked with it, is called a "work that uses the Library". Such a
work, in isolation, is not a derivative work of the Library, and
therefore falls outside the scope of this License.
However, linking a "work that uses the Library" with the Library
creates an executable that is a derivative of the Library (because it
contains portions of the Library), rather than a "work that uses the
library". The executable is therefore covered by this License.
Section 6 states terms for distribution of such executables.
When a "work that uses the Library" uses material from a header file
that is part of the Library, the object code for the work may be a
derivative work of the Library even though the source code is not.
Whether this is true is especially significant if the work can be
linked without the Library, or if the work is itself a library. The
threshold for this to be true is not precisely defined by law.
If such an object file uses only numerical parameters, data
structure layouts and accessors, and small macros and small inline
functions (ten lines or less in length), then the use of the object
file is unrestricted, regardless of whether it is legally a derivative
work. (Executables containing this object code plus portions of the
Library will still fall under Section 6.)
Otherwise, if the work is a derivative of the Library, you may
distribute the object code for the work under the terms of Section 6.
Any executables containing that work also fall under Section 6,
whether or not they are linked directly with the Library itself.
6. As an exception to the Sections above, you may also compile or
link a "work that uses the Library" with the Library to produce a
work containing portions of the Library, and distribute that work
under terms of your choice, provided that the terms permit
modification of the work for the customer's own use and reverse
engineering for debugging such modifications.
You must give prominent notice with each copy of the work that the
Library is used in it and that the Library and its use are covered by
this License. You must supply a copy of this License. If the work
during execution displays copyright notices, you must include the
copyright notice for the Library among them, as well as a reference
directing the user to the copy of this License. Also, you must do one
of these things:
a) Accompany the work with the complete corresponding
machine-readable source code for the Library including whatever
changes were used in the work (which must be distributed under
Sections 1 and 2 above); and, if the work is an executable linked
with the Library, with the complete machine-readable "work that
uses the Library", as object code and/or source code, so that the
user can modify the Library and then relink to produce a modified
executable containing the modified Library. (It is understood
that the user who changes the contents of definitions files in the
Library will not necessarily be able to recompile the application
to use the modified definitions.)
b) Accompany the work with a written offer, valid for at
least three years, to give the same user the materials
specified in Subsection 6a, above, for a charge no more
than the cost of performing this distribution.
c) If distribution of the work is made by offering access to copy
from a designated place, offer equivalent access to copy the above
specified materials from the same place.
d) Verify that the user has already received a copy of these
materials or that you have already sent this user a copy.
For an executable, the required form of the "work that uses the
Library" must include any data and utility programs needed for
reproducing the executable from it. However, as a special exception,
the source code distributed need not include anything that is normally
distributed (in either source or binary form) with the major
components (compiler, kernel, and so on) of the operating system on
which the executable runs, unless that component itself accompanies
the executable.
It may happen that this requirement contradicts the license
restrictions of other proprietary libraries that do not normally
accompany the operating system. Such a contradiction means you cannot
use both them and the Library together in an executable that you
distribute.
7. You may place library facilities that are a work based on the
Library side-by-side in a single library together with other library
facilities not covered by this License, and distribute such a combined
library, provided that the separate distribution of the work based on
the Library and of the other library facilities is otherwise
permitted, and provided that you do these two things:
a) Accompany the combined library with a copy of the same work
based on the Library, uncombined with any other library
facilities. This must be distributed under the terms of the
Sections above.
b) Give prominent notice with the combined library of the fact
that part of it is a work based on the Library, and explaining
where to find the accompanying uncombined form of the same work.
8. You may not copy, modify, sublicense, link with, or distribute
the Library except as expressly provided under this License. Any
attempt otherwise to copy, modify, sublicense, link with, or
distribute the Library is void, and will automatically terminate your
rights under this License. However, parties who have received copies,
or rights, from you under this License will not have their licenses
terminated so long as such parties remain in full compliance.
9. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Library or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Library (or any work based on the
Library), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Library or works based on it.
10. Each time you redistribute the Library (or any work based on the
Library), the recipient automatically receives a license from the
original licensor to copy, distribute, link with or modify the Library
subject to these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
11. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Library at all. For example, if a patent
license would not permit royalty-free redistribution of the Library by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Library.
If any portion of this section is held invalid or unenforceable under any
particular circumstance, the balance of the section is intended to apply,
and the section as a whole is intended to apply in other circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
12. If the distribution and/or use of the Library is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Library under this License may add
an explicit geographical distribution limitation excluding those countries,
so that distribution is permitted only in or among countries not thus
excluded. In such case, this License incorporates the limitation as if
written in the body of this License.
13. The Free Software Foundation may publish revised and/or new
versions of the Library General Public License from time to time.
Such new versions will be similar in spirit to the present version,
but may differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Library
specifies a version number of this License which applies to it and
"any later version", you have the option of following the terms and
conditions either of that version or of any later version published by
the Free Software Foundation. If the Library does not specify a
license version number, you may choose any version ever published by
the Free Software Foundation.
14. If you wish to incorporate parts of the Library into other free
programs whose distribution conditions are incompatible with these,
write to the author to ask for permission. For software which is
copyrighted by the Free Software Foundation, write to the Free
Software Foundation; we sometimes make exceptions for this. Our
decision will be guided by the two goals of preserving the free status
of all derivatives of our free software and of promoting the sharing
and reuse of software generally.
NO WARRANTY
15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO
WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY
KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU
FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR
CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING
RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A
FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF
SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Libraries
If you develop a new library, and you want it to be of the greatest
possible use to the public, we recommend making it free software that
everyone can redistribute and change. You can do so by permitting
redistribution under these terms (or, alternatively, under the terms of the
ordinary General Public License).
To apply these terms, attach the following notices to the library. It is
safest to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least the
"copyright" line and a pointer to where the full notice is found.
<one line to give the library's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Library General Public
License as published by the Free Software Foundation; either
version 2 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Library General Public License for more details.
You should have received a copy of the GNU Library General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
Also add information on how to contact you by electronic and paper mail.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the library, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the
library `Frob' (a library for tweaking knobs) written by James Random Hacker.
<signature of Ty Coon>, 1 April 1990
Ty Coon, President of Vice
That's all there is to it!

View File

@@ -0,0 +1,13 @@
Can I use LAME in my commercial program?
Yes, you can, under the restrictions of the LGPL (see COPYING
in this folder). The easiest way to do this is to:
1. Link to LAME as separate library (libmp3lame.a on unix or
lame_enc.dll or libmp3lame.dll on windows)
2. Fully acknowledge that you are using LAME, and give a link
to our web site, www.mp3dev.org
3. If you make modifications to LAME, you *must* release these
modifications back to the LAME project, under the LGPL.

View File

@@ -0,0 +1,28 @@
Copyright (c) 2002, Xiph.org Foundation
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
- Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
- Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
- Neither the name of the Xiph.org Foundation nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION
OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@@ -0,0 +1,44 @@
Copyright 2001-2011 Xiph.Org, Skype Limited, Octasic,
Jean-Marc Valin, Timothy B. Terriberry,
CSIRO, Gregory Maxwell, Mark Borgerding,
Erik de Castro Lopo
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
- Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
- Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
- Neither the name of Internet Society, IETF or IETF Trust, nor the
names of specific contributors, may be used to endorse or promote
products derived from this software without specific prior written
permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER
OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
Opus is subject to the royalty-free patent licenses which are
specified at:
Xiph.Org Foundation:
https://datatracker.ietf.org/ipr/1524/
Microsoft Corporation:
https://datatracker.ietf.org/ipr/1914/
Broadcom Corporation:
https://datatracker.ietf.org/ipr/1526/

View File

@@ -0,0 +1,28 @@
Copyright (C) 2002-2009 Xiph.org Foundation
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
- Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
- Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
- Neither the name of the Xiph.org Foundation nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION
OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@@ -0,0 +1,28 @@
Copyright (c) 2002-2008 Xiph.org Foundation
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
- Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
- Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
- Neither the name of the Xiph.org Foundation nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION
OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@@ -0,0 +1,15 @@
ISC License
Copyright (C) 2006-2016 libass contributors
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

View File

@@ -0,0 +1,31 @@
Copyright (c) 2010, The WebM Project authors. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in
the documentation and/or other materials provided with the
distribution.
* Neither the name of Google, nor the WebM Project, nor the names
of its contributors may be used to endorse or promote products
derived from this software without specific prior written
permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@@ -0,0 +1,23 @@
Additional IP Rights Grant (Patents)
------------------------------------
"These implementations" means the copyrightable works that implement the WebM
codecs distributed by Google as part of the WebM Project.
Google hereby grants to you a perpetual, worldwide, non-exclusive, no-charge,
royalty-free, irrevocable (except as stated in this section) patent license to
make, have made, use, offer to sell, sell, import, transfer, and otherwise
run, modify and propagate the contents of these implementations of WebM, where
such license applies only to those patent claims, both currently owned by
Google and acquired in the future, licensable by Google that are necessarily
infringed by these implementations of WebM. This grant does not include claims
that would be infringed only as a consequence of further modification of these
implementations. If you or your agent or exclusive licensee institute or order
or agree to the institution of patent litigation or any other patent
enforcement activity against any entity (including a cross-claim or
counterclaim in a lawsuit) alleging that any of these implementations of WebM
or any code incorporated within any of these implementations of WebM
constitute direct or contributory patent infringement, or inducement of
patent infringement, then any patent rights granted to you under this License
for these implementations of WebM shall terminate as of the date such
litigation is filed.

View File

@@ -0,0 +1,30 @@
Copyright (c) 2010, Google Inc. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in
the documentation and/or other materials provided with the
distribution.
* Neither the name of Google nor the names of its contributors may
be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@@ -0,0 +1,23 @@
Additional IP Rights Grant (Patents)
------------------------------------
"These implementations" means the copyrightable works that implement the WebM
codecs distributed by Google as part of the WebM Project.
Google hereby grants to you a perpetual, worldwide, non-exclusive, no-charge,
royalty-free, irrevocable (except as stated in this section) patent license to
make, have made, use, offer to sell, sell, import, transfer, and otherwise
run, modify and propagate the contents of these implementations of WebM, where
such license applies only to those patent claims, both currently owned by
Google and acquired in the future, licensable by Google that are necessarily
infringed by these implementations of WebM. This grant does not include claims
that would be infringed only as a consequence of further modification of these
implementations. If you or your agent or exclusive licensee institute or order
or agree to the institution of patent litigation or any other patent
enforcement activity against any entity (including a cross-claim or
counterclaim in a lawsuit) alleging that any of these implementations of WebM
or any code incorporated within any of these implementations of WebM
constitute direct or contributory patent infringement, or inducement of
patent infringement, then any patent rights granted to you under this License
for these implementations of WebM shall terminate as of the date such
litigation is filed.

View File

@@ -0,0 +1,340 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.
59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Library General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Ty Coon>, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Library General
Public License instead of this License.

View File

@@ -0,0 +1,343 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.
59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Library General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Ty Coon>, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Library General
Public License instead of this License.
This program is also available under a commercial proprietary license.
For more information, contact us at license @ x265.com.

View File

@@ -0,0 +1,14 @@
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
Version 2, December 2004
Copyright (C) 2004 Sam Hocevar <sam@hocevar.net>
Everyone is permitted to copy and distribute verbatim or modified
copies of this license document, and changing it is allowed as long
as the name is changed.
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. You just DO WHAT THE FUCK YOU WANT TO.

View File

@@ -0,0 +1,115 @@
ZLIB DATA COMPRESSION LIBRARY
zlib 1.2.11 is a general purpose data compression library. All the code is
thread safe. The data format used by the zlib library is described by RFCs
(Request for Comments) 1950 to 1952 in the files
http://tools.ietf.org/html/rfc1950 (zlib format), rfc1951 (deflate format) and
rfc1952 (gzip format).
All functions of the compression library are documented in the file zlib.h
(volunteer to write man pages welcome, contact zlib@gzip.org). A usage example
of the library is given in the file test/example.c which also tests that
the library is working correctly. Another example is given in the file
test/minigzip.c. The compression library itself is composed of all source
files in the root directory.
To compile all files and run the test program, follow the instructions given at
the top of Makefile.in. In short "./configure; make test", and if that goes
well, "make install" should work for most flavors of Unix. For Windows, use
one of the special makefiles in win32/ or contrib/vstudio/ . For VMS, use
make_vms.com.
Questions about zlib should be sent to <zlib@gzip.org>, or to Gilles Vollant
<info@winimage.com> for the Windows DLL version. The zlib home page is
http://zlib.net/ . Before reporting a problem, please check this site to
verify that you have the latest version of zlib; otherwise get the latest
version and check whether the problem still exists or not.
PLEASE read the zlib FAQ http://zlib.net/zlib_faq.html before asking for help.
Mark Nelson <markn@ieee.org> wrote an article about zlib for the Jan. 1997
issue of Dr. Dobb's Journal; a copy of the article is available at
http://marknelson.us/1997/01/01/zlib-engine/ .
The changes made in version 1.2.11 are documented in the file ChangeLog.
Unsupported third party contributions are provided in directory contrib/ .
zlib is available in Java using the java.util.zip package, documented at
http://java.sun.com/developer/technicalArticles/Programming/compression/ .
A Perl interface to zlib written by Paul Marquess <pmqs@cpan.org> is available
at CPAN (Comprehensive Perl Archive Network) sites, including
http://search.cpan.org/~pmqs/IO-Compress-Zlib/ .
A Python interface to zlib written by A.M. Kuchling <amk@amk.ca> is
available in Python 1.5 and later versions, see
http://docs.python.org/library/zlib.html .
zlib is built into tcl: http://wiki.tcl.tk/4610 .
An experimental package to read and write files in .zip format, written on top
of zlib by Gilles Vollant <info@winimage.com>, is available in the
contrib/minizip directory of zlib.
Notes for some targets:
- For Windows DLL versions, please see win32/DLL_FAQ.txt
- For 64-bit Irix, deflate.c must be compiled without any optimization. With
-O, one libpng test fails. The test works in 32 bit mode (with the -n32
compiler flag). The compiler bug has been reported to SGI.
- zlib doesn't work with gcc 2.6.3 on a DEC 3000/300LX under OSF/1 2.1 it works
when compiled with cc.
- On Digital Unix 4.0D (formely OSF/1) on AlphaServer, the cc option -std1 is
necessary to get gzprintf working correctly. This is done by configure.
- zlib doesn't work on HP-UX 9.05 with some versions of /bin/cc. It works with
other compilers. Use "make test" to check your compiler.
- gzdopen is not supported on RISCOS or BEOS.
- For PalmOs, see http://palmzlib.sourceforge.net/
Acknowledgments:
The deflate format used by zlib was defined by Phil Katz. The deflate and
zlib specifications were written by L. Peter Deutsch. Thanks to all the
people who reported problems and suggested various improvements in zlib; they
are too numerous to cite here.
Copyright notice:
(C) 1995-2017 Jean-loup Gailly and Mark Adler
This software is provided 'as-is', without any express or implied
warranty. In no event will the authors be held liable for any damages
arising from the use of this software.
Permission is granted to anyone to use this software for any purpose,
including commercial applications, and to alter it and redistribute it
freely, subject to the following restrictions:
1. The origin of this software must not be misrepresented; you must not
claim that you wrote the original software. If you use this software
in a product, an acknowledgment in the product documentation would be
appreciated but is not required.
2. Altered source versions must be plainly marked as such, and must not be
misrepresented as being the original software.
3. This notice may not be removed or altered from any source distribution.
Jean-loup Gailly Mark Adler
jloup@gzip.org madler@alumni.caltech.edu
If you use the zlib library in a product, we would appreciate *not* receiving
lengthy legal documents to sign. The sources are provided for free but without
warranty of any kind. The library has been entirely written by Jean-loup
Gailly and Mark Adler; it does not include third-party code.
If you redistribute modified sources, we would appreciate that you include in
the file ChangeLog history information documenting your changes. Please read
the FAQ for more information on the distribution of modified source versions.

335
README.md Normal file
View File

@@ -0,0 +1,335 @@
# Audio & Video Tools
av-tools is a standalone, local-first browser application for converting and
lightly editing audio/video. Media is processed by self-hosted FFmpeg
WebAssembly in a browser worker: there is no upload API, server transcoder,
telemetry, runtime CDN, remote-media fetch or third-party script.
It is also a Toolbox SDK application. The same static build runs directly or
under Toolbox Portal with shared shell, theme, help and app switching.
> **Release status:** 0.1.0 is licensed GPL-3.0-or-later. Its canonical
> release consists of the exact reviewed `v0.1.0` source, production
> ZIP/checksum and Corresponding Source bundle published together. See
> [Licensing](#licensing).
## Product scope
Two workflows keep the product intentionally smaller than a nonlinear editor:
- **Quick Convert:** drop/import media, inspect streams, choose a
capability-checked preset/streams, convert, preview when the browser can, and
download.
- **Edit:** a media bin and sequential non-overlapping clip timeline with
non-destructive settings, one export at a time.
The planned 0.1 line covers typed/capability-gated conversion and remux,
fast/accurate trim, split, compatible/normalized concatenate, reorder, crop,
resize, frame-rate change, audio extraction/stream removal, waveform,
peak/two-pass loudness normalization, gain, audio/video fades, metadata,
chapters, SRT/WebVTT/ASS text subtitles, thumbnails, contact sheets, presets,
versioned project save/load, reattachment, progress, cancellation/recovery,
diagnostics and explicit browser export.
The Edit workspace now exposes structural exports directly: fast stream-copy
or preset-backed accurate trim uses the selected clip's timeline range, while
fast concatenate requires explicit compatibility diagnostics and normalized
concatenate requires a preset plus an explicit missing-audio policy.
Operations enter a bounded, in-memory application queue with at most eight
scheduled operations and one FFmpeg execution at a time.
When a source cannot be played directly, the preview panel can create a
disposable H.264/AAC MP4 proxy. The user can bound it to 1300 seconds and
choose a maximum width from 4801920 pixels. A proxy is reused from the local
derivative cache only when the source identity and settings match.
A control is not considered complete merely because its UI or command builder
exists. The exact milestone status is the tested matrix in the final release
report; [CHANGELOG.md](CHANGELOG.md) lists current blockers.
## Engine and format model
Exact runtime packages:
- `@ffmpeg/ffmpeg` 0.12.15;
- `@ffmpeg/util` 0.12.2;
- `@ffmpeg/core` 0.12.10 (single-thread);
- `@ffmpeg/core-mt` 0.12.10 (multithread).
The published cores contain **FFmpeg n5.1.4** (libavcodec 59.37.100), built
with x264, x265, libvpx, LAME, Ogg/Theora/Opus/Vorbis, zlib, libwebp,
FreeType/FriBidi/HarfBuzz/libass and zimg. Both ~32 MiB WASM cores and their
JS/worker files are copied from exact npm packages and served from the app's
own origin. `npm run build` does not download them.
Every engine load queries actual demuxers, muxers, decoders, encoders and
filters. Presets remain disabled when requirements are absent; an extension or
FFmpeg build flag is not a compatibility promise.
To bound native heap retained by heterogeneous long-running sessions, the app
refreshes an idle core before its seventeenth media execution and before a
fourth contact sheet uses the pinned core's retained `tile`-filter state.
Queue state, sources and completed results stay in application memory; only
the worker is recreated. A bare core abort also triggers recovery for a retry.
Generated fixtures cover MP4/H.264/AAC, WebM/VP8/Vorbis, WAV/PCM, MP3, FLAC,
metadata, chapters, attached cover art, SRT, WebVTT, ASS and malformed input.
All 15 fixtures were generated/re-probed with native FFmpeg 7.1.3 for test
preparation and pass the repository checksum/probe verifier. Chromium
automation uses the pinned ST core for a broad operation matrix and separately
proves a real MT H.264/AAC conversion. That does not establish cross-browser
support or exhaustive MT parity, and fixture generation is not evidence that
“all formats” work. See
[format support](docs/FORMAT_SUPPORT.md) and
[fixture provenance](tests/fixtures/README.md).
The recorded pinned-core capability inventory contains 341 demuxers, 173
muxers, 515 decoders, 204 encoders and 465 filters (1,698 entries total).
These are discovered capabilities, not product or browser-playback guarantees.
Browser playback is independent of FFmpeg readability. MP4/H.264/AAC,
WebM/VP8/Vorbis, MP3 and WAV are common preview candidates, but actual codec,
browser and platform support is checked at runtime. Matroska and many other
valid outputs may be download-only. A preview proxy may require re-encoding.
The pinned core has an unresolved Opus encoding regression risk (zero-byte or
out-of-bounds behavior reported around upstream stack-size fixes). Opus
presets must stay unavailable unless a real non-empty, re-probeable ST/MT smoke
passes. Details are in [FFmpeg build](docs/FFMPEG_BUILD.md).
## Execution modes
- **Multithread** is selected only in secure, cross-origin-isolated contexts
with `SharedArrayBuffer`.
- **Single-thread compatibility mode** is the automatic fallback when
isolation is unavailable or MT loading fails. It remains fully worker-based
but may be slower.
MT requires every response in the page to support:
```text
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Resource-Policy: same-origin
```
Serve `.wasm` as `application/wasm`, keep all core files same-origin and allow
`'wasm-unsafe-eval'` plus workers in CSP. Blob media preview additionally needs
`media-src 'self' blob:`. The manifest intentionally does **not** require
cross-origin isolation because ST is supported.
## Limits, storage and privacy
ffmpeg.wasm documents a hard **2 GiB input ceiling**. Real browser limits are
often lower because sources, decoded frames, filters and outputs coexist. The
initial application policy warns at 256 MiB input, blocks a single or total
input above 1.5 GiB, warns at a 512 MiB output estimate and blocks an estimated
output above 1 GiB. The in-memory queue accepts at most eight scheduled
operations and runs only one FFmpeg operation at a time. Other defaults bound
clips, streams, dimensions/pixels, thumbnails, contact-sheet cells and runtime.
Refreshing or closing the page discards queued work.
Estimates are not guarantees. Lower resolution, duration, frame rate, pass
count and output count can reduce failure risk. WebAssembly does not offer
desktop FFmpeg performance or hardware encoding.
Source `File` objects and preview/result Blob URLs are temporary and object URLs
are revoked on replacement/cleanup. Per-job FFmpeg files/mounts are deleted;
cancellation discards the engine filesystem. The mounted application currently
uses IndexedDB for validated user presets; project JSON is explicitly
saved/loaded by the user and the operation queue remains in memory. Optional
OPFS caching is limited to regenerable derivatives and is clearable; source
bytes are **not persisted by default**. OPFS belongs to the current origin, so
changing scheme/host/port does not move cached data. See
[storage](docs/STORAGE.md).
Imported media, names, tags and subtitles are untrusted. They are rendered as
text, paths are generated, operations build argument arrays rather than shell
commands, logs are bounded/redacted, and extracted URLs are inert. See
[security](docs/SECURITY.md).
## Development
Requirements: Node.js 22+, npm matching the lockfile, and a modern browser.
Native FFmpeg is a development/test dependency for fixture regeneration and
the conditional native command-plan integration suite; it is not a production
runtime dependency.
```sh
git clone git@git.add-ideas.de:zemion/av-tools.git
cd av-tools
npm ci
npm run dev
```
The token-free `.npmrc` selects the published Toolbox SDK scope. Do not add
credentials to repository configuration.
Core assets are generated local build inputs:
```sh
npm run vendor:ffmpeg
npm run vendor:verify
```
Regenerate or verify the tiny synthetic fixtures:
```sh
npm run fixtures:generate
npm run fixtures:verify
```
## Checks
```sh
npm run manifest:check
npm run vendor:verify
npm run typecheck
npm run lint
npm run format:check
npm test
npm run build
npm run toolbox:check
npm run test:browser
```
`npm run check` combines the non-browser production checks. Browser tests cover
the real pinned cores, MT-to-ST fallback, cancellation/recovery, generated
fixtures, nested deployment, Toolbox context and the absence of runtime CDN
traffic.
The latest recorded local Vitest run on 2026-07-24 completed **303 passing
tests and one conditional skip**. It includes broad structured-command
execution against native FFmpeg 7.1.3; the skipped native subtitle burn-in case
requires a `subtitles` filter unavailable in that host build.
The Chromium suite exercises the pinned ST core across conversion,
trim/concat/split, analysis, waveform, image, metadata, chapter, subtitle,
queue and recovery workflows. The MT core is directly initialized and queried,
and completes a real non-empty, re-probeable H.264/AAC conversion; the same
path is benchmarked with 2-second 160×90 and 6-second 640×360 inputs. Nested
static/Toolbox-context loading is also covered. This evidence is
Chromium-specific: Firefox, Safari, mobile and exhaustive MT operation parity
remain unrecorded. The exact one-run performance record and its limitations are
in [performance observations](docs/PERFORMANCE.md).
## Deployment
Deploy the contents of `dist/` beneath any static path; URLs are base-relative
and no server route fallback is required. Production examples:
- [Nginx](deployment/nginx.example.conf)
- [Caddy](deployment/caddy.example)
- [generic static headers](deployment/headers.md)
The policy permits same-origin WebAssembly and Blob workers/media without
broad `'unsafe-eval'`. Keep stable entry/manifest/notices revalidated. Exact
versioned core files and Vite content-hashed assets may be cached immutable.
When isolation headers cannot be configured, ST remains the supported
fallback.
## Toolbox integration
The one typed `de.add-ideas.av-tools` manifest generates
`public/toolbox-app.json`. `AppShell` receives the relative
`./toolbox-app.json` URL and falls back to standalone behavior when Toolbox
context is absent or invalid. av-tools does not duplicate suite
personalization or app switching.
The manifest uses `entry: "./"`, relative icon/assets, top-level navigation,
local processing/no upload/no telemetry, and advertises workers, secure
context and IndexedDB. Its `source` object identifies the canonical av-tools
repository and the `GPL-3.0-or-later` SPDX licence.
Portal must consume an independently built ZIP; it must not build or import
av-tools source. Immutable Portal revision
`bda9da044d61c109afd9969d357cc27f76648938` has isolation and WASM CSP
permissions but lacks `media-src blob:`, which blocks Blob previews. The
developer's active Toolbox Portal worktree currently contains the narrow local
`media-src 'self' blob:` patch; that uncommitted worktree state does not change
the immutable reference or release evidence. The exact distinction, smallest
change and fallback are documented in
[Portal requirements](docs/PORTAL_REQUIREMENTS.md).
## Release
After all checks, normal packaging would produce deterministic:
```text
release/av-tools-<version>.zip
release/av-tools-<version>.zip.sha256
```
The ZIP root contains `index.html`, `toolbox-app.json`, `CHANGELOG.md`,
`LICENSES/`, `SOURCE.md`, `THIRD_PARTY_NOTICES.md`, `assets/` and all
`vendor/` core files. It excludes `node_modules`, sources, source maps and
fixtures. Paths are sorted, timestamps/modes normalized and the SHA-256
sidecar is verified.
Normal packaging requires the root GPL licence and matching package/manifest
metadata:
```sh
npm run package:release
npm run portal:smoke -- \
--allow-known-header-gap
```
The header-gap override remains development-only until the Portal CSP change is
committed. Full commands and safety behavior are in
[release documentation](docs/RELEASE.md).
## Licensing
av-tools-owned code and documentation are Copyright © 2026 Albrecht Degering
and licensed **GPL-3.0-or-later**. See [LICENSE](LICENSE). The application
provides a visible licence, source and no-warranty notice in its Help dialog.
Third-party components retain their copyright and licence terms.
The wrapper/util are MIT. Core/core-mt 0.12.10 declare
GPL-2.0-or-later and statically include FFmpeg/external codec code with
additional terms. The branch-based x264/LAME inputs have been resolved to
immutable commits in the source record. A binary publication must include
equivalent access to the reviewed Corresponding Source and notices. See:
- [third-party notices](THIRD_PARTY_NOTICES.md)
- [source record](SOURCE.md)
- [licence texts/status](LICENSES/README.md)
The complete distributed application must not be called MIT-only. Publishing a
binary or hosted JS/WASM build requires equivalent access to its corresponding
source; linking only to a component homepage is not asserted to satisfy that
obligation. Codec patent requirements are separate from copyright licensing.
## Known limitations
The auditable list is in
[known limitations](docs/KNOWN_LIMITATIONS.md). In summary:
- No server transcoder, remote URL input, live capture, DRM or encrypted media.
- No overlapping video tracks, arbitrary compositing, collaboration or
professional nonlinear editing.
- No “all formats,” unlimited file size, desktop performance or hardware
encoding guarantee.
- Stream-copy trim is fast but not promised frame-accurate.
- Re-encoding is not lossless; lossless codec output cannot restore discarded
source information.
- Metadata removal is best effort, not an anonymity guarantee.
- Browser preview supports fewer combinations than FFmpeg.
- MT depends on isolation; ST can still hit memory/time limits.
- Opus output is release-gated for the pinned core.
- Projects do not contain source bytes by default and require reattachment.
- Cross-browser/mobile and exhaustive MT performance matrices remain
unrecorded; published support claims must stay within the tested evidence.
## Roadmap
1. Complete Quick Convert and structural-export browser verification with the
real pinned ST/MT cores, including fallback and queued execution.
2. Verify waveform, loudness normalization and combined filters in browsers.
3. Verify metadata/chapter/subtitle round trips in browsers.
4. Verify bounded thumbnails/contact sheets, projects, derivative-cache reuse
and export in browsers.
5. Complete accessibility, mobile, performance and nested Portal matrices.
6. Commit/tag exact source, publish its reviewed Corresponding Source, assemble
the exact production ZIP through Portal and verify deployed headers.

132
SOURCE.md Normal file
View File

@@ -0,0 +1,132 @@
# Source and reproducibility record
This file identifies the source corresponding to av-tools 0.1.0. It is not a
substitute for satisfying a component's licence or for delivering complete
Corresponding Source with an object-code release.
## av-tools
- Canonical repository:
`git@git.add-ideas.de:zemion/av-tools.git`
- Browser source: `https://git.add-ideas.de/zemion/av-tools`
- Version: `0.1.0`
- Release source identity: immutable annotated tag `v0.1.0`
(`refs/tags/v0.1.0`) in the canonical repository.
- Application licence: `GPL-3.0-or-later`; full GPL version 3 terms in
`LICENSE`.
- Copyright: Copyright © 2026 Albrecht Degering.
The release tag is the source identity because embedding a commit's own hash
inside that commit is impossible. Before publishing binary bytes, verify that
the tag exists, is annotated, points to the reviewed release commit and is
available without authentication:
```sh
test "$(git cat-file -t v0.1.0)" = tag
git rev-parse v0.1.0^{commit}
git ls-remote --tags \
https://git.add-ideas.de/zemion/av-tools.git refs/tags/v0.1.0
```
The Toolbox manifest names the canonical repository and SPDX licence. Publish
the tag/source archive beside the binary release and checksum.
## Pinned npm inputs
`package-lock.json` is authoritative. The FFmpeg-related runtime packages are:
| Package | Exact version | npm integrity |
| ---------------------------- | ------------- | ------------------------------------------------------------------------------------------------- |
| `@ffmpeg/ffmpeg` | 0.12.15 | `sha512-1C8Obr4GsN3xw+/1Ww6PFM84wSQAGsdoTuTWPOj2OizsRDLT4CXTaVjPhkw6ARyDus1B9X/L2LiXHqYYsGnRFw==` |
| `@ffmpeg/util` | 0.12.2 | `sha512-ouyoW+4JB7WxjeZ2y6KpRvB+dLp7Cp4ro8z0HIVpZVCM7AwFlHa0c4R8Y/a4M3wMqATpYKhC7lSFHQ0T11MEDw==` |
| `@ffmpeg/types` (transitive) | 0.12.4 | `sha512-k9vJQNBGTxE5AhYDtOYR5rO5fKsspbg51gbcwtbkw2lCdoIILzklulcjJfIDwrtn7XhDeF2M+THwJ2FGrLeV6A==` |
| `@ffmpeg/core` | 0.12.10 | `sha512-dzNplnn2Nxle2c2i2rrDhqcB19q9cglCkWnoMTDN9Q9l3PvdjZWd1HfSPjCNWc/p8Q3CT+Es9fWOR0UhAeYQZA==` |
| `@ffmpeg/core-mt` | 0.12.10 | `sha512-atyRTOpa58bLCIgd6GXBZAXWyWD3AUoQyzxqjvGhp9MuSzdILtOTI62ffLswBsCnLq15lQ8IETHUpm1oe4V9FQ==` |
Install exact packages and recreate the copied same-origin assets without a
network fetch during the production build:
```sh
npm ci
npm run vendor:ffmpeg
npm run vendor:verify
npm run build
```
`scripts/copy-ffmpeg-assets.mjs` copies the ESM distributions from the
installed core packages; it does not download them. The copied bytes are:
| Release path | Bytes | SHA-256 |
| ------------------------------------------------ | -------: | ------------------------------------------------------------------ |
| `vendor/ffmpeg/0.12.10/st/ffmpeg-core.js` | 111804 | `67a48f11645f85439f3fde4f2119042c16b374b910206b7a7a24f342e28dcae3` |
| `vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm` | 32232419 | `9f57947a5bd530d8f00c5b3f2cb2a3492faa7e5d823315342d6a8656d0a6b7b7` |
| `vendor/ffmpeg/0.12.10/mt/ffmpeg-core.js` | 128947 | `270a2e6ff945e173238610669a3f7132df5f9c52698a9bf708cf5c2ab6bda0de` |
| `vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm` | 32718323 | `be2c97605366b78f3f13e21b52e81a55a79e1f29c133b03a68ec187b1a2ec41a` |
| `vendor/ffmpeg/0.12.10/mt/ffmpeg-core.worker.js` | 2115 | `f77898d631dc010b45c29c23cb4379c611a7d7b131bf591d08a656bb729a4ca3` |
## Corresponding ffmpeg.wasm and FFmpeg source
- Upstream ffmpeg.wasm release tag containing `@ffmpeg/ffmpeg` 0.12.15,
`@ffmpeg/core` 0.12.10 and `@ffmpeg/core-mt` 0.12.10:
`v12.15` at `71aa99d37c02a7b4c435275ca9ef50e612f6efa1`.
- FFmpeg source selected by that revision's `Dockerfile`: tag `n5.1.4`,
peeled Git commit `4729204c17f756e186d622060088371d10b34f7e`.
- Emscripten image selected upstream: `emscripten/emsdk:3.1.40`.
Obtain and inspect those sources:
```sh
git clone https://github.com/ffmpegwasm/ffmpeg.wasm.git
git -C ffmpeg.wasm checkout 71aa99d37c02a7b4c435275ca9ef50e612f6efa1
git clone https://git.ffmpeg.org/ffmpeg.git ffmpeg
git -C ffmpeg checkout 4729204c17f756e186d622060088371d10b34f7e
```
The upstream build control files are `Dockerfile`, `Makefile`,
`build/ffmpeg.sh`, `build/ffmpeg-wasm.sh` and the other scripts under
`build/` at the ffmpeg.wasm release revision. `make prd` builds single-thread;
`make prd-mt` builds multithread.
The Dockerfile uses branch names for two inputs. Repository history resolves
those refs to tips that predate the ffmpeg.wasm v12.15 release and have not
changed since:
- x264 `4-cores`:
`33cac6b77d5b9259c552156013a817ab23119612`;
- x265 `3.4`:
`2bb5520e9596f361bf0ed81b3b8da0d7fd999069`;
- LAME `master`:
`2badea1974ae36cb8312afe99cff1e6b3b5decee`.
Use those immutable commits when assembling Corresponding Source:
```sh
git clone https://github.com/ffmpegwasm/x264.git
git -C x264 checkout 33cac6b77d5b9259c552156013a817ab23119612
git clone https://github.com/ffmpegwasm/x265.git
git -C x265 checkout 2bb5520e9596f361bf0ed81b3b8da0d7fd999069
git clone https://github.com/ffmpegwasm/lame.git
git -C lame checkout 2badea1974ae36cb8312afe99cff1e6b3b5decee
```
`scripts/ffmpeg-source-lock.json` records every source selected by the
upstream build as an immutable Git commit. Create the deterministic
Corresponding Source bundle and sidecar with:
```sh
npm run source:package
```
The command writes
`release/ffmpeg-core-0.12.10-corresponding-source.tar.xz` and its
`.sha256` sidecar. Pass `-- --force` only to replace those exact outputs.
The npm packages contain the resulting binaries but not every linked
component's source and legal file, so publish this reviewed bundle and the
notices beside the object code. A homepage link alone is not asserted to
satisfy GPL source obligations.
See `docs/FFMPEG_BUILD.md` for the observed version/configuration and
`THIRD_PARTY_NOTICES.md` for component roles and terms.

97
THIRD_PARTY_NOTICES.md Normal file
View File

@@ -0,0 +1,97 @@
# Third-party notices
av-tools 0.1.0 is Copyright © 2026 Albrecht Degering and licensed
GPL-3.0-or-later; see the root `LICENSE`. This inventory describes third-party
inputs. It is informational, not legal advice, and does not replace their
terms. Files under `LICENSES/` reproduce available terms. A packaged ZIP also
receives licence files shipped by installed runtime npm packages under
`LICENSES/npm/`.
## Browser runtime
| Package | Version | Licence declared upstream | Project source | Role | Distribution |
| -------------------------------- | ------- | ------------------------- | --------------------------------------------- | ------------------------------------------------------------- | -------------------------------------- |
| `@add-ideas/toolbox-contract` | 0.2.2 | Apache-2.0 | `https://git.add-ideas.de/zemion/toolbox-sdk` | typed manifest/context contract | shipped, bundled |
| `@add-ideas/toolbox-shell-react` | 0.2.2 | Apache-2.0 | `https://git.add-ideas.de/zemion/toolbox-sdk` | shared Toolbox application shell | shipped, bundled |
| `@ffmpeg/ffmpeg` | 0.12.15 | MIT | `https://github.com/ffmpegwasm/ffmpeg.wasm` | browser worker and high-level FFmpeg API | shipped, bundled |
| `@ffmpeg/util` | 0.12.2 | MIT | `https://github.com/ffmpegwasm/ffmpeg.wasm` | byte/blob helpers | shipped, bundled |
| `@ffmpeg/types` | 0.12.4 | MIT | `https://github.com/ffmpegwasm/ffmpeg.wasm` | transitive runtime API types | shipped as compiled/bundled references |
| `@ffmpeg/core` | 0.12.10 | GPL-2.0-or-later package | `https://github.com/ffmpegwasm/ffmpeg.wasm` | single-thread FFmpeg JavaScript and WebAssembly | shipped verbatim as self-hosted assets |
| `@ffmpeg/core-mt` | 0.12.10 | GPL-2.0-or-later package | `https://github.com/ffmpegwasm/ffmpeg.wasm` | multithread FFmpeg JavaScript, WebAssembly and pthread worker | shipped verbatim as self-hosted assets |
| `fflate` | 0.8.3 | MIT | `https://github.com/101arrowz/fflate` | in-browser ZIP creation | shipped, bundled |
| `react` | 19.2.7 | MIT | `https://github.com/facebook/react` | user-interface runtime | shipped, bundled |
| `react-dom` | 19.2.7 | MIT | `https://github.com/facebook/react` | browser rendering | shipped, bundled |
| `scheduler` | 0.27.0 | MIT | `https://github.com/facebook/react` | transitive React scheduling runtime | shipped, bundled |
The MIT text inspected in the ffmpeg.wasm source is
`LICENSES/ffmpeg.wasm-MIT.txt`. The core npm tarballs do not contain a licence
file, but their package manifests declare `GPL-2.0-or-later`;
`LICENSES/GPL-2.0-or-later.txt` reproduces GPL version 2. These notices do not
make the complete application “MIT-only.”
## Native components linked into the WebAssembly cores
The following list is derived from the `v12.15` ffmpeg.wasm Dockerfile, the
configuration embedded in the copied 0.12.10 WASM and the resolved histories
of its source repositories. Branch-based inputs are recorded below as exact
commits. The combined core is GPL-enabled. Individual projects retain their
own terms, notices, patent considerations and corresponding-source
requirements.
| Component | Upstream build ref | Upstream term(s) to review | Source selected by the build | Role | Distribution |
| --------- | --------------------------------------------------- | ------------------------------------------------------ | ----------------------------------------- | -------------------------------------------------- | -------------------------- |
| FFmpeg | n5.1.4 (`4729204c17f756e186d622060088371d10b34f7e`) | LGPL-2.1-or-later; GPL features enabled for this build | `https://github.com/FFmpeg/FFmpeg` | demuxing, decoding, filtering, encoding and muxing | shipped in both WASM cores |
| x264 | `33cac6b77d5b9259c552156013a817ab23119612` | GPL-2.0-or-later/commercial alternatives | `https://github.com/ffmpegwasm/x264` | H.264 encoding | shipped, statically linked |
| x265 | `2bb5520e9596f361bf0ed81b3b8da0d7fd999069` | GPL-2.0-or-later/commercial alternatives | `https://github.com/ffmpegwasm/x265` | HEVC encoding | shipped, statically linked |
| libvpx | v1.13.1 | BSD-3-Clause | `https://github.com/ffmpegwasm/libvpx` | VP8/VP9 codecs | shipped, statically linked |
| LAME | `2badea1974ae36cb8312afe99cff1e6b3b5decee` | LGPL-2.0-or-later | `https://github.com/ffmpegwasm/lame` | MP3 encoding | shipped, statically linked |
| Ogg | v1.3.4 | BSD-3-Clause | `https://github.com/ffmpegwasm/Ogg` | Ogg framing | shipped, statically linked |
| Theora | v1.1.1 | BSD-3-Clause | `https://github.com/ffmpegwasm/theora` | Theora codec | shipped, statically linked |
| Opus | v1.3.1 | BSD-3-Clause | `https://github.com/ffmpegwasm/opus` | Opus codec | shipped, statically linked |
| Vorbis | v1.3.3 | BSD-3-Clause | `https://github.com/ffmpegwasm/vorbis` | Vorbis codec | shipped, statically linked |
| zlib | v1.2.11 | Zlib | `https://github.com/ffmpegwasm/zlib` | DEFLATE support | shipped, statically linked |
| libwebp | v1.3.2 | BSD-3-Clause | `https://github.com/ffmpegwasm/libwebp` | WebP codec/muxing | shipped, statically linked |
| FreeType | VER-2-10-4 | FTL or GPL-2.0-only | `https://github.com/ffmpegwasm/freetype2` | font rendering | shipped, statically linked |
| FriBidi | v1.0.9 | LGPL-2.1-or-later | `https://github.com/fribidi/fribidi` | bidirectional text | shipped, statically linked |
| HarfBuzz | 5.2.0 | MIT | `https://github.com/harfbuzz/harfbuzz` | text shaping | shipped, statically linked |
| libass | 0.15.0 | ISC | `https://github.com/libass/libass` | ASS/SSA subtitle rendering | shipped, statically linked |
| zimg | release-3.0.5 | WTFPL-2.0 | `https://github.com/sekrit-twc/zimg` | image scaling/conversion | shipped, statically linked |
The publisher must make the reviewed Corresponding Source bundle and external
notices available with the core assets and assess applicable patent/licensing
constraints. Exact upstream legal files for these native components are
included under `LICENSES/native/` in both the source tree and production ZIP.
`SOURCE.md` records the immutable source identities and retrieval instructions.
## Development-only packages
These direct packages support building or testing and are not intentionally
shipped in the static ZIP:
| Package | Exact version | Licence | Role |
| ----------------------------- | ------------- | ---------- | ------------------------------------- |
| `@add-ideas/toolbox-testkit` | 0.2.2 | Apache-2.0 | manifest and nested-path test helpers |
| `@eslint/js` | 10.0.1 | MIT | lint rules |
| `@playwright/test` | 1.61.1 | Apache-2.0 | browser tests |
| `@testing-library/jest-dom` | 6.9.1 | MIT | DOM assertions |
| `@testing-library/react` | 16.3.2 | MIT | component tests |
| `@testing-library/user-event` | 14.6.1 | MIT | interaction tests |
| `@types/node` | 25.9.5 | MIT | Node type declarations |
| `@types/react` | 19.2.17 | MIT | React type declarations |
| `@types/react-dom` | 19.2.3 | MIT | React DOM type declarations |
| `@vitejs/plugin-react` | 6.0.3 | MIT | Vite React transform |
| `eslint` | 10.7.0 | MIT | static analysis |
| `eslint-plugin-react-hooks` | 7.1.1 | MIT | hooks lint rules |
| `eslint-plugin-react-refresh` | 0.5.2 | MIT | refresh lint rules |
| `globals` | 17.7.0 | MIT | lint environment data |
| `jsdom` | 29.1.1 | MIT | unit-test DOM |
| `prettier` | 3.9.5 | MIT | formatting |
| `typescript` | 6.0.3 | Apache-2.0 | type checking |
| `typescript-eslint` | 8.64.0 | MIT | TypeScript lint support |
| `vite` | 8.1.5 | MIT | development and production bundling |
| `vitest` | 4.1.10 | MIT | unit/integration tests |
Native FFmpeg 7.1.3 was development-only for fixture generation. It and its
host libraries are not an application runtime dependency and are not included
in the release ZIP. Generated fixture data is CC0-1.0 as described in
`tests/fixtures/README.md`.

25
deployment/caddy.example Normal file
View File

@@ -0,0 +1,25 @@
tools.example.test {
header {
X-Content-Type-Options "nosniff"
Referrer-Policy "no-referrer"
X-Frame-Options "DENY"
Cross-Origin-Opener-Policy "same-origin"
Cross-Origin-Embedder-Policy "require-corp"
Cross-Origin-Resource-Policy "same-origin"
Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()"
Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; connect-src 'self'; img-src 'self' blob: data:; media-src 'self' blob:; font-src 'self'; style-src 'self'; object-src 'none'; frame-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'"
Cache-Control "no-cache"
}
@wasm path_regexp wasm \.wasm$
header @wasm Content-Type "application/wasm"
@immutable path_regexp immutable ^/av/(?:vendor/ffmpeg/0\.12\.10/(?:st|mt)/ffmpeg-core(?:\.worker)?\.(?:js|wasm)|assets/.*-[A-Za-z0-9_-]{8,}\.[^/]+)$
header @immutable Cache-Control "public, max-age=31536000, immutable"
redir /av /av/ 308
handle_path /av/* {
root * /srv/av-tools
file_server
}
}

76
deployment/headers.md Normal file
View File

@@ -0,0 +1,76 @@
# Static-hosting headers
Serve the release over HTTPS. Apply these headers to the app entry, wrapper
worker, both core modes and every generated result page:
```text
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Resource-Policy: same-origin
X-Content-Type-Options: nosniff
Referrer-Policy: no-referrer
X-Frame-Options: DENY
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()
Content-Security-Policy: default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; connect-src 'self'; img-src 'self' blob: data:; media-src 'self' blob:; font-src 'self'; style-src 'self'; object-src 'none'; frame-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'
```
The multithread core requires all three cross-origin headers. Without them the
app must select the single-thread core; the rest of the app still works. Never
add broad `'unsafe-eval'`: WebAssembly needs only `'wasm-unsafe-eval'`.
Response types:
```text
*.wasm Content-Type: application/wasm
*.js Content-Type: text/javascript
*.mjs Content-Type: text/javascript
*.json Content-Type: application/json
```
All FFmpeg JS/WASM/worker files must be same-origin. A CDN requires careful
cross-origin resource policy and is outside the supported deployment profile.
Use `no-cache` for `index.html`, `toolbox-app.json`, notices and other stable
names. The files under the exact version directory
`vendor/ffmpeg/0.12.10/{st,mt}/` are content-verified and may use:
```text
Cache-Control: public, max-age=31536000, immutable
```
Vite files whose names include their content hash under `assets/` may use the
same immutable policy. Do not make mutable or unversioned files immutable.
For a host with a `_headers`-style configuration, adapt:
```text
/av/*
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Resource-Policy: same-origin
X-Content-Type-Options: nosniff
Referrer-Policy: no-referrer
X-Frame-Options: DENY
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()
Content-Security-Policy: default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; connect-src 'self'; img-src 'self' blob: data:; media-src 'self' blob:; font-src 'self'; style-src 'self'; object-src 'none'; frame-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'
Cache-Control: no-cache
/av/vendor/ffmpeg/0.12.10/*
Cache-Control: public, max-age=31536000, immutable
/av/assets/*
Cache-Control: public, max-age=31536000, immutable
```
Confirm the provider can override `.wasm` to `application/wasm`; many
`_headers` formats set headers but cannot correct MIME mappings. Validate a
real response rather than relying only on configuration:
```sh
curl -sSI https://tools.example.test/av/ | sed -n '1,30p'
curl -sSI https://tools.example.test/av/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm | sed -n '1,30p'
```
In browser developer tools, also verify `window.crossOriginIsolated === true`,
that core URLs remain below the nested `/av/` path, and that no runtime request
targets a CDN.

View File

@@ -0,0 +1,63 @@
# Complete server example. Place av-tools at /srv/www/av/ to serve it from
# https://tools.example.test/av/. Adjust only the hostname and filesystem root.
map $uri $av_tools_cache_control {
default "no-cache";
"~^/(?:.*/)?vendor/ffmpeg/0\.12\.10/(?:st|mt)/ffmpeg-core(?:\.worker)?\.(?:js|wasm)$" "public, max-age=31536000, immutable";
"~^/(?:.*/)?assets/.*-[A-Za-z0-9_-]{8,}\.[^/]+$" "public, max-age=31536000, immutable";
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name tools.example.test;
root /srv/www;
# Configure ssl_certificate/ssl_certificate_key or the surrounding proxy.
server_tokens off;
charset utf-8;
etag on;
# Explicit types avoid serving WASM or ES modules as octet-stream.
types {
text/html html;
text/css css;
text/javascript js mjs;
application/json json;
application/wasm wasm;
image/svg+xml svg;
image/png png;
image/jpeg jpg jpeg;
image/gif gif;
image/webp webp;
image/x-icon ico;
font/woff woff;
font/woff2 woff2;
}
default_type application/octet-stream;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header X-Frame-Options "DENY" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; connect-src 'self'; img-src 'self' blob: data:; media-src 'self' blob:; font-src 'self'; style-src 'self'; object-src 'none'; frame-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'" always;
add_header Cache-Control $av_tools_cache_control always;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types application/json application/wasm image/svg+xml text/css text/javascript text/plain;
location = /av {
return 308 /av/;
}
# No SPA fallback is needed: all release resources are real static files.
location /av/ {
try_files $uri $uri/ =404;
}
}

141
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,141 @@
# Architecture
av-tools is a static, local-first React application. The browser receives code
and the two self-hosted FFmpeg cores; media bytes do not leave the origin
through an application backend. Toolbox Portal assembles the already-built
artifact and never compiles its source.
## Layers and boundaries
```text
Toolbox AppShell / standalone context
└─ React application
├─ Quick Convert and sequential Edit workflows
├─ project + typed preset state
├─ media inspection / preview / result views
├─ bounded application queue + resource policy
└─ typed command plans
└─ EngineManager
├─ @ffmpeg/ffmpeg wrapper worker
├─ ST or MT same-origin core
└─ per-job virtual filesystem
Browser storage boundary
├─ object URLs (ephemeral previews)
├─ IndexedDB (validated user presets)
└─ OPFS cache (optional derivatives; never source in the current app)
```
- `src/toolbox/` owns the one typed manifest and suite integration.
- `src/media/` normalizes ffprobe JSON, duration/timecode, stream selection,
playback MIME decisions and safe names.
- `src/project/` owns the versioned, non-destructive project document and
migrations. A source is represented by identity/metadata and must be
reattached after load; the current application does not persist source bytes.
- `src/presets/` defines typed built-ins and validates user presets against
detected capabilities.
- `src/commands/` converts validated operations to a `CommandPlan`. A plan has
generated input/output paths, argument arrays, expected outputs,
capabilities and cleanup intent; it is not a shell string.
- `src/ffmpeg/` selects/loads the engine, discovers capabilities, mounts
inputs, executes one job, reads outputs, sanitizes diagnostics and cleans.
- `src/jobs/` provides typed queue, progress, cancellation, recovery and
durable-metadata infrastructure. The mounted application scheduler is a
bounded in-memory promise chain around `EngineManager`.
- `src/waveform/`, `src/storage/` and `src/export/` keep bounded analysis,
persistence and browser-download responsibilities outside the UI.
The DOM receives normalized values as text/attributes. It never renders media
metadata as HTML. Command builders never accept arbitrary virtual paths or a
raw command field.
## FFmpeg lifecycle
1. Inspect secure-context, worker, `crossOriginIsolated` and
`SharedArrayBuffer` availability.
2. Select MT only when all MT prerequisites are true (or ST when explicitly
selected). A failed MT load is terminated and retried once with ST.
3. Load wrapper/core assets via base-relative, same-origin URLs.
4. Query version/build configuration/formats/codecs/encoders/filters
sequentially and publish immutable capability state.
5. For one job, validate resource estimates and structured settings, allocate
a generated directory, mount/write inputs, run a generated argument array,
read only declared outputs, and re-probe applicable results.
6. Return result bytes and an export report, then unmount/delete virtual files.
7. Retain the idle engine for the next sequential job; terminate it on mode
changes, unrecoverable errors or cancellation.
The FFmpeg instance is a single process. `maxConcurrentFFmpegJobs` is one and
no probe/export operations overlap on it. The application-facing scheduler is
also bounded: it retains at most eight scheduled operations in memory,
including the active operation, and advances its promise chain after either
success or failure. Queue state is deliberately not persisted across a page
reload.
## Project and edit model
A project is versioned JSON containing source descriptors, a sequential clip
timeline, non-destructive clip in/out and transform/audio settings, stream
selection, metadata/chapter/subtitle edits and export preset references. Clips
do not overlap. There is no arbitrary compositing graph, collaborative state or
server-side document.
The structural-export panel maps the selected clip's current range to either a
stream-copy or preset-backed trim plan. It maps the visible timeline order to
either a compatibility-checked stream-copy concat plan or a normalized
re-encode plan. Normalized concat requires the user to choose what happens when
some clips lack audio; unchecked fast compatibility fails closed.
Migrations are explicit version-to-version functions. Unknown future versions,
invalid shapes and unsafe source references fail with actionable validation
errors. Source bytes are not embedded in normal project JSON. Reattachment
matches stable descriptors and asks the user before associating a local file.
## Command plans and filesystem
Plans use arrays such as `['-i', generatedInput, ...]`; no shell evaluates
them. Filtergraph builders escape values for their exact FFmpeg grammar.
Generated paths contain a job id plus bounded safe basename and never reuse a
user-controlled directory. Expected output paths are declared before
execution, so the result reader cannot enumerate arbitrary output.
Small inputs may be copied into FFmpeg's in-memory filesystem. Where supported,
WORKERFS mounts `File`/`Blob` inputs to avoid an initial JavaScript copy; it
does not remove WASM address-space/output constraints. OPFS is optional for
regenerable derivatives and uses asynchronous origin-private file handles with
an in-memory fallback. See `docs/STORAGE.md`.
## Result flow
Output bytes become a Blob with an operation-derived filename and MIME type.
The UI creates a short-lived object URL only when the browser can preview that
container/codec, exposes an explicit save/download action, and includes probe
data plus warnings in the export report. Revoking a preview does not invalidate
the retained Blob until the result is cleared. Batch derivatives may be
assembled into a bounded ZIP.
For unsupported direct playback, a preview proxy is a disposable H.264/AAC MP4
derivative rather than a source mutation. Duration and maximum width are
bounded by validated user controls. Its cache identity includes the source
fingerprint and proxy settings, so only an exact matching derivative is reused;
cache failure falls back to normal generation.
## Cancellation and recovery
ffmpeg.wasm cancellation terminates the active wrapper/core worker rather than
pretending a native FFmpeg process can receive a signal. The job becomes
cancelled, outstanding promises are rejected, object URLs/mounts are released
where possible, and that engine generation is discarded. The next job creates
a fresh engine and re-runs capability discovery. A cancellation is complete
only after the stale worker cannot publish a result.
## Integration boundaries
`AppShell` owns suite chrome, theme/personalization, help and app switching.
av-tools passes `manifestUrl="./toolbox-app.json"` and works when context is
missing, malformed or unavailable. It does not embed another switcher.
The manifest advertises secure context, workers, IndexedDB and top-level
navigation. It does not require cross-origin isolation because ST is a valid
fallback. Portal receives only the deterministic ZIP/checksum and places it
under a locked target such as `apps/av/`.

143
docs/FFMPEG_BUILD.md Normal file
View File

@@ -0,0 +1,143 @@
# FFmpeg WebAssembly build record
av-tools ships the published ESM assets from `@ffmpeg/core` and
`@ffmpeg/core-mt` **0.12.10**. The browser wrapper is `@ffmpeg/ffmpeg`
**0.12.15** and helpers are `@ffmpeg/util` **0.12.2**. All versions are exact,
not ranges.
## Underlying build
The matching ffmpeg.wasm upstream release is `v12.15` at
`71aa99d37c02a7b4c435275ca9ef50e612f6efa1`. Its Dockerfile selects:
- FFmpeg **n5.1.4** (libavcodec 59.37.100);
- Emscripten SDK **3.1.40**;
- production `-O3 -msimd128`;
- pthread flags for the MT build and explicit thread disabling for ST.
The build configuration embedded in both copied WASM files is:
```text
--target-os=none
--arch=x86_32
--enable-cross-compile
--disable-asm
--disable-stripping
--disable-programs
--disable-doc
--disable-debug
--disable-runtime-cpudetect
--disable-autodetect
--nm=emnm
--ar=emar
--ranlib=emranlib
--cc=emcc
--cxx=em++
--objcc=emcc
--dep-cc=emcc
--extra-cflags='-I/opt/include -O3 -msimd128'
--extra-cxxflags='-I/opt/include -O3 -msimd128'
--enable-gpl
--enable-libx264
--enable-libx265
--enable-libvpx
--enable-libmp3lame
--enable-libtheora
--enable-libvorbis
--enable-libopus
--enable-zlib
--enable-libwebp
--enable-libfreetype
--enable-libfribidi
--enable-libass
--enable-libzimg
```
The ST string also has `--disable-pthreads --disable-w32threads
--disable-os2threads`; the MT build supplies Emscripten pthread flags instead.
At runtime the application executes `-version`, `-buildconf`, `-formats`,
`-codecs`, `-encoders` and `-filters` sequentially. Presets are enabled only
when the active core reports every required muxer, encoder and filter. Build
flags alone are not treated as a capability guarantee.
## External libraries
The upstream build references x264 `4-cores`, x265 3.4, libvpx v1.13.1, LAME
`master`, Ogg v1.3.4, Theora v1.1.1, Opus v1.3.1, Vorbis v1.3.3, zlib v1.2.11,
libwebp v1.3.2, FreeType VER-2-10-4, FriBidi v1.0.9, HarfBuzz 5.2.0, libass
0.15.0 and zimg release-3.0.5. These resolve to x264
`33cac6b77d5b9259c552156013a817ab23119612`, x265
`2bb5520e9596f361bf0ed81b3b8da0d7fd999069` and LAME
`2badea1974ae36cb8312afe99cff1e6b3b5decee`. All three commits predate the
ffmpeg.wasm v12.15 release; the branch-based x264/LAME commits remain the
respective branch tips. `SOURCE.md` records retrieval commands.
## Shipped files
| Mode | File | Bytes | SHA-256 |
| ---- | -------------------------- | -------: | ------------------------------------------------------------------ |
| ST | `st/ffmpeg-core.js` | 111804 | `67a48f11645f85439f3fde4f2119042c16b374b910206b7a7a24f342e28dcae3` |
| ST | `st/ffmpeg-core.wasm` | 32232419 | `9f57947a5bd530d8f00c5b3f2cb2a3492faa7e5d823315342d6a8656d0a6b7b7` |
| MT | `mt/ffmpeg-core.js` | 128947 | `270a2e6ff945e173238610669a3f7132df5f9c52698a9bf708cf5c2ab6bda0de` |
| MT | `mt/ffmpeg-core.wasm` | 32718323 | `be2c97605366b78f3f13e21b52e81a55a79e1f29c133b03a68ec187b1a2ec41a` |
| MT | `mt/ffmpeg-core.worker.js` | 2115 | `f77898d631dc010b45c29c23cb4379c611a7d7b131bf591d08a656bb729a4ca3` |
`npm run vendor:ffmpeg` copies these files from `node_modules` to
`public/vendor/ffmpeg/0.12.10/`; `npm run vendor:verify` checks versions,
sizes, paths and digests. Vite then copies them unchanged into `dist`. A normal
production build performs no network download.
## Multithread Chromium guardrail
The pinned multithread core can stall in Chromium when FFmpeg lets codecs use
the browser's full reported logical-core count. This is tracked upstream in
[ffmpeg.wasm issue 654](https://github.com/ffmpegwasm/ffmpeg.wasm/issues/654).
av-tools keeps the pthread core but adds `-threads 2` as an input and output
codec option for MT jobs. ST arguments remain unchanged. A regular browser
test performs an actual MT H.264/AAC conversion; loading the core alone is not
accepted as functional evidence.
Every execution also has a watchdog: an explicit shorter plan timeout wins;
otherwise the timeout is at least two minutes, scales to thirty times the
expected media duration, and is capped at the displayed four-hour product
limit. Timeout is still a failure, not a successful partial export.
## Long-session heap guardrail
The pinned Emscripten core was observed to end a later heterogeneous browser
workflow with a bare `Aborted()` even though the same command passed in a
fresh core and all per-job virtual files had been removed. Its `tile` filter
also aborted deterministically on a fourth consecutive contact sheet in local
stress runs. av-tools therefore refreshes an idle core before its seventeenth
media execution and before a fourth contact-sheet execution. Application queue
state, sources and completed results remain outside the worker and are not
discarded. A bare core abort also triggers recreation after cleanup so a
manual or queued retry starts on a fresh instance.
This is a conservative browser-memory guardrail, not a guarantee that sixteen
large jobs or three large contact sheets fit in memory. Cancellation and
failed filesystem-cleanup recovery continue to recreate the core immediately.
## Opus regression risk
Pinned core 0.12.10 predates upstream ffmpeg.wasm fixes associated with stack
size and Opus zero-byte/out-of-bounds failures (including upstream issues
`#591` and `#775`). Therefore:
- decoder/encoder listing is not enough to certify Opus;
- real ST and MT fixture encodes must produce a non-empty, re-probeable output;
- the Opus audio and VP9/Opus presets must remain disabled with a diagnostic
if that smoke fails;
- a core upgrade requires new checksums, capability snapshots, legal review
and regression tests.
This is a known pinned-core risk, not a claim that every Opus job fails.
## Licence consequence
The core packages declare GPL-2.0-or-later and the actual build enables GPL and
links external codecs. The wrapper/util are MIT. av-tools is
GPL-3.0-or-later, and the distributed application must not be described as
MIT-only. Publish the reviewed Corresponding Source and external notices with
the binary release. See `LICENSE`, `LICENSES/`, `THIRD_PARTY_NOTICES.md` and
`SOURCE.md`.

123
docs/FORMAT_SUPPORT.md Normal file
View File

@@ -0,0 +1,123 @@
# Format and feature support
FFmpeg capability is determined from the **loaded core**, not from filename
extensions or this document. The app queries muxers, demuxers, decoders,
encoders and filters; a preset is disabled with missing components listed.
Browser playback is a separate capability checked with MIME/media APIs.
## 0.1.0 candidate fixture matrix
All 15 files are generated and re-probed with native FFmpeg 7.1.3 for test
preparation and pass the checksum/probe verifier. That proves fixture
provenance, not ffmpeg.wasm behavior. A 2026-07-24 Vitest run passed 303 tests
with one conditional native subtitle-filter skip and includes broad execution
of typed plans against native FFmpeg 7.1.3.
Chromium automation also runs a broad operation matrix against the real pinned
ST core. The real MT core is queried and completes an H.264/AAC conversion, but
the complete ST operation matrix has not been duplicated under MT. The
following table distinguishes exact browser evidence from still-pending
coverage; it must not be generalized to Firefox, Safari or mobile.
| Input | Fixture | Expected streams | Browser preview expectation | Recorded pinned-core Chromium evidence |
| ------------------- | ---------------------------------- | -------------------------------- | ------------------------------------------------------------------ | -------------------------------------------------------------- |
| MP4 / H.264 + AAC | `pattern-av.mp4`, compatible clips | H.264 video, AAC audio | commonly playable after `canPlayType`/media load succeeds | broad ST operations; real MT conversion; exhaustive MT pending |
| WebM / VP8 + Vorbis | `pattern.webm` | VP8 video, Vorbis audio | commonly playable in Chromium/Firefox; browser-dependent elsewhere | ST probe, remux and VP8/Vorbis re-encode; MT pending |
| WAV / PCM s16le | `tone.wav` | mono PCM | commonly playable | ST WAV output re-probed; exact input fixture and MT pending |
| MP3 | `tone.mp3` | mono MP3 | commonly playable | ST MP3 outputs re-probed; exact input fixture and MT pending |
| FLAC | `tone.flac` | mono FLAC | browser/version-dependent | ST input and FLAC output re-probed; MT pending |
| MP3 + attached PNG | `attached-cover.mp3` | MP3 audio, attached-picture PNG | audio may play; cover rendering is app-owned | ST probe/stream selection; MT pending |
| MP4 tags | `metadata.mp4` | H.264/AAC | same as MP4 | ST import/removal and output metadata round-trip; MT pending |
| MP4 chapters | `chapters.mp4` | H.264/AAC/data plus two chapters | media may play; browser does not expose all chapter data | ST generated-output chapter round-trip; fixture import pending |
| malformed MP4 | `truncated.mp4` | no usable streams expected | must not preview | ST bounded failure, cleanup and recovery; MT pending |
The runtime query for pinned core 0.12.10 recorded 341 demuxers, 173 muxers,
515 decoders, 204 encoders and 465 filters (1,698 entries total). Runtime
discovery only says that FFmpeg exposes an item; the application must still
build a valid plan, the input must be compatible, resources must fit and the
browser may still be unable to preview the result.
## Output presets
Preset availability always depends on detected runtime requirements:
| Preset family | Required examples | Browser result preview |
| ------------------ | ---------------------------------------- | ------------------------------------------------------ |
| MP4 H.264/AAC | `mp4`, `libx264`, `aac` | usually, but verify actual output |
| WebM VP9/Opus | `webm`, `libvpx-vp9`, `libopus` | browser-dependent; Opus core regression gate applies |
| WebM VP8/Vorbis | `webm`, `libvpx`, `libvorbis` | commonly Chromium/Firefox |
| Matroska H.264/AAC | `matroska`, `libx264`, `aac` | generally download-only in browsers |
| animated WebP | `webp`, `libwebp_anim` | browser-dependent image preview |
| GIF | `gif`, `gif`, `palettegen`, `paletteuse` | broadly image-previewable |
| MP3 | `mp3`, `libmp3lame` | commonly playable |
| M4A/AAC | `ipod`, `aac` | commonly playable |
| Ogg Vorbis | `ogg`, `libvorbis` | browser-dependent |
| Opus | `opus`, `libopus` | browser-dependent; pinned-core regression gate applies |
| WAV/PCM | `wav`, `pcm_s16le` | commonly playable |
| FLAC | `flac`, `flac` | browser-dependent |
“Lossless” applies only to a lossless audio codec such as PCM/FLAC relative to
its decoded samples. It does not make a lossy source lossless. Stream-copy
remux avoids re-encoding compatible streams but may change container metadata.
## Structural editing
- The Edit workspace exposes fast/accurate trim for the selected timeline range
and fast/normalized concat for clips in visible timeline order. Availability
still fails closed against loaded-core capabilities.
- Fast trim uses stream copy where container/stream/timestamp constraints are
compatible. It can begin at a nearby keyframe and is not promised
frame-accurate.
- Accurate trim decodes/re-encodes selected streams and can still be limited by
source timestamps.
- Stream-copy concat requires compatible codec parameters, time bases,
dimensions, sample layout and selected streams; the UI requires a completed
compatibility check before enabling it. Otherwise normalized re-encoding is
required.
- Normalized concat uses an explicit export preset and requires an explicit
insert-silence, drop-all-audio or reject policy when clips have mismatched
audio presence.
- Crop, resize, frame-rate conversion, fades, normalization, burn-in and
contact sheets require specific filters discovered at runtime.
- Even-dimension correction is applied when the selected pixel format/encoder
requires it and is reported in the plan/result.
## Subtitles
The text fixtures cover SRT, WebVTT and ASS import with explicit time shift.
The Chromium ST matrix converts a generated SRT to WebVTT with a time shift,
soft-muxes it into Matroska, burns it into H.264 video and extracts the
soft-muxed stream. The exact standalone WebVTT and ASS fixtures and equivalent
MT paths are not separately browser-smoked. Soft-subtitle muxing depends on
container support (for example converted text in MP4 versus text/ASS in
Matroska). ASS burn-in requires `subtitles`/libass and local font support; no
remote font is fetched. Bitmap subtitle streams are identified but are not
claimed to support arbitrary conversion or OCR. Unsupported container/codec
combinations must remain disabled with reasons.
## Metadata and chapters
Format and stream tags can be inspected. Edits/removal are limited to fields
the selected muxer writes; “remove metadata” is best effort and the output is
re-probed to report remaining structural/encoder/container fields. Stream
language, title and dispositions are separate from format tags.
Chapters use validated integer time bases, monotonic non-overlapping ranges and
escaped titles. Some output containers do not support chapters. Browser media
elements do not reliably expose them, so ffprobe round-trip is authoritative.
## Known unsupported or conditional cases
- remote protocols, live capture, DRM/encrypted media and network playlists;
- hardware-accelerated encoding or desktop-FFmpeg performance;
- files above the 2 GiB WASM input ceiling or stricter product policy;
- arbitrary filters/commands, arbitrary compositing and nonlinear overlap;
- frame-accurate stream-copy trimming;
- complete metadata erasure/anonymity;
- browser preview of every FFmpeg-readable result;
- Opus presets until real non-empty ST/MT encoding smoke passes for core
0.12.10;
- any capability omitted by the active core's runtime query.
Update this file only from re-probed generated/public-domain fixtures and
record the engine mode, browser and exact core version.

81
docs/KNOWN_LIMITATIONS.md Normal file
View File

@@ -0,0 +1,81 @@
# Known limitations
This is the conservative 0.1.0 development baseline. A capability appearing in
FFmpeg output does not remove these product/browser/resource limits.
## Functional
- The editor is a sequential light editor: no overlapping tracks, arbitrary
compositing, collaborative state or professional nonlinear workflow.
- Remote URLs/protocols, live capture, DRM/encrypted media and server-side
transcoding are outside scope.
- Fast stream-copy trim can start at a nearby keyframe; it is not promised
frame-accurate. Accurate mode requires decoding/re-encoding.
- Stream-copy concatenate works only when selected streams and codec/container
parameters are compatible. Other input needs normalized re-encoding.
- Re-encoding is not lossless. PCM/FLAC can preserve decoded audio samples but
cannot restore information already lost in a source.
- Metadata removal is best effort. Muxers may add structural/encoder fields,
unknown structures can remain, and this is not an anonymity guarantee.
- Subtitle support is text/container/filter-dependent. No OCR or universal
bitmap-subtitle conversion is promised; burn-in never fetches remote fonts.
- Projects omit source bytes by default and therefore require local-file
reattachment after reopening.
- The operation queue is bounded to eight scheduled items, runs one FFmpeg
operation at a time and exists only in page memory. Reloading or closing the
page discards queued work.
- A file-generating batch and its managed result collection are bounded to 255
outputs, leaving the 256th ZIP entry available for an optional export report.
- Preview proxies are bounded, lossy H.264/AAC derivatives for local playback;
they do not replace the source or guarantee that every browser can play the
result.
## Format and browser
- There is no “all FFmpeg formats supported” claim. The active core gates
presets from runtime formats/codecs/encoders/filters.
- FFmpeg-readable output may be download-only because browser media elements
support fewer container/codec combinations.
- Browser support, codec policy and memory differ across Chromium, Firefox and
Safari and across desktop/mobile versions.
- Core 0.12.10 has an Opus regression risk. Opus remains release-gated until
non-empty, re-probeable ST and MT fixture outputs pass.
- MT needs HTTPS, COOP/COEP/CORP and `SharedArrayBuffer`. ST fallback works
without isolation but is not a guarantee that a large job fits.
- Top-level navigation is supported; iframe embedding is not.
## Resource and performance
- ffmpeg.wasm documents a 2 GiB input ceiling; the application uses stricter
1.5 GiB hard input/total and 1 GiB estimated-output defaults. Practical
limits may be far lower.
- Only one FFmpeg job runs at once. Core startup downloads/instantiates roughly
32 MiB of WASM and capability discovery adds latency.
- To bound retained native heap state in long heterogeneous sessions, an idle
core is refreshed before its seventeenth media execution and before a fourth
contact sheet. The queue and completed results survive, but that next job
pays core startup and capability discovery latency again.
- WebAssembly can be substantially slower than native FFmpeg and has no
supported hardware encoder.
- Mobile/background-tab throttling, browser quota eviction and page
termination can interrupt a job. Progress is an estimate.
- OPFS/IndexedDB are origin-private, quota-managed and not guaranteed durable.
## Deployment and release
- A Portal deployment must permit `media-src 'self' blob:`, serve WASM as
`application/wasm`, retain the isolation headers and cache only versioned
FFmpeg assets as immutable. These are deployment requirements and require
verification against the live container.
- The application is GPL-3.0-or-later and its native legal files and pinned
source identities are included in the release record. Publish the production
ZIP/checksum, exact `v0.1.0` source and FFmpeg Corresponding Source bundle
together; development-only smoke artifacts must not be distributed.
- Chromium covers a broad real pinned-core ST operation matrix, a real MT
H.264/AAC conversion, MT-to-ST fallback, nested static/Toolbox-context
loading and one two-size ST/MT performance baseline. Firefox, Safari, mobile,
exhaustive MT feature parity and repeated performance measurements remain
unrecorded. Live Portal container/header verification is a deployment gate.
See `docs/FORMAT_SUPPORT.md`, `docs/PERFORMANCE.md`,
`docs/PORTAL_REQUIREMENTS.md` and `docs/RELEASE.md` for testable detail.

81
docs/PERFORMANCE.md Normal file
View File

@@ -0,0 +1,81 @@
# Performance and resource observations
ffmpeg.wasm runs native media code compiled to WebAssembly. It is normally much
slower than desktop FFmpeg and may hold source, decoded frames, filters and
output at once. No hardware encoder is used.
## Fixed download footprint
Uncompressed self-hosted core assets for 0.12.10:
| Mode | JavaScript/worker | WASM | Total |
| ------------- | ------------------: | -----------: | -----------: |
| single-thread | 111,804 B | 32,232,419 B | 32,344,223 B |
| multithread | 128,947 B + 2,115 B | 32,718,323 B | 32,849,385 B |
Only the selected mode is loaded. HTTP compression may reduce JavaScript
transfer but not necessarily WASM to the same degree; report transfer and
decoded sizes separately. Versioned core assets may be cached immutably.
## Benchmark record
One opt-in pinned-core baseline was recorded on 2026-07-24 with Chrome for
Testing 149.0.7827.55 (Playwright 1.61.1), Linux 7.0.0-28 on Ubuntu 24.04,
Intel Core i7-8700 (6 cores/12 threads) and 31 GiB RAM.
`navigator.hardwareConcurrency` reported 12 and every sample was
cross-origin-isolated. Each row used a fresh browser context; the first core
fetch may populate the browser HTTP cache, so later rows are explicitly
potentially warm-cache observations.
Preset: MP4, H.264/libx264 CRF 23 medium, AAC. Times are milliseconds.
“Export end-to-end” spans the Convert click through result construction,
cleanup, verification re-probe and visible result. The three narrower engine
columns come from the export validation report.
| Source | Input bytes | Mode | Init | Probe | FFmpeg exec | Output read | Cleanup | Export end-to-end | Output bytes | Observed JS heap |
| --------------------------------------- | ----------: | ----------------- | ----: | ----: | ----------: | ----------: | ------: | ----------------: | -----------: | ---------------: |
| 2 s, 160×90 H.264/AAC synthetic pattern | 34,057 | ST | 294.4 | 131.4 | 315.6 | 1.0 | 1.2 | 422.4 | 68,700 | 10,000,000 B |
| same | 34,057 | MT (`-threads 2`) | 914.5 | 126.4 | 294.3 | 1.3 | 1.9 | 416.8 | 68,753 | 10,000,000 B |
| 6 s, 640×360 H.264/AAC synthetic loop | 526,632 | ST | 270.2 | 163.6 | 6,854.1 | 1.3 | 0.8 | 7,030.9 | 585,981 | 15,200,000 B |
| same | 526,632 | MT (`-threads 2`) | 946.3 | 121.6 | 4,053.1 | 1.6 | 0.5 | 4,497.5 | 585,884 | 11,200,000 B |
Command:
```sh
npm run benchmark:browser
```
Result: **1 passed (18.2 s)**; test body 17.6 s. Chromium exposed
`performance.memory.usedJSHeapSize`, but this coarse post-operation value is
not a peak-WASM-memory measurement. One run is not a statistical distribution;
cold-cache repetitions, median/range, mobile and other-browser measurements
remain release-quality evidence gaps. Never present this workstation as a
universal promise.
## Measurement boundaries
- initialization: wrapper construction through successful capability query;
- probe: input mount/write through parsed ffprobe JSON;
- conversion: accepted command through exit code;
- output read: exit through Blob/result construction;
- cleanup: final result read through job directory unmount/delete;
- cancellation: cancel request through worker termination and recoverable idle
state (fresh-engine load is a separate measure).
Progress events are experimental estimates and not a timer. Use monotonic
browser timing around lifecycle boundaries.
## Product limits
Default soft input warning is 256 MiB; hard single/total input is 1.5 GiB.
Estimated output warns at 512 MiB and is blocked at 1 GiB. FFmpeg jobs are
serial; the application retains at most eight scheduled operations in its
in-memory queue. Timeline clips are capped at 100, streams per input at 64,
images at 8192×8192/67,108,864 pixels, generated output files at 255,
contact-sheet cells at 500 and runtime at four hours. The documented
ffmpeg.wasm input ceiling is 2 GiB, but real limits can be substantially lower
on mobile or memory-constrained browsers.
Lowering resolution, duration, frame rate, passes and output count reduces
work. MT can be faster but consumes more memory/CPU and requires isolation; ST
is not inherently a low-memory guarantee.

115
docs/PORTAL_REQUIREMENTS.md Normal file
View File

@@ -0,0 +1,115 @@
# Toolbox Portal requirements
av-tools is built and packaged independently. Toolbox Portal consumes the
exact ZIP and SHA-256 from a reviewed release lock; it must never import the
app source into its React build.
## Required serving behavior
All app, wrapper, core, WASM and worker responses are same-origin and served
over HTTPS (or trusted localhost). Apply:
```text
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Resource-Policy: same-origin
X-Content-Type-Options: nosniff
```
Serve `.wasm` as `application/wasm`, JavaScript modules as a JavaScript MIME
type and JSON as `application/json`. Test real responses under
`/apps/av/vendor/ffmpeg/0.12.10/...`; a file-extension allowlist alone does not
prove the MIME type.
Suggested CSP:
```text
default-src 'self';
script-src 'self' 'wasm-unsafe-eval';
worker-src 'self' blob:;
connect-src 'self';
img-src 'self' blob: data:;
media-src 'self' blob:;
font-src 'self';
style-src 'self';
object-src 'none';
frame-src 'none';
frame-ancestors 'none';
base-uri 'none';
form-action 'none';
```
`'wasm-unsafe-eval'` is the narrow WebAssembly permission; broad
`'unsafe-eval'` is unnecessary. `worker-src blob:` covers the wrapper-created
worker path. `media-src blob:` permits local source/result previews.
## Immutable reference and active-worktree state
Portal revision `bda9da044d61c109afd9969d357cc27f76648938` already sends COOP
`same-origin`, COEP `require-corp`, CORP `same-origin`, `script-src 'self'
'wasm-unsafe-eval'`, and `worker-src 'self' blob:`. Its packaged nginx CSP does
**not** declare `media-src`; `default-src 'self'` therefore blocks local
`blob:` audio/video URLs. The expected browser diagnostic is equivalent to:
```text
Refused to load media from 'blob:…' because it violates
"default-src 'self'".
```
Smallest required Portal change: append exactly
`media-src 'self' blob:;` to that CSP. This permits only same-origin and
in-memory Blob media; it does not permit remote media hosts, inline script or
eval. If the Portal is not changed, conversion/download can remain available
but Blob source/result preview must show a clear disabled reason.
As inspected on 2026-07-24, the active
`/mnt/DATA/git/toolbox-portal/deploy/nginx.conf` contains that exact directive
as a local worktree patch. The worktree still resolves to the immutable
revision above, so the patch is useful for local deployment testing but is not
a committed Portal revision and must not be cited as published release
evidence. The clean-reference assembly smoke therefore continues to require
`--allow-known-header-gap` until a reviewed Portal commit contains the fix.
The nginx profile includes `.wasm` in its static allowlist/gzip types but
relies on the base image `mime.types`. Verify `Content-Type:
application/wasm` against the pinned image; add an explicit mapping if the
response differs.
## Caching
The immutable Portal reference's cache map makes Vite content-hashed assets
immutable but leaves versioned FFmpeg cores at `no-cache`. Correctness is
unaffected.
For performance, the exact directory
`apps/av/vendor/ffmpeg/0.12.10/{st,mt}/` may receive:
```text
Cache-Control: public, max-age=31536000, immutable
```
Only do this while a version path is immutable and verified against
`version.json`. Keep `index.html`, manifest/catalog/release JSON and legal
notices revalidated.
## Nested path and context
The artifact uses `base: './'`, `entry: './'`, a relative icon, a relative
manifest URL and relative core URLs. Portal may therefore target `av`, yielding
`apps/av/`. Do not rewrite files to root-absolute `/assets` or `/vendor`.
Use top-level navigation; embedding is unsupported.
The app remains usable without valid Toolbox context. It validates context
version/origin and falls back to standalone shell behavior without moving or
duplicating suite controls.
## Fallback contract
MT requires all isolation headers plus secure context and
`SharedArrayBuffer`. When any prerequisite or MT load fails, the app terminates
that instance and loads ST. The manifest intentionally declares
`crossOriginIsolated: false` because isolation is not an unconditional
requirement; it declares workers, secure context, IndexedDB and top-level
context.
See `scripts/portal-assembly-smoke.mjs` and `docs/RELEASE.md` for the temporary,
non-mutating assembly procedure.

View File

@@ -0,0 +1,40 @@
# Reference implementations
Inspection date: **2026-07-24**.
The references were cloned beside av-tools under
`/mnt/DATA/git/references/`; no reference repository was nested in this
working tree. Revisions are full immutable commit ids.
| Repository | Revision inspected | Used for | Copied or adapted |
| -------------------------------------------------------------------- | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| av-tools, `git@git.add-ideas.de:zemion/av-tools.git` | no revision: canonical `main` was unborn and had no commits | canonical destination and licence/history check | the empty repository was scaffolded in place; no prior code existed |
| Toolbox SDK, `https://git.add-ideas.de/zemion/toolbox-sdk.git` | `53c40a61ba1581246f65773fcbb1c1cfd31ac98e` (SDK 0.2.2) | manifest schema/definition, `AppShell`, context behavior, shared styles, token-free registry, nested-path checks and toolchain baseline | published 0.2.2 packages are direct dependencies; documented usage patterns were followed; no SDK source file was copied |
| Toolbox Portal, `https://git.add-ideas.de/zemion/toolbox-portal.git` | `bda9da044d61c109afd9969d357cc27f76648938` | release ZIP/sidecar/lock formats, independent assembly, catalogue behavior, path security, nested deployment and production headers | no portal application source was copied; the documented artifact contract informed an independent release packager and smoke harness |
| ffmpeg.wasm, `https://github.com/ffmpegwasm/ffmpeg.wasm.git` | `f876f907c7e9b9bf51d4ed0b913a855a63ae63fc` | current API, worker lifecycle, filesystem, loading, ST/MT behavior, licence documentation and build layout | no reference-worktree code was copied; exact published npm packages are dependencies and their five core assets are copied verbatim by the vendor script |
For the **pinned** package set, the more specific upstream release tag
`v12.15` at `71aa99d37c02a7b4c435275ca9ef50e612f6efa1` was also inspected. It
contains `@ffmpeg/ffmpeg` 0.12.15 and core/core-mt 0.12.10. That revision's
build selects FFmpeg n5.1.4.
The user's active `/mnt/DATA/git/toolbox-portal` worktree still has Portal
revision `bda9da044d61c109afd9969d357cc27f76648938` as `HEAD`, but currently
contains a local `media-src 'self' blob:` nginx CSP patch. That mutable
worktree state is documented for deployment testing only; it does not replace
the immutable reference in the table.
Official behavior references:
- `https://ffmpegwasm.netlify.app/docs/overview/`
- `https://ffmpegwasm.netlify.app/docs/getting-started/usage/`
- `https://ffmpegwasm.netlify.app/docs/api/ffmpeg/classes/ffmpeg/`
- `https://ffmpegwasm.netlify.app/docs/faq/`
- `https://ffmpeg.org/ffmpeg.html`
- `https://ffmpeg.org/ffprobe.html`
- `https://ffmpeg.org/ffmpeg-formats.html`
- `https://ffmpeg.org/ffmpeg-filters.html`
- `https://ffmpeg.org/ffmpeg-utils.html`
Command plans are based on those primary references and runtime capability
queries, not unverified command snippets.

130
docs/RELEASE.md Normal file
View File

@@ -0,0 +1,130 @@
# Release process
av-tools is licensed GPL-3.0-or-later. A publishable release is the exact
reviewed source tag, production ZIP/checksum, Corresponding Source and
third-party notices distributed together. The steps below fail closed where
practical.
## 1. Resolve legal/source identity
The repository already declares the application licence and immutable
third-party source identities. Before any distribution:
1. commit the reviewed GPL-3.0-or-later source;
2. create and verify annotated tag `v0.1.0` at that exact commit;
3. push the commit and tag to the canonical public repository;
4. assemble, review and publish corresponding source and exact external
notices for both core packages and linked libraries;
5. keep the manifest/package metadata, `SOURCE.md`, ZIP and tag version aligned.
`SOURCE.md` pins FFmpeg, ffmpeg.wasm, x264 and LAME by full commit. Do not
describe the complete bundle as MIT-only.
## 2. Reproducible checks
With Node 22+ and the lockfile:
```sh
npm ci
npm run fixtures:verify
npm run check
npm run test:browser
```
`check` verifies manifest/vendor assets, types, lint/format, unit tests,
production build and `toolbox-check dist`. Browser tests must exercise ST, MT,
ST fallback, cancellation/recovery, nested paths and no runtime CDN request.
Record exact results in the release report.
## 3. Package
Normal packaging validates the root GPL version 3 text and matching
`GPL-3.0-or-later` package/manifest metadata:
```sh
npm run package:release
npm run source:package
```
The legacy development-only escape hatch is retained only for checking an
otherwise unlicensed temporary fixture:
```sh
npm run package:release -- --allow-unlicensed-development-smoke
```
Pass `--force` only to replace the exact same-version ZIP and sidecar. Output:
```text
release/av-tools-0.1.0.zip
release/av-tools-0.1.0.zip.sha256
release/ffmpeg-core-0.12.10-corresponding-source.tar.xz
release/ffmpeg-core-0.12.10-corresponding-source.tar.xz.sha256
```
Verify independently:
```sh
node scripts/checksum-release.mjs
(cd release && sha256sum -c ffmpeg-core-0.12.10-corresponding-source.tar.xz.sha256)
unzip -l release/av-tools-0.1.0.zip
tar -tJf release/ffmpeg-core-0.12.10-corresponding-source.tar.xz
```
The packager:
- rejects symlinks, special files, traversal, absolute/ambiguous/duplicate
archive paths and credential-like files;
- rejects source maps and local absolute paths in text release files;
- requires entry, manifest, icon and every declared asset;
- requires root `CHANGELOG.md`, `SOURCE.md`, `THIRD_PARTY_NOTICES.md`,
`LICENSES/`, `assets/` and `vendor/`;
- adds installed runtime npm licence files;
- sorts paths, stores files as mode 0644, uses 1980-01-01 00:00:00 UTC and
produces deterministic ZIP32 bytes;
- excludes `node_modules`, test fixtures and source because it starts from
`dist` plus an explicit legal/document list.
When used against a fixture without an application licence, the escape hatch adds
`DEVELOPMENT-ONLY-NOT-FOR-DISTRIBUTION.txt` inside the ZIP.
## 4. Portal assembly smoke
Use the clean reference clone beside av-tools, never the user's active Portal
worktree:
```sh
npm run portal:smoke -- \
--artifact release/av-tools-0.1.0.zip \
--allow-known-header-gap
```
The smoke harness clones the exact Portal HEAD to a temporary directory,
copies the ZIP there, writes a temporary one-app lock derived from
`release/toolbox.lock.example.json`, runs `npm ci`, tests, build and assembly,
then verifies the generated catalogue/release records and copied FFmpeg assets.
The temporary directory is removed unless `--keep-temp` is explicitly passed.
No machine-specific path is committed and no Portal worktree is changed.
Without `--allow-known-header-gap`, the clean immutable Portal reference's CSP
omission of `media-src blob:` is a failing deployment check. The override still
reports the gap and is only for testing archive assembly. See
`docs/PORTAL_REQUIREMENTS.md`.
The user's active `/mnt/DATA/git/toolbox-portal` worktree contains the narrow
directive as a local patch, but the smoke intentionally does not consume that
mutable worktree. Until the patch exists in a reviewed immutable Portal
revision, retain the clean-reference failure/override behavior above.
## 5. Publish only reviewed bytes
From a clean `v0.1.0` checkout, rerun all checks, create the ZIP once, verify
the sidecar, assemble that exact ZIP through Portal, and publish the immutable
ZIP, `.zip.sha256`, manifest, changelog, Corresponding Source and notices. Put
its exact lowercase digest/version/id/target in a reviewed Portal lock. Never
use mutable “latest” URLs or credentials in a lock.
The final report must state inspected commits, exact packages/build config,
capabilities, completed tested milestones, all check results, artifact paths,
Portal result, fixture provenance, licence status, headers, gaps, performance
and browser limits. A UI or command builder alone is not a completed milestone.

140
docs/SECURITY.md Normal file
View File

@@ -0,0 +1,140 @@
# Security and privacy
## Trust boundary
Imported media, filenames, container metadata, tags, chapters, subtitles,
attachments, codec logs, project JSON and Toolbox context are untrusted. FFmpeg
parsing occurs in a worker but still consumes memory/CPU in the user's browser.
The application cannot make a malicious codec safe; it limits exposure and
keeps cleanup/recovery explicit.
The supported product has:
- no upload or processing backend;
- no telemetry, analytics or third-party runtime script;
- no remote-media protocol or automatic external fetch;
- no shell/native command execution;
- no arbitrary FFmpeg command input;
- no persistent source copy in the current application.
Browser extension, operating-system, reverse-proxy and compromised dependency
behavior remain outside this boundary.
## Paths and command construction
User names are display labels only. Virtual filesystem paths are generated from
job ids plus sanitized, length-bounded basenames; separators, traversal,
control characters, reserved names and collisions are handled centrally.
Outputs must be declared in a typed plan before execution.
Commands are argument arrays assembled from discriminated operation/preset
types. No shell parses them. Filters, metadata, concat lists, chapter metadata
and subtitle values use syntax-specific escaping; one escaping routine is not
reused across incompatible grammars. Capability requirements are checked before
execution. Logs are line/byte bounded and local path-like names are redacted in
exportable diagnostics.
## Rendering and external URLs
Metadata is rendered as React text, never `dangerouslySetInnerHTML`. Subtitle
text is parsed and rendered or sent through a generated local file; it is not
inserted as HTML. Extracted URLs are inert text. The application does not open
links from media automatically. No remote font is fetched; ASS burn-in uses
packaged/system-generic font configuration only when the required local
capability is available.
Preview/download URLs are `blob:` values made from local bytes. They are
revoked on replacement, project close and unmount. Browser support for a
container is not evidence that its contents are safe or fully decodable.
## Resource controls
The administrator-adjustable defaults are product limits, not FFmpeg limits:
| Limit | Default |
| --------------------------------- | ----------------: |
| soft input warning | 256 MiB |
| hard single input | 1536 MiB |
| hard total input | 1536 MiB |
| soft output estimate | 512 MiB |
| hard output estimate | 1024 MiB |
| concurrent FFmpeg jobs | 1 |
| scheduled operations in memory | 8 |
| timeline clips | 100 |
| streams per input | 64 |
| image dimension / pixel count | 8192 / 67,108,864 |
| generated outputs / contact cells | 255 / 500 |
| job runtime | 4 hours |
Before allocation, the application estimates source/output bytes, duration,
resolution, frame/pixel work, passes, retained results and output count.
Estimates are labelled as estimates. Hard-limit violations do not start; soft
violations explain resolution/duration/output-count alternatives. The
255-output limit keeps a whole managed batch saveable in the 256-entry ZIP
limit with an optional report. Memory-constrained devices receive stronger
warnings only when a reliable browser signal exists.
WASM itself has a documented 2 GiB input ceiling; practical browser limits can
be much lower, especially when input and output coexist. Archive/export and
cache operations also enforce entry, total-size and path limits.
## Workers and cancellation
Wrapper, core, pthread and waveform workers are same-origin or short-lived
`blob:` workers created by trusted application code. OPFS caching currently
uses asynchronous browser file handles rather than a separate storage worker.
MT requires shared memory and cross-origin isolation. ST loads when isolation
is absent. Cancelling FFmpeg terminates and discards its worker/core; a new
instance is required before another job. Stale generations cannot commit a
result.
## Content Security Policy
Tested deployment policy target:
```text
default-src 'self';
script-src 'self' 'wasm-unsafe-eval';
worker-src 'self' blob:;
connect-src 'self';
img-src 'self' blob: data:;
media-src 'self' blob:;
font-src 'self';
style-src 'self';
object-src 'none';
frame-src 'none';
frame-ancestors 'none';
base-uri 'none';
form-action 'none';
```
Do not replace `'wasm-unsafe-eval'` with broad `'unsafe-eval'`. All core assets
are same-origin. `media-src blob:` is required for result/source previews.
The exact headers, immutable Portal reference gap and active-worktree local
patch are in `docs/PORTAL_REQUIREMENTS.md`.
## Cross-origin isolation
MT needs secure HTTPS (localhost during development is acceptable),
`SharedArrayBuffer`, and:
```text
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Resource-Policy: same-origin
```
Isolation affects every resource in the page. Do not weaken COEP or allow
cross-origin dependencies merely to make MT load. The safe fallback is ST.
## Dependency and vulnerability response
Exact runtime versions and core digests are recorded in `SOURCE.md`. Run
`npm audit`, tests, real core smoke tests and legal review before an update.
Core upgrades change a large native attack surface and require new capability
snapshots, fixtures, checksums and browser tests.
Report vulnerabilities privately to the repository owner through the Gitea
instance when possible. Do not attach sensitive media, credentials or an
unredacted diagnostic log to a public issue. Include the app/core version,
browser, mode, smallest synthetic reproduction, impact and cleanup result.

93
docs/STORAGE.md Normal file
View File

@@ -0,0 +1,93 @@
# Storage and cleanup
av-tools separates source bytes, engine files, derivatives and project
metadata. The default policy is deliberately non-persistent for source media.
## Object URLs
Browser preview and result playback use `URL.createObjectURL(File|Blob)`.
Object URLs reveal bytes only to the current browser context but retain the
underlying object until revoked. Each owner revokes its previous URL when a
source/result changes and all URLs on project close/unmount. A preview failure
does not trigger an upload or external fallback.
## FFmpeg virtual filesystem
Every execution receives a generated job directory. Inputs are copied to the
in-memory filesystem or mounted read-only through WORKERFS where browser/core
support is verified. Outputs are always in generated paths. The runner reads
only declared outputs, then deletes output/input/control files and unmounts.
WORKERFS can avoid an initial JavaScript byte copy; FFmpeg decoding and output
still consume WASM/browser memory. Cancelling terminates the engine, so cleanup
also discards that entire virtual filesystem.
Each probe/export records its actual input mode and byte accounting. A
successful WORKERFS mount reports zero copied input bytes; if mounting fails,
the bounded compatibility fallback reports the exact bytes copied into engine
memory. This is runtime evidence rather than a speculative preflight claim.
## IndexedDB
The mounted application currently stores validated user presets in IndexedDB.
The codebase also contains a durable job-metadata store, but the application
queue described in the UI is deliberately in memory and does not instantiate
that store. Project documents are validated, versioned JSON that the user
explicitly saves and loads; they are not currently auto-saved to IndexedDB.
Media source bytes are not written there by default.
Clearing site data removes these records. Private browsing and quota eviction
may remove them earlier; the app must treat storage as recoverable cache, not
the only copy of an important user preset.
## Origin-private file system
OPFS is optional for bounded preview proxies, waveform peaks, thumbnails and
other regenerable derivatives. The current implementation uses asynchronous
origin-private file handles and falls back to a 128 MiB least-recently-used
in-memory cache if OPFS is unavailable or fails. A derivative key includes a
source fingerprint, operation, relevant settings and a cache-schema version;
waveform keys additionally identify the selected stream and analysis settings.
Preview-proxy settings are part of that identity. The current UI validates a
1300 second maximum duration and a 480, 720, 960, 1280 or 1920 pixel maximum
width. A matching local derivative is reused without FFmpeg execution; changing
the source or either setting produces a different cache lookup. A cache miss or
cache failure falls back to generation and never mutates/persists the source.
The application works without OPFS. The storage panel reports the browser's
origin-wide quota estimate when available and offers per-entry or global cache
deletion. The current OPFS layer relies on browser quota enforcement rather
than pre-reserving space; a failed cache write switches subsequent cache work
to the bounded in-memory fallback. OPFS writes close their
`FileSystemWritableFileStream` on success and abort it on error.
OPFS is private to the current **origin**. Moving an app from one hostname,
scheme or port to another does not move cached data. Toolbox and standalone
deployments at different origins have different caches even if their files are
identical.
## Source persistence policy
Normal import retains the user-selected `File` only in the active page. Saved
projects contain source descriptors/fingerprints and prompt for reattachment;
they do not embed source bytes. If a later UI offers “keep source on this
device,” it must be explicit, show estimated/quota usage, identify the origin,
allow per-source deletion and never be silently enabled by a project import.
Downloads are explicit user actions. The File System Access API, when
available, writes only after the user selects a target; Blob download remains
the fallback.
## Cleanup matrix
| Resource | Normal completion | Cancellation/error | User control |
| ------------------------ | ---------------------------------------------------- | -------------------------------------------------------- | ----------------------- |
| source/result object URL | retained only while displayed, then revoked | revoked when owning state clears | clear source/result |
| FFmpeg job files/mount | delete/unmount in finalization | engine termination discards FS; stale generation ignored | cancel job/reset engine |
| result Blob | retained until replaced/cleared/download page closes | failed partial output is not published | clear result |
| IndexedDB user presets | retained intentionally | transaction rollback | delete/reset presets |
| OPFS derivative | retained until expiry or explicit deletion | failed write is not indexed; cache switches to memory | delete item/clear all |
The storage screen reports approximate usage; browser quota figures remain
estimates and are not a durability promise.

44
eslint.config.mjs Normal file
View File

@@ -0,0 +1,44 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: [
'dist',
'release',
'coverage',
'test-results',
'playwright-report',
'public/vendor/ffmpeg',
],
},
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2023,
globals: {
...globals.browser,
...globals.worker,
},
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
{
files: ['scripts/**/*.mjs', 'playwright.config.ts'],
languageOptions: { globals: globals.node },
}
);

19
index.html Normal file
View File

@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#28366d" />
<meta
name="description"
content="Convert and lightly edit audio and video locally in your browser."
/>
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
<title>Audio &amp; Video Tools</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4105
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

82
package.json Normal file
View File

@@ -0,0 +1,82 @@
{
"name": "av-tools",
"private": true,
"version": "0.1.0",
"description": "Local-first browser audio and video conversion and light editing with ffmpeg.wasm.",
"license": "GPL-3.0-or-later",
"author": "Albrecht Degering",
"repository": {
"type": "git",
"url": "git+https://git.add-ideas.de/zemion/av-tools.git"
},
"homepage": "https://git.add-ideas.de/zemion/av-tools",
"bugs": {
"url": "https://git.add-ideas.de/zemion/av-tools/issues"
},
"type": "module",
"engines": {
"node": ">=22"
},
"scripts": {
"predev": "npm run vendor:ffmpeg && npm run manifest:generate",
"dev": "vite",
"vendor:ffmpeg": "node scripts/copy-ffmpeg-assets.mjs",
"vendor:verify": "node scripts/verify-ffmpeg-assets.mjs",
"manifest:generate": "node scripts/generate-toolbox-manifest.mjs",
"manifest:check": "node scripts/generate-toolbox-manifest.mjs --check",
"prebuild": "npm run vendor:ffmpeg && npm run manifest:generate",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc -b --pretty false",
"lint": "eslint . --max-warnings=0",
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "vitest run",
"test:watch": "vitest",
"test:browser": "playwright test",
"benchmark:browser": "AV_BENCHMARK=1 playwright test tests/browser/performance.spec.ts --project=chromium --workers=1",
"fixtures:generate": "sh scripts/generate-fixtures.sh",
"fixtures:verify": "cd tests/fixtures/generated && sha256sum -c SHA256SUMS",
"toolbox:check": "toolbox-check dist",
"package:release": "node scripts/package-release.mjs",
"source:package": "node scripts/package-ffmpeg-source.mjs",
"checksum:release": "node scripts/checksum-release.mjs",
"portal:smoke": "node scripts/portal-assembly-smoke.mjs",
"check": "npm run manifest:check && npm run vendor:verify && npm run typecheck && npm run lint && npm run format:check && npm run test && npm run build && npm run toolbox:check",
"release:artifact": "npm run check && npm run test:browser && npm run package:release"
},
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.2",
"@add-ideas/toolbox-shell-react": "0.2.2",
"@ffmpeg/core": "0.12.10",
"@ffmpeg/core-mt": "0.12.10",
"@ffmpeg/ffmpeg": "0.12.15",
"@ffmpeg/util": "0.12.2",
"fflate": "0.8.3",
"react": "19.2.7",
"react-dom": "19.2.7"
},
"devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.2",
"@eslint/js": "10.0.1",
"@playwright/test": "1.61.1",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.2",
"@testing-library/user-event": "14.6.1",
"@types/node": "25.9.5",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.3",
"eslint": "10.7.0",
"eslint-plugin-react-hooks": "7.1.1",
"eslint-plugin-react-refresh": "0.5.2",
"globals": "17.7.0",
"jsdom": "29.1.1",
"prettier": "3.9.5",
"typescript": "6.0.3",
"typescript-eslint": "8.64.0",
"vite": "8.1.5",
"vitest": "4.1.10"
},
"packageManager": "npm@11.17.0"
}

25
playwright.config.ts Normal file
View File

@@ -0,0 +1,25 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/browser',
fullyParallel: false,
timeout: 120_000,
expect: { timeout: 10_000 },
reporter: [['list']],
use: {
baseURL: 'http://127.0.0.1:4173',
trace: 'retain-on-failure',
},
webServer: {
command: 'npm run build && npm run preview -- --host 127.0.0.1',
url: 'http://127.0.0.1:4173',
reuseExistingServer: !process.env.CI,
timeout: 180_000,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});

1
public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

64
public/toolbox-app.json Normal file
View File

@@ -0,0 +1,64 @@
{
"$schema": "https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
"schemaVersion": 1,
"id": "de.add-ideas.av-tools",
"name": "Audio & Video Tools",
"version": "0.1.0",
"description": "Convert and lightly edit audio and video locally in the browser.",
"entry": "./",
"icon": "./favicon.svg",
"categories": [
"media",
"audio",
"video"
],
"tags": [
"convert",
"transcode",
"trim",
"split",
"concatenate",
"crop",
"resize",
"normalize",
"subtitles"
],
"integration": {
"contextVersion": 1,
"launchModes": [
"navigate",
"new-tab"
],
"embedding": "unsupported"
},
"requirements": {
"secureContext": true,
"workers": true,
"indexedDb": true,
"crossOriginIsolated": false,
"topLevelContext": true
},
"privacy": {
"processing": "local",
"fileUploads": false,
"telemetry": false
},
"source": {
"repository": "https://git.add-ideas.de/zemion/av-tools",
"license": "GPL-3.0-or-later"
},
"actions": [
{
"id": "source",
"label": "Source",
"url": "https://git.add-ideas.de/zemion/av-tools"
}
],
"assets": [
"./vendor/ffmpeg/0.12.10/st/ffmpeg-core.js",
"./vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm",
"./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.js",
"./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm",
"./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.worker.js"
]
}

View File

@@ -0,0 +1,150 @@
import { createHash } from 'node:crypto';
import { access, lstat, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const repositoryRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..'
);
export function sha256(data) {
return createHash('sha256').update(data).digest('hex');
}
export async function sha256File(file) {
return sha256(await readFile(file));
}
async function requireRegularFile(file, label) {
const details = await lstat(file);
if (details.isSymbolicLink() || !details.isFile()) {
throw new Error(`${label} must be a regular file: ${file}`);
}
}
export function checksumLine(digest, archiveName) {
if (!/^[a-f0-9]{64}$/.test(digest)) {
throw new Error('SHA-256 digest must be 64 lowercase hexadecimal digits.');
}
if (
typeof archiveName !== 'string' ||
archiveName.length === 0 ||
archiveName.includes('/') ||
archiveName.includes('\\') ||
archiveName.includes('\n') ||
archiveName.includes('\r')
) {
throw new Error('Checksum archive name must be one safe basename.');
}
return `${digest} ${archiveName}\n`;
}
export function parseChecksumLine(contents) {
const match = /^([a-f0-9]{64}) ([^/\\\r\n]+)\n?$/.exec(contents);
if (!match) {
throw new Error(
'Checksum sidecar must contain `<lowercase-sha256> <archive-basename>`.'
);
}
return { digest: match[1], archiveName: match[2] };
}
export async function verifyChecksum(archivePath, sidecarPath) {
await requireRegularFile(archivePath, 'Release archive');
await requireRegularFile(sidecarPath, 'Checksum sidecar');
const expected = parseChecksumLine(await readFile(sidecarPath, 'utf8'));
if (expected.archiveName !== path.basename(archivePath)) {
throw new Error(
`Checksum names ${expected.archiveName}, not ${path.basename(archivePath)}.`
);
}
const actual = await sha256File(archivePath);
if (actual !== expected.digest) {
throw new Error(
`Checksum mismatch for ${path.basename(archivePath)}: expected ${expected.digest}, received ${actual}.`
);
}
return actual;
}
export async function writeChecksum(archivePath, options = {}) {
const sidecarPath = options.sidecarPath ?? `${archivePath}.sha256`;
await requireRegularFile(archivePath, 'Release archive');
if (options.force && (await exists(sidecarPath))) {
await requireRegularFile(sidecarPath, 'Existing checksum sidecar');
}
const digest = await sha256File(archivePath);
await writeFile(
sidecarPath,
checksumLine(digest, path.basename(archivePath)),
{
encoding: 'utf8',
flag: options.force ? 'w' : 'wx',
}
);
return { digest, sidecarPath };
}
async function exists(file) {
try {
await access(file);
return true;
} catch (error) {
if (error && typeof error === 'object' && error.code === 'ENOENT') {
return false;
}
throw error;
}
}
async function main() {
const packageJson = JSON.parse(
await readFile(path.join(repositoryRoot, 'package.json'), 'utf8')
);
const defaultArchive = path.join(
repositoryRoot,
'release',
`av-tools-${packageJson.version}.zip`
);
const argumentsList = process.argv.slice(2);
const write = argumentsList.includes('--write');
const force = argumentsList.includes('--force');
const positional = argumentsList.filter(
(argument) => argument !== '--write' && argument !== '--force'
);
if (positional.length > 1) {
throw new Error(
'Usage: node scripts/checksum-release.mjs [archive.zip] [--write] [--force]'
);
}
const archivePath = path.resolve(
repositoryRoot,
positional[0] ?? defaultArchive
);
const sidecarPath = `${archivePath}.sha256`;
if (write) {
const result = await writeChecksum(archivePath, { force, sidecarPath });
console.log(
`Wrote ${path.relative(repositoryRoot, result.sidecarPath)} (${result.digest})`
);
return;
}
if (!(await exists(sidecarPath))) {
throw new Error(
`${path.relative(repositoryRoot, sidecarPath)} is missing; pass --write to create it.`
);
}
const digest = await verifyChecksum(archivePath, sidecarPath);
console.log(
`Verified ${path.relative(repositoryRoot, archivePath)} (${digest})`
);
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href
) {
await main();
}

View File

@@ -0,0 +1,138 @@
import { createHash } from 'node:crypto';
import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
const repositoryRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
const expectedVersion = '0.12.10';
const outputRoot = join(
repositoryRoot,
'public',
'vendor',
'ffmpeg',
expectedVersion
);
const packages = [
{
packageName: '@ffmpeg/core',
mode: 'st',
files: ['ffmpeg-core.js', 'ffmpeg-core.wasm'],
},
{
packageName: '@ffmpeg/core-mt',
mode: 'mt',
files: ['ffmpeg-core.js', 'ffmpeg-core.wasm', 'ffmpeg-core.worker.js'],
},
];
const expectedHashes = new Map([
[
'@ffmpeg/core:ffmpeg-core.js',
'67a48f11645f85439f3fde4f2119042c16b374b910206b7a7a24f342e28dcae3',
],
[
'@ffmpeg/core:ffmpeg-core.wasm',
'9f57947a5bd530d8f00c5b3f2cb2a3492faa7e5d823315342d6a8656d0a6b7b7',
],
[
'@ffmpeg/core-mt:ffmpeg-core.js',
'270a2e6ff945e173238610669a3f7132df5f9c52698a9bf708cf5c2ab6bda0de',
],
[
'@ffmpeg/core-mt:ffmpeg-core.wasm',
'be2c97605366b78f3f13e21b52e81a55a79e1f29c133b03a68ec187b1a2ec41a',
],
[
'@ffmpeg/core-mt:ffmpeg-core.worker.js',
'f77898d631dc010b45c29c23cb4379c611a7d7b131bf591d08a656bb729a4ca3',
],
]);
async function loadPackageVersion(packageName) {
const packagePath = join(
repositoryRoot,
'node_modules',
...packageName.split('/'),
'package.json'
);
const parsed = JSON.parse(await readFile(packagePath, 'utf8'));
if (parsed.version !== expectedVersion) {
throw new Error(
`${packageName} ${parsed.version ?? '<unknown>'} is installed; expected exactly ${expectedVersion}`
);
}
}
async function copyAsset(packageName, mode, fileName) {
const source = join(
repositoryRoot,
'node_modules',
...packageName.split('/'),
'dist',
'esm',
fileName
);
const destination = join(outputRoot, mode, fileName);
const sourceBytes = await readFile(source).catch((error) => {
throw new Error(`Required FFmpeg asset is missing: ${source}`, {
cause: error,
});
});
const sourceDigest = createHash('sha256').update(sourceBytes).digest('hex');
const expectedDigest = expectedHashes.get(`${packageName}:${fileName}`);
if (!expectedDigest || sourceDigest !== expectedDigest) {
throw new Error(
`Pinned FFmpeg asset checksum mismatch for ${packageName}/${fileName}`
);
}
await mkdir(dirname(destination), { recursive: true });
await cp(source, destination, {
dereference: true,
errorOnExist: false,
force: true,
preserveTimestamps: false,
});
const destinationBytes = await readFile(destination);
if (!sourceBytes.equals(destinationBytes)) {
throw new Error(`Copied FFmpeg asset differs from its source: ${fileName}`);
}
return {
path: relative(outputRoot, destination).replaceAll('\\', '/'),
bytes: destinationBytes.byteLength,
sha256: sourceDigest,
};
}
await rm(outputRoot, { force: true, recursive: true });
const assets = [];
for (const entry of packages) {
await loadPackageVersion(entry.packageName);
for (const fileName of entry.files) {
assets.push(await copyAsset(entry.packageName, entry.mode, fileName));
}
}
assets.sort((left, right) => left.path.localeCompare(right.path, 'en'));
const versionManifest = {
schemaVersion: 1,
coreVersion: expectedVersion,
packages: {
'@ffmpeg/core': expectedVersion,
'@ffmpeg/core-mt': expectedVersion,
},
assets,
};
await writeFile(
join(outputRoot, 'version.json'),
`${JSON.stringify(versionManifest, null, 2)}\n`
);
console.log(
`Copied ${assets.length} pinned FFmpeg assets to ${relative(repositoryRoot, outputRoot)}`
);

View File

@@ -0,0 +1,50 @@
# FFmpeg WebAssembly Corresponding Source
This archive accompanies the exact `@ffmpeg/core` and `@ffmpeg/core-mt`
0.12.10 object code distributed by av-tools 0.1.0.
`SOURCE_LOCK.json` records the immutable Git commit for ffmpeg.wasm, FFmpeg and
every external library selected by the upstream v12.15 build. The source trees
are stored under `sources/`. In particular, the upstream branch names
`4-cores` and `master` have been resolved to the commits from which the
published core was built; neither branch changed between its recorded commit
and the upstream core release.
The original build-control files are in `sources/ffmpeg.wasm/`, including:
- `Dockerfile`;
- `Makefile`;
- `build/ffmpeg.sh`;
- `build/ffmpeg-wasm.sh`;
- the other dependency scripts under `build/`.
The upstream build selects Emscripten SDK 3.1.40 and FFmpeg n5.1.4. av-tools
does not modify the five published JavaScript/WebAssembly core assets. Their
exact sizes, hashes and embedded configure arguments are recorded in the
application's `SOURCE.md` and `docs/FFMPEG_BUILD.md`.
The source archive preserves each component's own licence and copyright
notices. av-tools itself is distributed separately under
GPL-3.0-or-later. Codec patent permissions, where required, are separate from
copyright licences.
To fetch and verify an identity:
```sh
git init source-check
git -C source-check fetch --depth 1 <repository> <revision>
test "$(git -C source-check rev-parse FETCH_HEAD)" = "<revision>"
```
The included ffmpeg.wasm Dockerfile documents the historical upstream build,
but still names remote tags and branches. It is not a turnkey offline or
byte-for-byte reproducible build recipe. To reconstruct a pinned build,
adapt each remote `ADD` to use the corresponding bundled source tree. Replace
the zimg `git clone -b ... --recursive` step with the bundled `sources/zimg/`
tree, which already contains its pinned GoogleTest submodule at
`test/extra/googletest/`. Keep the included build scripts and Emscripten
version unchanged. The upstream targets are `make prd` for single-thread and
`make prd-mt` for multithread.
General-purpose build tooling such as Git, Docker/BuildKit and the Emscripten
3.1.40 container image is not copied into this archive.

View File

@@ -0,0 +1,116 @@
{
"schemaVersion": 1,
"coreVersion": "0.12.10",
"wrapperRelease": "v12.15",
"sourceDateEpoch": 1736208000,
"sources": [
{
"name": "ffmpeg.wasm",
"directory": "ffmpeg.wasm",
"repository": "https://github.com/ffmpegwasm/ffmpeg.wasm.git",
"revision": "71aa99d37c02a7b4c435275ca9ef50e612f6efa1"
},
{
"name": "FFmpeg",
"directory": "FFmpeg",
"repository": "https://github.com/FFmpeg/FFmpeg.git",
"revision": "4729204c17f756e186d622060088371d10b34f7e"
},
{
"name": "x264",
"directory": "x264",
"repository": "https://github.com/ffmpegwasm/x264.git",
"revision": "33cac6b77d5b9259c552156013a817ab23119612"
},
{
"name": "x265",
"directory": "x265",
"repository": "https://github.com/ffmpegwasm/x265.git",
"revision": "2bb5520e9596f361bf0ed81b3b8da0d7fd999069"
},
{
"name": "libvpx",
"directory": "libvpx",
"repository": "https://github.com/ffmpegwasm/libvpx.git",
"revision": "10b9492dcf05b652e2e4b370e205bd605d421972"
},
{
"name": "LAME",
"directory": "lame",
"repository": "https://github.com/ffmpegwasm/lame.git",
"revision": "2badea1974ae36cb8312afe99cff1e6b3b5decee"
},
{
"name": "Ogg",
"directory": "ogg",
"repository": "https://github.com/ffmpegwasm/Ogg.git",
"revision": "bada45718453ac27b56773ae663f7e65112f6a6e"
},
{
"name": "Theora",
"directory": "theora",
"repository": "https://github.com/ffmpegwasm/theora.git",
"revision": "7ffd8b2ecfc2d93ae5e16028e7528e609266bfbf"
},
{
"name": "Opus",
"directory": "opus",
"repository": "https://github.com/ffmpegwasm/opus.git",
"revision": "e85ed7726db5d677c9c0677298ea0cb9c65bdd23"
},
{
"name": "Vorbis",
"directory": "vorbis",
"repository": "https://github.com/ffmpegwasm/vorbis.git",
"revision": "7798164043197d7e33f02de4353ce2aa5b248225"
},
{
"name": "zlib",
"directory": "zlib",
"repository": "https://github.com/ffmpegwasm/zlib.git",
"revision": "cacf7f1d4e3d44d871b605da3b647f07d718623f"
},
{
"name": "libwebp",
"directory": "libwebp",
"repository": "https://github.com/ffmpegwasm/libwebp.git",
"revision": "ca332209cb5567c9b249c86788cb2dbf8847e760"
},
{
"name": "FreeType",
"directory": "freetype2",
"repository": "https://github.com/ffmpegwasm/freetype2.git",
"revision": "6a2b3e4007e794bfc6c91030d0ed987f925164a8"
},
{
"name": "FriBidi",
"directory": "fribidi",
"repository": "https://github.com/fribidi/fribidi.git",
"revision": "f9e8e71a6fbf4a4619481284c9f484d10e559995"
},
{
"name": "HarfBuzz",
"directory": "harfbuzz",
"repository": "https://github.com/harfbuzz/harfbuzz.git",
"revision": "4a1d891c6317d2c83e5f3c2607ec5f5ccedffcde"
},
{
"name": "libass",
"directory": "libass",
"repository": "https://github.com/libass/libass.git",
"revision": "d149636f502f5774ae1a8fb4c554b122674393b2"
},
{
"name": "zimg",
"directory": "zimg",
"repository": "https://github.com/sekrit-twc/zimg.git",
"revision": "e5b0de6bebbcbc66732ed5afaafef6b2c7dfef87"
},
{
"name": "GoogleTest (zimg build submodule)",
"directory": "zimg/test/extra/googletest",
"repository": "https://github.com/google/googletest.git",
"revision": "703bd9caab50b139428cea1aaff9974ebee5742e"
}
]
}

View File

@@ -0,0 +1,315 @@
#!/bin/sh
set -eu
export LC_ALL=C
export TZ=UTC
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P)
project_root=$(CDPATH= cd -- "$script_dir/.." && pwd -P)
output_dir="$project_root/tests/fixtures/generated"
force=false
if [ "${1:-}" = "--force" ]; then
force=true
shift
fi
if [ "$#" -ne 0 ]; then
echo "Usage: scripts/generate-fixtures.sh [--force]" >&2
exit 2
fi
for command_name in ffmpeg ffprobe head sha256sum mktemp; do
if ! command -v "$command_name" >/dev/null 2>&1; then
echo "Required development command is unavailable: $command_name" >&2
exit 1
fi
done
fixture_names="
pattern-av.mp4
compatible-a.mp4
compatible-b.mp4
different-dimensions.mp4
tone.wav
tone.mp3
tone.flac
pattern.webm
subtitle.srt
subtitle.vtt
subtitle.ass
metadata.mp4
chapters.mp4
attached-cover.mp3
truncated.mp4
SHA256SUMS
"
mkdir -p "$output_dir"
if [ -L "$output_dir" ] || [ ! -d "$output_dir" ]; then
echo "Fixture output must be a real directory: $output_dir" >&2
exit 1
fi
canonical_output_dir=$(CDPATH= cd -- "$output_dir" && pwd -P)
if [ "$canonical_output_dir" != "$project_root/tests/fixtures/generated" ]; then
echo "Fixture output resolves outside the expected project path." >&2
exit 1
fi
for fixture_name in $fixture_names; do
fixture_path="$output_dir/$fixture_name"
if [ -e "$fixture_path" ] && [ "$force" != true ]; then
echo "Fixture already exists: $fixture_path" >&2
echo "Pass --force to replace only the known generated fixture set." >&2
exit 1
fi
done
if [ "$force" = true ]; then
for fixture_name in $fixture_names; do
rm -f -- "$output_dir/$fixture_name"
done
fi
temporary_dir=$(mktemp -d "/tmp/av-tools-fixtures.XXXXXX")
case "$temporary_dir" in
/tmp/av-tools-fixtures.*) ;;
*)
echo "Refusing unexpected temporary path: $temporary_dir" >&2
exit 1
;;
esac
trap 'rm -rf -- "$temporary_dir"' EXIT HUP INT TERM
run_ffmpeg() {
ffmpeg \
-hide_banner \
-loglevel error \
-y \
-fflags +bitexact \
-filter_threads 1 \
-filter_complex_threads 1 \
"$@"
}
run_ffmpeg \
-f lavfi -i "testsrc2=size=160x90:rate=12" \
-f lavfi -i "sine=frequency=440:sample_rate=16000" \
-t 2 -shortest \
-map 0:v:0 -map 1:a:0 \
-c:v libx264 -preset ultrafast -crf 35 -pix_fmt yuv420p -g 12 -threads 1 \
-c:a aac -b:a 32k -ar 16000 -ac 1 \
-flags:v +bitexact -flags:a +bitexact \
-metadata title="Generated AV fixture" \
-metadata creation_time="2020-01-01T00:00:00Z" \
-movflags +faststart \
"$output_dir/pattern-av.mp4"
run_ffmpeg \
-f lavfi -i "color=c=red:size=128x72:rate=10" \
-f lavfi -i "sine=frequency=330:sample_rate=16000" \
-t 1 -shortest \
-map 0:v:0 -map 1:a:0 \
-c:v libx264 -preset ultrafast -crf 35 -pix_fmt yuv420p -g 10 -threads 1 \
-c:a aac -b:a 32k -ar 16000 -ac 1 \
-flags:v +bitexact -flags:a +bitexact \
-metadata creation_time="2020-01-01T00:00:00Z" \
-movflags +faststart \
"$output_dir/compatible-a.mp4"
run_ffmpeg \
-f lavfi -i "color=c=blue:size=128x72:rate=10" \
-f lavfi -i "sine=frequency=550:sample_rate=16000" \
-t 1 -shortest \
-map 0:v:0 -map 1:a:0 \
-c:v libx264 -preset ultrafast -crf 35 -pix_fmt yuv420p -g 10 -threads 1 \
-c:a aac -b:a 32k -ar 16000 -ac 1 \
-flags:v +bitexact -flags:a +bitexact \
-metadata creation_time="2020-01-01T00:00:00Z" \
-movflags +faststart \
"$output_dir/compatible-b.mp4"
run_ffmpeg \
-f lavfi -i "testsrc2=size=96x96:rate=10" \
-f lavfi -i "sine=frequency=660:sample_rate=16000" \
-t 1 -shortest \
-map 0:v:0 -map 1:a:0 \
-c:v libx264 -preset ultrafast -crf 35 -pix_fmt yuv420p -g 10 -threads 1 \
-c:a aac -b:a 32k -ar 16000 -ac 1 \
-flags:v +bitexact -flags:a +bitexact \
-metadata creation_time="2020-01-01T00:00:00Z" \
-movflags +faststart \
"$output_dir/different-dimensions.mp4"
run_ffmpeg \
-f lavfi -i "sine=frequency=440:sample_rate=16000:duration=1" \
-c:a pcm_s16le -flags:a +bitexact \
-metadata title="Generated tone" \
"$output_dir/tone.wav"
run_ffmpeg \
-f lavfi -i "sine=frequency=440:sample_rate=16000:duration=1" \
-c:a libmp3lame -b:a 32k -ar 16000 -ac 1 -flags:a +bitexact \
-id3v2_version 3 -write_xing 0 \
-metadata title="Generated tone" \
"$output_dir/tone.mp3"
run_ffmpeg \
-f lavfi -i "sine=frequency=440:sample_rate=16000:duration=1" \
-c:a flac -compression_level 5 -flags:a +bitexact \
-metadata title="Generated tone" \
"$output_dir/tone.flac"
run_ffmpeg \
-f lavfi -i "testsrc2=size=128x72:rate=10" \
-f lavfi -i "sine=frequency=440:sample_rate=16000" \
-t 1 -shortest \
-map 0:v:0 -map 1:a:0 \
-c:v libvpx -deadline good -cpu-used 8 -crf 50 -b:v 80k -threads 1 \
-c:a libvorbis -b:a 32k -ar 16000 -ac 1 \
-flags:v +bitexact -flags:a +bitexact \
-metadata title="Generated WebM fixture" \
-fflags +bitexact \
"$output_dir/pattern.webm"
printf '%s\n' \
'1' \
'00:00:00,100 --> 00:00:00,700' \
'Generated subtitle' \
>"$output_dir/subtitle.srt"
printf '%s\n' \
'WEBVTT' \
'' \
'00:00:00.100 --> 00:00:00.700' \
'Generated subtitle' \
>"$output_dir/subtitle.vtt"
printf '%s\n' \
'[Script Info]' \
'ScriptType: v4.00+' \
'PlayResX: 128' \
'PlayResY: 72' \
'' \
'[V4+ Styles]' \
'Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding' \
'Style: Default,sans-serif,12,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,1,0,2,8,8,8,1' \
'' \
'[Events]' \
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text' \
'Dialogue: 0,0:00:00.10,0:00:00.70,Default,,0,0,0,,Generated subtitle' \
>"$output_dir/subtitle.ass"
run_ffmpeg \
-i "$output_dir/compatible-a.mp4" \
-map 0 -c copy \
-metadata title="Metadata fixture" \
-metadata artist="add ideas test generator" \
-metadata comment="Local generated media" \
-metadata creation_time="2020-01-01T00:00:00Z" \
"$output_dir/metadata.mp4"
printf '%s\n' \
';FFMETADATA1' \
'title=Chapter fixture' \
'[CHAPTER]' \
'TIMEBASE=1/1000' \
'START=0' \
'END=500' \
'title=First' \
'[CHAPTER]' \
'TIMEBASE=1/1000' \
'START=500' \
'END=1000' \
'title=Second' \
>"$temporary_dir/chapters.ffmeta"
run_ffmpeg \
-i "$output_dir/compatible-b.mp4" \
-i "$temporary_dir/chapters.ffmeta" \
-map 0 -map_metadata 1 -map_chapters 1 -c copy \
-metadata creation_time="2020-01-01T00:00:00Z" \
"$output_dir/chapters.mp4"
run_ffmpeg \
-f lavfi -i "color=c=yellow:size=32x32:rate=1" \
-frames:v 1 -c:v png -threads 1 -flags:v +bitexact \
"$temporary_dir/cover.png"
run_ffmpeg \
-f lavfi -i "sine=frequency=440:sample_rate=16000:duration=1" \
-i "$temporary_dir/cover.png" \
-map 0:a:0 -map 1:v:0 \
-c:a libmp3lame -b:a 32k -ar 16000 -ac 1 \
-c:v png -threads 1 -disposition:v:0 attached_pic \
-id3v2_version 3 -write_xing 0 \
-metadata title="Attached cover fixture" \
-metadata:s:v:0 title="Generated cover" \
-metadata:s:v:0 comment="Cover (front)" \
"$output_dir/attached-cover.mp3"
head -c 128 "$output_dir/compatible-a.mp4" >"$output_dir/truncated.mp4"
for media_name in \
attached-cover.mp3 \
chapters.mp4 \
compatible-a.mp4 \
compatible-b.mp4 \
different-dimensions.mp4 \
metadata.mp4 \
pattern-av.mp4 \
pattern.webm \
tone.flac \
tone.mp3 \
tone.wav
do
ffprobe \
-v error \
-show_entries format=format_name,duration:stream=index,codec_name,codec_type \
-of json \
"$output_dir/$media_name" \
>/dev/null
done
truncated_streams=$(
ffprobe \
-v error \
-show_entries stream=index \
-of csv=p=0 \
"$output_dir/truncated.mp4"
)
if [ -n "$truncated_streams" ]; then
echo "Truncated fixture unexpectedly contains a usable stream." >&2
exit 1
fi
(
cd "$output_dir"
sha256sum \
attached-cover.mp3 \
chapters.mp4 \
compatible-a.mp4 \
compatible-b.mp4 \
different-dimensions.mp4 \
metadata.mp4 \
pattern-av.mp4 \
pattern.webm \
subtitle.ass \
subtitle.srt \
subtitle.vtt \
tone.flac \
tone.mp3 \
tone.wav \
truncated.mp4 \
>SHA256SUMS
)
for fixture_name in $fixture_names; do
chmod 0644 "$output_dir/$fixture_name"
done
echo "Generated fixtures in $output_dir"
ffmpeg -version | sed -n '1p'
(
cd "$output_dir"
sha256sum -c SHA256SUMS
)

View File

@@ -0,0 +1,46 @@
import { readFile, writeFile } from 'node:fs/promises';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
const repositoryRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
const sourcePath = join(
repositoryRoot,
'src',
'toolbox',
'manifest.source.json'
);
const outputPath = join(repositoryRoot, 'public', 'toolbox-app.json');
const packagePath = join(repositoryRoot, 'package.json');
const checkOnly = process.argv.includes('--check');
const source = JSON.parse(await readFile(sourcePath, 'utf8'));
const packageJson = JSON.parse(await readFile(packagePath, 'utf8'));
if (source.version !== packageJson.version) {
throw new Error(
`Manifest version ${source.version} differs from package version ${packageJson.version}`
);
}
if (
source.source?.repository !== 'https://git.add-ideas.de/zemion/av-tools' ||
source.source?.license !== 'GPL-3.0-or-later'
) {
throw new Error(
'Manifest source must identify the canonical av-tools repository and GPL-3.0-or-later licence'
);
}
const serialized = `${JSON.stringify(source, null, 2)}\n`;
if (checkOnly) {
const current = await readFile(outputPath, 'utf8').catch(() => '');
if (current !== serialized) {
throw new Error(
`${relative(repositoryRoot, outputPath)} is stale; run npm run manifest:generate`
);
}
console.log('Toolbox manifest is synchronized with its canonical source');
} else {
await writeFile(outputPath, serialized);
console.log(`Generated ${relative(repositoryRoot, outputPath)}`);
}

View File

@@ -0,0 +1,362 @@
import { createHash } from 'node:crypto';
import {
chmod,
copyFile,
lstat,
mkdir,
mkdtemp,
readFile,
rename,
rm,
writeFile,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { basename, dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawn } from 'node:child_process';
import { createReadStream } from 'node:fs';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const lockPath = join(repositoryRoot, 'scripts', 'ffmpeg-source-lock.json');
const readmePath = join(repositoryRoot, 'scripts', 'ffmpeg-source-README.md');
const releaseDirectory = join(repositoryRoot, 'release');
const force = process.argv.includes('--force');
for (const argument of process.argv.slice(2)) {
if (argument !== '--force') {
throw new Error(`Unknown argument: ${argument}`);
}
}
function assertLock(lock) {
if (
lock?.schemaVersion !== 1 ||
!/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/u.test(
lock.coreVersion
) ||
!/^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/u.test(lock.wrapperRelease) ||
!Number.isSafeInteger(lock.sourceDateEpoch) ||
lock.sourceDateEpoch < 315532800 ||
lock.sourceDateEpoch > 4102444799 ||
!Array.isArray(lock.sources) ||
lock.sources.length === 0
) {
throw new TypeError('Invalid FFmpeg source lock header.');
}
const directories = new Set();
for (const [index, source] of lock.sources.entries()) {
if (
typeof source?.name !== 'string' ||
!/^[a-zA-Z0-9][a-zA-Z0-9 .()_+-]*$/u.test(source.name) ||
typeof source.directory !== 'string' ||
!/^[a-zA-Z0-9][a-zA-Z0-9._/-]*$/u.test(source.directory) ||
source.directory
.split('/')
.some((part) => part === '' || part === '.' || part === '..') ||
directories.has(source.directory) ||
typeof source.repository !== 'string' ||
!source.repository.startsWith('https://') ||
!/^[a-f0-9]{40}$/u.test(source.revision)
) {
throw new TypeError(`Invalid FFmpeg source lock entry ${index}.`);
}
directories.add(source.directory);
}
}
async function run(command, argumentsList, options = {}) {
await new Promise((resolvePromise, rejectPromise) => {
const child = spawn(command, argumentsList, {
stdio: 'inherit',
...options,
});
child.once('error', rejectPromise);
child.once('exit', (code, signal) => {
if (code === 0) {
resolvePromise();
} else {
rejectPromise(
new Error(
`${command} exited with ${code ?? `signal ${signal ?? 'unknown'}`}`
)
);
}
});
});
}
async function output(command, argumentsList, options = {}) {
return await new Promise((resolvePromise, rejectPromise) => {
let stdout = '';
let stderr = '';
const child = spawn(command, argumentsList, {
stdio: ['ignore', 'pipe', 'pipe'],
...options,
});
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', (chunk) => {
stdout += chunk;
});
child.stderr.on('data', (chunk) => {
stderr += chunk;
});
child.once('error', rejectPromise);
child.once('exit', (code, signal) => {
if (code === 0) {
resolvePromise(stdout.trim());
} else {
rejectPromise(
new Error(
`${command} exited with ${code ?? `signal ${signal ?? 'unknown'}`}: ${stderr.trim()}`
)
);
}
});
});
}
async function sha256(file) {
const hash = createHash('sha256');
await new Promise((resolvePromise, rejectPromise) => {
const stream = createReadStream(file);
stream.on('data', (chunk) => hash.update(chunk));
stream.once('error', rejectPromise);
stream.once('end', resolvePromise);
});
return hash.digest('hex');
}
async function optionalDetails(path) {
try {
return await lstat(path);
} catch (error) {
if (error && typeof error === 'object' && error.code === 'ENOENT') {
return undefined;
}
throw error;
}
}
async function ensureReleaseDirectory() {
let details = await optionalDetails(releaseDirectory);
if (!details) {
await mkdir(releaseDirectory, { recursive: true });
details = await lstat(releaseDirectory);
}
if (details.isSymbolicLink() || !details.isDirectory()) {
throw new Error(
`Release path must be a real directory: ${releaseDirectory}`
);
}
}
async function assertReplaceableOutput(path) {
const details = await optionalDetails(path);
if (!details) return false;
if (!force) {
throw new Error(
`${path} already exists; pass --force to replace this exact artifact.`
);
}
if (details.isSymbolicLink() || !details.isFile()) {
throw new Error(`Refusing to replace non-file output ${path}.`);
}
return true;
}
async function publishSourcePair({
archiveExists,
archivePath,
checksumExists,
checksumPath,
stagedArchivePath,
stagedChecksumPath,
stagingRoot,
}) {
const previousArchive = join(stagingRoot, 'previous-archive');
const previousChecksum = join(stagingRoot, 'previous-checksum');
let archiveBackedUp = false;
let checksumBackedUp = false;
let archivePublished = false;
let checksumPublished = false;
try {
if (archiveExists) {
await rename(archivePath, previousArchive);
archiveBackedUp = true;
}
if (checksumExists) {
await rename(checksumPath, previousChecksum);
checksumBackedUp = true;
}
await rename(stagedArchivePath, archivePath);
archivePublished = true;
await rename(stagedChecksumPath, checksumPath);
checksumPublished = true;
} catch (error) {
if (checksumPublished) await rm(checksumPath, { force: true });
if (archivePublished) await rm(archivePath, { force: true });
if (checksumBackedUp) await rename(previousChecksum, checksumPath);
if (archiveBackedUp) await rename(previousArchive, archivePath);
throw error;
}
}
const lock = JSON.parse(await readFile(lockPath, 'utf8'));
assertLock(lock);
const archiveName = `ffmpeg-core-${lock.coreVersion}-corresponding-source.tar.xz`;
const archivePath = join(releaseDirectory, archiveName);
const checksumPath = `${archivePath}.sha256`;
await ensureReleaseDirectory();
const archiveExists = await assertReplaceableOutput(archivePath);
const checksumExists = await assertReplaceableOutput(checksumPath);
const stagingRoot = await mkdtemp(
join(releaseDirectory, '.ffmpeg-source-package-')
);
const stagedArchivePath = join(stagingRoot, archiveName);
const stagedChecksumPath = `${stagedArchivePath}.sha256`;
let temporaryRoot;
const archiveRootName = archiveName.replace(/\.tar\.xz$/u, '');
try {
temporaryRoot = await mkdtemp(
join(tmpdir(), 'av-tools-corresponding-source-')
);
const archiveRoot = join(temporaryRoot, archiveRootName);
const sourcesRoot = join(archiveRoot, 'sources');
await mkdir(sourcesRoot, { recursive: true });
const archiveReadmePath = join(archiveRoot, 'README.md');
const archiveLockPath = join(archiveRoot, 'SOURCE_LOCK.json');
await copyFile(readmePath, archiveReadmePath);
await copyFile(lockPath, archiveLockPath);
await chmod(archiveReadmePath, 0o644);
await chmod(archiveLockPath, 0o644);
for (const [index, source] of lock.sources.entries()) {
const checkout = join(temporaryRoot, `checkout-${index}`);
const sourceTar = join(temporaryRoot, `source-${index}.tar`);
await mkdir(checkout);
await run('git', ['init', '--quiet'], { cwd: checkout });
await run('git', ['remote', 'add', 'origin', source.repository], {
cwd: checkout,
});
await run(
'git',
['fetch', '--quiet', '--depth', '1', 'origin', source.revision],
{ cwd: checkout }
);
const revision = await output('git', ['rev-parse', 'FETCH_HEAD'], {
cwd: checkout,
});
if (revision !== source.revision) {
throw new Error(
`${source.name} resolved to ${revision}, expected ${source.revision}.`
);
}
await run(
'git',
[
'archive',
'--format=tar',
`--prefix=sources/${source.directory}/`,
`--output=${sourceTar}`,
source.revision,
],
{ cwd: checkout }
);
await run('tar', [
'--same-permissions',
'-xf',
sourceTar,
'-C',
archiveRoot,
]);
process.stdout.write(`Archived ${source.name} at ${source.revision}\n`);
}
const timestamp = `@${lock.sourceDateEpoch}`;
await run('find', [
archiveRoot,
'-type',
'd',
'-exec',
'chmod',
'0755',
'{}',
'+',
]);
await run('find', [
archiveRoot,
'-type',
'f',
'-perm',
'/111',
'-exec',
'chmod',
'0755',
'{}',
'+',
]);
await run('find', [
archiveRoot,
'-type',
'f',
'!',
'-perm',
'/111',
'-exec',
'chmod',
'0644',
'{}',
'+',
]);
await run('find', [
archiveRoot,
'-exec',
'touch',
'-h',
'-d',
timestamp,
'{}',
'+',
]);
await run('tar', [
'--sort=name',
'--format=posix',
'--owner=0',
'--group=0',
'--numeric-owner',
`--mtime=${timestamp}`,
'--pax-option=delete=atime,delete=ctime',
'-C',
temporaryRoot,
'-cJf',
stagedArchivePath,
archiveRootName,
]);
const digest = await sha256(stagedArchivePath);
await writeFile(stagedChecksumPath, `${digest} ${basename(archivePath)}\n`, {
mode: 0o644,
});
await chmod(stagedArchivePath, 0o644);
await chmod(stagedChecksumPath, 0o644);
await publishSourcePair({
archiveExists,
archivePath,
checksumExists,
checksumPath,
stagedArchivePath,
stagedChecksumPath,
stagingRoot,
});
process.stdout.write(
`Corresponding Source: ${archivePath}\nSHA-256: ${digest}\n`
);
} finally {
if (temporaryRoot) {
await rm(temporaryRoot, { recursive: true, force: true });
}
await rm(stagingRoot, { recursive: true, force: true });
}

782
scripts/package-release.mjs Normal file
View File

@@ -0,0 +1,782 @@
import {
access,
lstat,
mkdir,
mkdtemp,
readFile,
readdir,
realpath,
rename,
rm,
writeFile,
} from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { zipSync } from 'fflate';
import { checksumLine, sha256 } from './checksum-release.mjs';
const repositoryRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..'
);
const zipEpoch = new Date(1980, 0, 1, 0, 0, 0);
const maximumEntryBytes = 256 * 1024 * 1024;
const maximumArchiveBytes = 2 * 1024 * 1024 * 1024;
const applicationLicenseSpdx = 'GPL-3.0-or-later';
const canonicalRepository = 'https://git.add-ideas.de/zemion/av-tools';
const canonicalPackageRepository =
'git+https://git.add-ideas.de/zemion/av-tools.git';
const requiredNativeLegalFiles = [
'LICENSES/native/FFmpeg-LICENSE.md',
'LICENSES/native/FreeType-LICENSE.txt',
'LICENSES/native/FriBidi-COPYING.txt',
'LICENSES/native/HarfBuzz-COPYING.txt',
'LICENSES/native/LAME-COPYING.txt',
'LICENSES/native/LAME-LICENSE.txt',
'LICENSES/native/Ogg-COPYING.txt',
'LICENSES/native/Opus-COPYING.txt',
'LICENSES/native/Theora-COPYING.txt',
'LICENSES/native/Vorbis-COPYING.txt',
'LICENSES/native/libass-COPYING.txt',
'LICENSES/native/libvpx-LICENSE.txt',
'LICENSES/native/libvpx-PATENTS.txt',
'LICENSES/native/libwebp-COPYING.txt',
'LICENSES/native/libwebp-PATENTS.txt',
'LICENSES/native/x264-COPYING.txt',
'LICENSES/native/x265-COPYING.txt',
'LICENSES/native/zimg-COPYING.txt',
'LICENSES/native/zlib-README.txt',
];
const ffmpegLicenseCoverage = new Map([
['@ffmpeg/ffmpeg', 'LICENSES/ffmpeg.wasm-MIT.txt'],
['@ffmpeg/util', 'LICENSES/ffmpeg.wasm-MIT.txt'],
['@ffmpeg/types', 'LICENSES/ffmpeg.wasm-MIT.txt'],
['@ffmpeg/core', 'LICENSES/GPL-2.0-or-later.txt'],
['@ffmpeg/core-mt', 'LICENSES/GPL-2.0-or-later.txt'],
]);
function compareText(left, right) {
return left < right ? -1 : left > right ? 1 : 0;
}
export function safeArchivePath(fileName) {
if (
typeof fileName !== 'string' ||
fileName.length === 0 ||
fileName.includes('\0') ||
fileName.includes('\\')
) {
throw new Error(`Invalid release path: ${String(fileName)}`);
}
if (fileName.startsWith('/') || /^[A-Za-z]:\//.test(fileName)) {
throw new Error(`Absolute release path is forbidden: ${fileName}`);
}
const components = fileName.split('/');
if (
components.some(
(component) => component === '' || component === '.' || component === '..'
)
) {
throw new Error(`Ambiguous or traversing release path: ${fileName}`);
}
const normalized = path.posix.normalize(fileName);
if (normalized !== fileName) {
throw new Error(`Non-canonical release path: ${fileName}`);
}
return normalized;
}
function relativeArchivePath(root, file) {
return safeArchivePath(
path.relative(root, file).split(path.sep).join(path.posix.sep)
);
}
function isInside(root, candidate) {
return candidate === root || candidate.startsWith(`${root}${path.sep}`);
}
async function collectDirectoryRecursive(
canonicalRoot,
sourceRoot,
directory,
prefix,
entries
) {
const canonicalDirectory = await realpath(directory);
if (!isInside(canonicalRoot, canonicalDirectory)) {
throw new Error(`Directory resolves outside packaging root: ${directory}`);
}
const children = await readdir(directory, { withFileTypes: true });
children.sort((left, right) => compareText(left.name, right.name));
for (const child of children) {
const childPath = path.join(directory, child.name);
const details = await lstat(childPath);
if (details.isSymbolicLink()) {
throw new Error(`Refusing release symlink: ${childPath}`);
}
if (details.isDirectory()) {
await collectDirectoryRecursive(
canonicalRoot,
sourceRoot,
childPath,
prefix,
entries
);
continue;
}
if (!details.isFile()) {
throw new Error(`Refusing special release entry: ${childPath}`);
}
const canonicalFile = await realpath(childPath);
if (!isInside(canonicalRoot, canonicalFile)) {
throw new Error(`File resolves outside packaging root: ${childPath}`);
}
const relativeName = relativeArchivePath(sourceRoot, childPath);
const name = safeArchivePath(
prefix ? path.posix.join(prefix, relativeName) : relativeName
);
entries.push({ name, data: await readFile(childPath) });
}
}
export async function collectDirectoryEntries(directory, prefix = '') {
const details = await lstat(directory);
if (details.isSymbolicLink() || !details.isDirectory()) {
throw new Error(`Release input is not a real directory: ${directory}`);
}
const sourceRoot = path.resolve(directory);
const canonicalRoot = await realpath(directory);
const entries = [];
await collectDirectoryRecursive(
canonicalRoot,
sourceRoot,
sourceRoot,
prefix,
entries
);
return entries.sort((left, right) => compareText(left.name, right.name));
}
async function readRequired(file) {
try {
return await readFile(file);
} catch (error) {
throw new Error(`Required release file is missing: ${file}`, {
cause: error,
});
}
}
async function existingApplicationLicense(root) {
for (const fileName of ['LICENSE', 'LICENSE.txt', 'COPYING']) {
const candidate = path.join(root, fileName);
try {
const details = await lstat(candidate);
if (details.isSymbolicLink() || !details.isFile()) {
throw new Error(
`Application licence path must be a regular file: ${candidate}`
);
}
return candidate;
} catch (error) {
if (error && typeof error === 'object' && error.code === 'ENOENT') {
continue;
}
throw error;
}
}
return null;
}
export async function assertLicenseReady(root, allowDevelopmentSmoke = false) {
const applicationLicense = await existingApplicationLicense(root);
if (applicationLicense) {
const legalCode = await readFile(applicationLicense, 'utf8');
if (
!legalCode.includes('GNU GENERAL PUBLIC LICENSE') ||
!legalCode.includes('Version 3, 29 June 2007') ||
!legalCode.includes('END OF TERMS AND CONDITIONS')
) {
throw new Error(
`Application licence does not contain the complete GNU GPL version 3 legal code: ${applicationLicense}`
);
}
return applicationLicense;
}
if (allowDevelopmentSmoke) return null;
throw new Error(
[
'RELEASE BLOCKED: av-tools has no application licence.',
'The FFmpeg and dependency notices do not license the application itself.',
'For a local, non-publishable portal test only, pass',
'`--allow-unlicensed-development-smoke`.',
].join(' ')
);
}
function safePackageName(name) {
return name.replace(/^@/, '').replaceAll('/', '--');
}
async function findInstalledPackageDirectory(name, fromDirectory) {
let current = path.resolve(fromDirectory);
const packageSegments = name.split('/');
while (true) {
const candidate = path.join(current, 'node_modules', ...packageSegments);
try {
const details = await lstat(path.join(candidate, 'package.json'));
if (!details.isFile()) {
throw new Error(`Package manifest is not a file: ${candidate}`);
}
return await realpath(candidate);
} catch (error) {
if (!(error && typeof error === 'object' && error.code === 'ENOENT')) {
throw error;
}
}
const parent = path.dirname(current);
if (parent === current) return null;
current = parent;
}
}
async function collectRuntimePackages(root, packageJson) {
const queue = Object.keys(packageJson.dependencies ?? {})
.sort(compareText)
.map((name) => ({ name, fromDirectory: root }));
const visited = new Set();
const packages = [];
while (queue.length > 0) {
const dependency = queue.shift();
const directory = await findInstalledPackageDirectory(
dependency.name,
dependency.fromDirectory
);
if (!directory) {
throw new Error(
`Runtime dependency is not installed: ${dependency.name}`
);
}
const manifest = JSON.parse(
await readFile(path.join(directory, 'package.json'), 'utf8')
);
const identity = `${manifest.name}@${manifest.version}`;
if (visited.has(identity)) continue;
visited.add(identity);
packages.push({
directory,
name: manifest.name,
version: manifest.version,
});
const childNames = [
...new Set([
...Object.keys(manifest.dependencies ?? {}),
...Object.keys(manifest.optionalDependencies ?? {}),
]),
].sort(compareText);
for (const name of childNames) {
const childDirectory = await findInstalledPackageDirectory(
name,
directory
);
if (childDirectory) {
queue.push({ name, fromDirectory: directory });
}
}
}
return packages.sort((left, right) =>
compareText(
`${left.name}@${left.version}`,
`${right.name}@${right.version}`
)
);
}
export async function collectRuntimeLicenseEntries(root, packageJson) {
const packages = await collectRuntimePackages(root, packageJson);
const entries = [];
for (const runtimePackage of packages) {
const coveredBy = ffmpegLicenseCoverage.get(runtimePackage.name);
if (coveredBy) {
await access(path.join(root, coveredBy));
continue;
}
const legalFiles = (
await readdir(runtimePackage.directory, {
withFileTypes: true,
})
)
.filter(
(entry) =>
entry.isFile() &&
/^(?:licen[cs]e|copying|notice)(?:\.|$)/i.test(entry.name)
)
.sort((left, right) => compareText(left.name, right.name));
if (legalFiles.length === 0) {
throw new Error(
`Runtime dependency ${runtimePackage.name}@${runtimePackage.version} has no packaged licence file.`
);
}
const packageDirectory = `${safePackageName(runtimePackage.name)}-${runtimePackage.version}`;
for (const legalFile of legalFiles) {
entries.push({
name: safeArchivePath(
`LICENSES/npm/${packageDirectory}/${legalFile.name}`
),
data: await readFile(
path.join(runtimePackage.directory, legalFile.name)
),
});
}
}
return entries;
}
function scanReleaseEntry(entry) {
if (entry.name.endsWith('.map')) {
throw new Error(`Source map is not approved for release: ${entry.name}`);
}
const lowerParts = entry.name.toLowerCase().split('/');
if (
lowerParts.some((part) =>
[
'.env',
'.npmrc',
'.git',
'id_rsa',
'id_ed25519',
'credentials',
].includes(part)
) ||
/\.(?:pem|key|p12|pfx)$/i.test(entry.name)
) {
throw new Error(`Credential-like release path is forbidden: ${entry.name}`);
}
// Exact FFmpeg vendor bytes are checked against pinned SHA-256 values. The
// generated Emscripten glue contains upstream build paths such as
// /home/web_user that are not paths from this repository or workstation.
if (entry.name.startsWith('vendor/ffmpeg/')) return;
if (!/\.(?:css|html|js|json|md|mjs|svg|txt)$/i.test(entry.name)) return;
const text = entry.data.toString('utf8');
if (/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/.test(text)) {
throw new Error(`Private-key material detected in ${entry.name}`);
}
if (/https?:\/\/[^/@\s]+:[^/@\s]+@/i.test(text)) {
throw new Error(`Credential-bearing URL detected in ${entry.name}`);
}
if (
/(?:^|[\s"'`])(?:\/home\/|\/mnt\/|\/Users\/|[A-Za-z]:\\Users\\)/m.test(text)
) {
throw new Error(`Local absolute path detected in ${entry.name}`);
}
}
function assertUniqueSortedEntries(entries) {
entries.sort((left, right) => compareText(left.name, right.name));
let totalBytes = 0;
let previous;
for (const entry of entries) {
safeArchivePath(entry.name);
if (entry.name === previous) {
throw new Error(`Duplicate release path: ${entry.name}`);
}
previous = entry.name;
if (entry.data.byteLength > maximumEntryBytes) {
throw new Error(`Release entry exceeds 256 MiB: ${entry.name}`);
}
totalBytes += entry.data.byteLength;
if (totalBytes > maximumArchiveBytes) {
throw new Error('Release exceeds the 2 GiB uncompressed-size limit.');
}
scanReleaseEntry(entry);
}
}
function manifestPath(reference, field, directoryIndex = false) {
if (
typeof reference !== 'string' ||
/^[A-Za-z][A-Za-z0-9+.-]*:/.test(reference) ||
reference.startsWith('/') ||
reference.startsWith('//') ||
reference.includes('?') ||
reference.includes('#')
) {
throw new Error(`${field} must be a plain relative packaged path.`);
}
let normalized = reference.replace(/^\.\//, '');
if (normalized === '' && directoryIndex) normalized = 'index.html';
else if (normalized.endsWith('/') && directoryIndex) {
normalized = `${normalized}index.html`;
}
return safeArchivePath(normalized);
}
function validateReleaseLayout(entries, packageJson, applicationLicense) {
const entriesByName = new Map(entries.map((entry) => [entry.name, entry]));
const names = new Set(entries.map((entry) => entry.name));
for (const required of [
'index.html',
'toolbox-app.json',
'CHANGELOG.md',
'SOURCE.md',
'THIRD_PARTY_NOTICES.md',
...requiredNativeLegalFiles,
]) {
if (!names.has(required)) {
throw new Error(`Required release root file is missing: ${required}`);
}
}
for (const prefix of ['LICENSES/', 'assets/', 'vendor/']) {
if (![...names].some((name) => name.startsWith(prefix))) {
throw new Error(`Required release directory is empty: ${prefix}`);
}
}
const manifestEntry = entries.find(
(entry) => entry.name === 'toolbox-app.json'
);
const manifest = JSON.parse(manifestEntry.data.toString('utf8'));
if (
manifest.id !== 'de.add-ideas.av-tools' ||
manifest.version !== packageJson.version
) {
throw new Error(
'Packaged manifest id/version does not match av-tools package.json.'
);
}
if (
applicationLicense &&
(manifest.source?.repository !== canonicalRepository ||
manifest.source?.license !== applicationLicenseSpdx)
) {
throw new Error(
'A licensed release requires an accurate, non-placeholder manifest.source declaration.'
);
}
if (!applicationLicense && manifest.source !== undefined) {
throw new Error(
'An unlicensed development artifact must omit manifest.source.'
);
}
const references = [
[manifest.entry, 'manifest.entry', true],
[manifest.icon, 'manifest.icon', false],
...(manifest.assets ?? []).map((asset, index) => [
asset,
`manifest.assets[${index}]`,
false,
]),
];
for (const [reference, field, directoryIndex] of references) {
const requiredPath = manifestPath(reference, field, directoryIndex);
if (!names.has(requiredPath)) {
throw new Error(`${field} is missing from release: ${reference}`);
}
}
const vendorManifestPath = 'vendor/ffmpeg/0.12.10/version.json';
const vendorManifestEntry = entriesByName.get(vendorManifestPath);
if (!vendorManifestEntry) {
throw new Error(
`Required FFmpeg verification file is missing: ${vendorManifestPath}`
);
}
const vendorManifest = JSON.parse(vendorManifestEntry.data.toString('utf8'));
if (
vendorManifest.schemaVersion !== 1 ||
vendorManifest.coreVersion !== '0.12.10' ||
vendorManifest.packages?.['@ffmpeg/core'] !== '0.12.10' ||
vendorManifest.packages?.['@ffmpeg/core-mt'] !== '0.12.10' ||
!Array.isArray(vendorManifest.assets) ||
vendorManifest.assets.length !== 5
) {
throw new Error(
'Packaged FFmpeg version.json has an unexpected shape or version.'
);
}
const verifiedVendorPaths = new Set();
for (const asset of vendorManifest.assets) {
if (
typeof asset.path !== 'string' ||
!Number.isSafeInteger(asset.bytes) ||
!/^[a-f0-9]{64}$/.test(asset.sha256)
) {
throw new Error(
'Packaged FFmpeg version.json contains invalid asset metadata.'
);
}
const assetPath = safeArchivePath(
path.posix.join('vendor/ffmpeg/0.12.10', asset.path)
);
if (verifiedVendorPaths.has(assetPath)) {
throw new Error(`Duplicate FFmpeg verification path: ${assetPath}`);
}
verifiedVendorPaths.add(assetPath);
const packagedAsset = entriesByName.get(assetPath);
if (
!packagedAsset ||
packagedAsset.data.byteLength !== asset.bytes ||
sha256(packagedAsset.data) !== asset.sha256
) {
throw new Error(
`Packaged FFmpeg asset failed byte verification: ${assetPath}`
);
}
}
const declaredVendorPaths = new Set(
(manifest.assets ?? []).map((reference) =>
manifestPath(reference, 'manifest.assets', false)
)
);
if (
verifiedVendorPaths.size !== declaredVendorPaths.size ||
[...verifiedVendorPaths].some(
(assetPath) => !declaredVendorPaths.has(assetPath)
)
) {
throw new Error(
'Manifest FFmpeg assets and the verified vendor asset set do not match.'
);
}
const indexHtml = entries
.find((entry) => entry.name === 'index.html')
.data.toString('utf8');
if (/(?:src|href)\s*=\s*["']\/(?!\/)/i.test(indexHtml)) {
throw new Error('index.html contains a root-absolute asset reference.');
}
}
export function createDeterministicZip(inputEntries) {
const entries = inputEntries.map((entry) => ({
name: safeArchivePath(entry.name),
data: Buffer.from(entry.data),
}));
assertUniqueSortedEntries(entries);
const zippable = Object.create(null);
for (const entry of entries) {
zippable[entry.name] = [
new Uint8Array(entry.data),
{
attrs: (0o100644 << 16) >>> 0,
level: 6,
mtime: zipEpoch,
os: 3,
},
];
}
return Buffer.from(
zipSync(zippable, {
attrs: (0o100644 << 16) >>> 0,
level: 6,
mtime: zipEpoch,
os: 3,
})
);
}
export async function collectReleaseEntries(options = {}) {
const root = options.root ?? repositoryRoot;
const packageJson = JSON.parse(
await readFile(path.join(root, 'package.json'), 'utf8')
);
const applicationLicense = await assertLicenseReady(
root,
options.allowUnlicensedDevelopmentSmoke
);
if (
applicationLicense &&
(packageJson.license !== applicationLicenseSpdx ||
packageJson.repository?.url !== canonicalPackageRepository ||
packageJson.homepage !== canonicalRepository)
) {
throw new Error(
'Licensed release package metadata must identify GPL-3.0-or-later and the canonical av-tools repository.'
);
}
const entries = await collectDirectoryEntries(path.join(root, 'dist'));
for (const fileName of [
'README.md',
'CHANGELOG.md',
'SOURCE.md',
'THIRD_PARTY_NOTICES.md',
]) {
entries.push({
name: fileName,
data: await readRequired(path.join(root, fileName)),
});
}
entries.push(
...(await collectDirectoryEntries(path.join(root, 'LICENSES'), 'LICENSES'))
);
entries.push(...(await collectRuntimeLicenseEntries(root, packageJson)));
if (applicationLicense) {
const applicationLicenseBytes = await readFile(applicationLicense);
entries.push({
name: path.basename(applicationLicense),
data: applicationLicenseBytes,
});
entries.push({
name: 'LICENSES/av-tools-LICENSE.txt',
data: applicationLicenseBytes,
});
} else {
entries.push({
name: 'DEVELOPMENT-ONLY-NOT-FOR-DISTRIBUTION.txt',
data: Buffer.from(
[
'UNLICENSED DEVELOPMENT SMOKE ARTIFACT',
'',
'av-tools has no application licence. This ZIP exists only to test',
'local Toolbox Portal assembly. Do not publish or distribute it.',
'',
].join('\n')
),
});
}
assertUniqueSortedEntries(entries);
validateReleaseLayout(entries, packageJson, applicationLicense);
return { entries, packageJson, unlicensed: !applicationLicense };
}
export function parseReleaseArguments(argumentsList) {
const allowed = new Set(['--force', '--allow-unlicensed-development-smoke']);
const unknown = argumentsList.filter((argument) => !allowed.has(argument));
if (unknown.length > 0) {
throw new Error(`Unknown release argument: ${unknown.join(', ')}`);
}
return {
force: argumentsList.includes('--force'),
allowUnlicensedDevelopmentSmoke: argumentsList.includes(
'--allow-unlicensed-development-smoke'
),
};
}
async function assertWritableTarget(target, force) {
try {
const details = await lstat(target);
if (details.isSymbolicLink() || !details.isFile()) {
throw new Error(`Release target is not a regular file: ${target}`);
}
if (!force) {
throw new Error(
`${path.relative(repositoryRoot, target)} exists; pass --force to replace this exact version.`
);
}
return true;
} catch (error) {
if (error && typeof error === 'object' && error.code === 'ENOENT') {
return false;
}
throw error;
}
}
async function publishReleasePair({
archive,
archivePath,
checksum,
checksumPath,
archiveExists,
checksumExists,
}) {
const releaseDirectory = path.dirname(archivePath);
const staging = await mkdtemp(
path.join(releaseDirectory, '.av-tools-release-')
);
const stagedArchive = path.join(staging, path.basename(archivePath));
const stagedChecksum = path.join(staging, path.basename(checksumPath));
const previousArchive = path.join(staging, 'previous-archive');
const previousChecksum = path.join(staging, 'previous-checksum');
let archiveBackedUp = false;
let checksumBackedUp = false;
let archivePublished = false;
let checksumPublished = false;
try {
await writeFile(stagedArchive, archive, { flag: 'wx' });
await writeFile(stagedChecksum, checksum, {
encoding: 'utf8',
flag: 'wx',
});
if (archiveExists) {
await rename(archivePath, previousArchive);
archiveBackedUp = true;
}
if (checksumExists) {
await rename(checksumPath, previousChecksum);
checksumBackedUp = true;
}
await rename(stagedArchive, archivePath);
archivePublished = true;
await rename(stagedChecksum, checksumPath);
checksumPublished = true;
} catch (error) {
if (checksumPublished) await rm(checksumPath, { force: true });
if (archivePublished) await rm(archivePath, { force: true });
if (checksumBackedUp) await rename(previousChecksum, checksumPath);
if (archiveBackedUp) await rename(previousArchive, archivePath);
throw error;
} finally {
await rm(staging, { force: true, recursive: true });
}
}
async function main() {
const options = parseReleaseArguments(process.argv.slice(2));
const { entries, packageJson, unlicensed } = await collectReleaseEntries({
allowUnlicensedDevelopmentSmoke: options.allowUnlicensedDevelopmentSmoke,
});
const releaseDirectory = path.join(repositoryRoot, 'release');
await mkdir(releaseDirectory, { recursive: true });
const releaseDirectoryDetails = await lstat(releaseDirectory);
if (
releaseDirectoryDetails.isSymbolicLink() ||
!releaseDirectoryDetails.isDirectory()
) {
throw new Error('release/ must be a real directory.');
}
const archiveName = `av-tools-${packageJson.version}.zip`;
const archivePath = path.join(releaseDirectory, archiveName);
const checksumPath = `${archivePath}.sha256`;
const archiveExists = await assertWritableTarget(archivePath, options.force);
const checksumExists = await assertWritableTarget(
checksumPath,
options.force
);
const archive = createDeterministicZip(entries);
const digest = sha256(archive);
await publishReleasePair({
archive,
archiveExists,
archivePath,
checksum: checksumLine(digest, archiveName),
checksumExists,
checksumPath,
});
if (unlicensed) {
console.warn(
'WARNING: created an unlicensed development smoke artifact. Do not publish or distribute it.'
);
}
console.log(`Created release/${archiveName}`);
console.log(`Created release/${archiveName}.sha256`);
console.log(`SHA-256 ${digest}`);
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href
) {
await main();
}

View File

@@ -0,0 +1,408 @@
import { spawn } from 'node:child_process';
import {
copyFile,
lstat,
mkdir,
mkdtemp,
readFile,
rm,
writeFile,
} from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { sha256File, verifyChecksum } from './checksum-release.mjs';
const repositoryRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..'
);
function consumeValue(argumentsList, index, option) {
const value = argumentsList[index + 1];
if (!value || value.startsWith('--')) {
throw new Error(`${option} requires a path value.`);
}
return value;
}
export function parsePortalArguments(argumentsList, root = repositoryRoot) {
const packageDefaults = {
artifact: undefined,
portalRoot: path.join(path.dirname(root), 'references', 'toolbox-portal'),
allowUnlicensedDevelopmentSmoke: false,
allowKnownHeaderGap: false,
keepTemp: false,
};
for (let index = 0; index < argumentsList.length; index += 1) {
const argument = argumentsList[index];
if (argument === '--artifact') {
packageDefaults.artifact = path.resolve(
root,
consumeValue(argumentsList, index, argument)
);
index += 1;
} else if (argument === '--portal-root') {
packageDefaults.portalRoot = path.resolve(
root,
consumeValue(argumentsList, index, argument)
);
index += 1;
} else if (argument === '--allow-unlicensed-development-smoke') {
packageDefaults.allowUnlicensedDevelopmentSmoke = true;
} else if (argument === '--allow-known-header-gap') {
packageDefaults.allowKnownHeaderGap = true;
} else if (argument === '--keep-temp') {
packageDefaults.keepTemp = true;
} else {
throw new Error(`Unknown portal-smoke argument: ${argument}`);
}
}
return packageDefaults;
}
function run(command, argumentsList, options = {}) {
console.log(`\n> ${command} ${argumentsList.join(' ')}`);
return new Promise((resolve, reject) => {
const child = spawn(command, argumentsList, {
cwd: options.cwd,
env: {
...process.env,
npm_config_audit: 'false',
npm_config_fund: 'false',
},
stdio: 'inherit',
});
child.once('error', reject);
child.once('exit', (code, signal) => {
if (code === 0) resolve();
else {
reject(
new Error(
`${command} failed with ${signal ? `signal ${signal}` : `exit code ${code}`}.`
)
);
}
});
});
}
async function capture(command, argumentsList, cwd) {
return await new Promise((resolve, reject) => {
const child = spawn(command, argumentsList, {
cwd,
env: process.env,
stdio: ['ignore', 'pipe', 'pipe'],
});
const stdout = [];
const stderr = [];
child.stdout.on('data', (chunk) => stdout.push(chunk));
child.stderr.on('data', (chunk) => stderr.push(chunk));
child.once('error', reject);
child.once('exit', (code) => {
if (code === 0) {
resolve(Buffer.concat(stdout).toString('utf8').trim());
} else {
reject(
new Error(
`${command} exited ${code}: ${Buffer.concat(stderr).toString('utf8').trim()}`
)
);
}
});
});
}
async function requireRegularFile(file, label) {
const details = await lstat(file);
if (details.isSymbolicLink() || !details.isFile()) {
throw new Error(`${label} is not a regular file: ${file}`);
}
}
function includesDevelopmentMarker(archive) {
return archive.includes(
Buffer.from('DEVELOPMENT-ONLY-NOT-FOR-DISTRIBUTION.txt', 'utf8')
);
}
function cspDirectives(policy) {
return new Map(
policy
.split(';')
.map((directive) => directive.trim().split(/\s+/))
.filter((parts) => parts[0])
.map(([name, ...values]) => [name, new Set(values)])
);
}
export function inspectPortalHeaders(configuration) {
const errors = [];
const warnings = [];
const requiredHeaders = [
['Cross-Origin-Opener-Policy', 'same-origin'],
['Cross-Origin-Embedder-Policy', 'require-corp'],
['Cross-Origin-Resource-Policy', 'same-origin'],
];
for (const [header, value] of requiredHeaders) {
const expression = new RegExp(
`add_header\\s+${header}\\s+"?${value}"?\\s+always`,
'i'
);
if (!expression.test(configuration)) {
errors.push(`Missing ${header}: ${value}`);
}
}
const cspMatch =
/add_header\s+Content-Security-Policy\s+"([^"]+)"\s+always/i.exec(
configuration
);
if (!cspMatch) {
errors.push('Missing Content-Security-Policy header');
} else {
const directives = cspDirectives(cspMatch[1]);
const requires = (directive, value) =>
directives.get(directive)?.has(value) === true;
if (!requires('script-src', "'wasm-unsafe-eval'")) {
errors.push("CSP script-src lacks 'wasm-unsafe-eval'");
}
if (!requires('worker-src', "'self'") || !requires('worker-src', 'blob:')) {
errors.push("CSP worker-src must contain 'self' and blob:");
}
if (!requires('media-src', "'self'") || !requires('media-src', 'blob:')) {
errors.push("CSP media-src must contain 'self' and blob:");
}
}
if (!/application\/wasm\s+wasm(?:\s|;)/i.test(configuration)) {
warnings.push(
'The config does not explicitly map `.wasm` to application/wasm; verify the pinned image mime.types response.'
);
}
if (
!configuration.includes('vendor/ffmpeg/0\\.12\\.10') &&
!configuration.includes('vendor/ffmpeg/0.12.10')
) {
warnings.push(
'The config does not give versioned FFmpeg core assets an explicit immutable cache rule.'
);
}
return { errors, warnings };
}
async function verifyAssembly(output, appVersion, digest) {
const catalogue = JSON.parse(
await readFile(path.join(output, 'toolbox.catalog.json'), 'utf8')
);
if (
catalogue.apps?.length !== 1 ||
catalogue.apps[0]?.manifest !== './apps/av/toolbox-app.json'
) {
throw new Error('Generated catalogue does not contain the locked av app.');
}
const release = JSON.parse(
await readFile(path.join(output, 'toolbox.release.json'), 'utf8')
);
const provenance = release.apps?.[0];
if (
release.apps?.length !== 1 ||
provenance.id !== 'de.add-ideas.av-tools' ||
provenance.version !== appVersion ||
provenance.sha256 !== digest ||
provenance.target !== 'av'
) {
throw new Error('Generated release provenance does not match the AV lock.');
}
const appRoot = path.join(output, 'apps', 'av');
const manifest = JSON.parse(
await readFile(path.join(appRoot, 'toolbox-app.json'), 'utf8')
);
if (
manifest.id !== 'de.add-ideas.av-tools' ||
manifest.version !== appVersion ||
manifest.requirements?.crossOriginIsolated !== false
) {
throw new Error(
'Assembled AV manifest identity/fallback contract changed.'
);
}
for (const reference of [
'./index.html',
'./vendor/ffmpeg/0.12.10/st/ffmpeg-core.js',
'./vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm',
'./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.js',
'./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm',
'./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.worker.js',
]) {
const relative = reference.replace(/^\.\//, '');
await requireRegularFile(
path.join(appRoot, ...relative.split('/')),
`Assembled ${reference}`
);
}
const indexHtml = await readFile(path.join(appRoot, 'index.html'), 'utf8');
if (/(?:src|href)\s*=\s*["']\/(?!\/)/i.test(indexHtml)) {
throw new Error('Assembled app contains a root-absolute entry asset URL.');
}
}
async function main() {
const options = parsePortalArguments(process.argv.slice(2));
const packageJson = JSON.parse(
await readFile(path.join(repositoryRoot, 'package.json'), 'utf8')
);
const artifact =
options.artifact ??
path.join(repositoryRoot, 'release', `av-tools-${packageJson.version}.zip`);
const sidecar = `${artifact}.sha256`;
await requireRegularFile(artifact, 'AV release artifact');
await requireRegularFile(sidecar, 'AV release checksum');
const digest = await verifyChecksum(artifact, sidecar);
const artifactBytes = await readFile(artifact);
const markedUnlicensed = includesDevelopmentMarker(artifactBytes);
if (markedUnlicensed && !options.allowUnlicensedDevelopmentSmoke) {
throw new Error(
'The ZIP is marked unlicensed development-only. Pass `--allow-unlicensed-development-smoke` for local assembly testing.'
);
}
const portalRootDetails = await lstat(options.portalRoot);
if (portalRootDetails.isSymbolicLink() || !portalRootDetails.isDirectory()) {
throw new Error(
`Portal reference must be a real Git directory: ${options.portalRoot}`
);
}
const portalRevision = await capture(
'git',
['rev-parse', 'HEAD'],
options.portalRoot
);
const temporaryRoot = await mkdtemp('/tmp/av-tools-portal-smoke-');
if (!temporaryRoot.startsWith('/tmp/av-tools-portal-smoke-')) {
throw new Error(`Unexpected temporary path: ${temporaryRoot}`);
}
const portalClone = path.join(temporaryRoot, 'toolbox-portal');
let successful = false;
try {
await run(
'git',
[
'clone',
'--no-local',
'--quiet',
'--no-checkout',
options.portalRoot,
portalClone,
],
{ cwd: temporaryRoot }
);
await run('git', ['checkout', '--quiet', '--detach', portalRevision], {
cwd: portalClone,
});
const clonedRevision = await capture(
'git',
['rev-parse', 'HEAD'],
portalClone
);
if (clonedRevision !== portalRevision) {
throw new Error('Temporary Portal clone revision changed unexpectedly.');
}
const smokeArtifacts = path.join(portalClone, 'smoke-artifacts');
await mkdir(smokeArtifacts);
const copiedArtifact = path.join(smokeArtifacts, path.basename(artifact));
await copyFile(artifact, copiedArtifact);
await copyFile(sidecar, `${copiedArtifact}.sha256`);
const templatePath = path.join(
portalClone,
'release',
'toolbox.lock.example.json'
);
const lock = JSON.parse(await readFile(templatePath, 'utf8'));
const portalPackage = JSON.parse(
await readFile(path.join(portalClone, 'package.json'), 'utf8')
);
lock.releaseVersion = `${packageJson.version}-smoke.0`;
lock.portalVersion = portalPackage.version;
lock.apps = [
{
id: 'de.add-ideas.av-tools',
version: packageJson.version,
artifact: `../smoke-artifacts/${path.basename(artifact)}`,
sha256: digest,
target: 'av',
},
];
const lockPath = path.join(
portalClone,
'release',
'av-tools.smoke.lock.json'
);
await writeFile(lockPath, `${JSON.stringify(lock, null, 2)}\n`, {
flag: 'wx',
});
await run('npm', ['ci'], { cwd: portalClone });
await run('npm', ['test'], { cwd: portalClone });
await run('npm', ['run', 'build'], { cwd: portalClone });
const output = path.join(portalClone, 'build', 'toolbox-av-smoke');
await run(
'npm',
[
'run',
'assemble',
'--',
'--lock',
'release/av-tools.smoke.lock.json',
'--portal-dist',
'dist',
'--output',
'build/toolbox-av-smoke',
'--archive',
'build/add-ideas-toolbox-av-smoke.zip',
],
{ cwd: portalClone }
);
await verifyAssembly(output, packageJson.version, digest);
const headerInspection = inspectPortalHeaders(
await readFile(path.join(portalClone, 'deploy', 'nginx.conf'), 'utf8')
);
for (const warning of headerInspection.warnings) {
console.warn(`Portal header warning: ${warning}`);
}
if (headerInspection.errors.length > 0) {
const message = `Portal header gaps: ${headerInspection.errors.join('; ')}`;
if (!options.allowKnownHeaderGap) throw new Error(message);
console.warn(
`KNOWN HEADER GAP ACCEPTED FOR DEVELOPMENT SMOKE: ${message}`
);
}
successful = true;
console.log('\nPortal assembly smoke passed');
console.log(`Portal revision: ${portalRevision}`);
console.log(`AV artifact SHA-256: ${digest}`);
console.log('Assembled target: apps/av/');
console.log(
'Validated: checksum, extraction, manifest identity/version, catalogue, ST/MT assets, relative entry URLs and ST fallback contract.'
);
} finally {
if (options.keepTemp) {
console.log(
`${successful ? 'Kept' : 'Failed; kept'} temporary Portal smoke directory: ${temporaryRoot}`
);
} else {
await rm(temporaryRoot, { force: true, recursive: true });
}
}
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href
) {
await main();
}

View File

@@ -0,0 +1,80 @@
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
const repositoryRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
const expectedVersion = '0.12.10';
const outputRoot = join(
repositoryRoot,
'public',
'vendor',
'ffmpeg',
expectedVersion
);
const manifestPath = join(outputRoot, 'version.json');
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
if (
manifest.schemaVersion !== 1 ||
manifest.coreVersion !== expectedVersion ||
!Array.isArray(manifest.assets) ||
manifest.assets.length !== 5
) {
throw new Error(`Invalid FFmpeg asset manifest: ${manifestPath}`);
}
const expectedPaths = new Set([
'mt/ffmpeg-core.js',
'mt/ffmpeg-core.wasm',
'mt/ffmpeg-core.worker.js',
'st/ffmpeg-core.js',
'st/ffmpeg-core.wasm',
]);
const expectedHashes = new Map([
[
'st/ffmpeg-core.js',
'67a48f11645f85439f3fde4f2119042c16b374b910206b7a7a24f342e28dcae3',
],
[
'st/ffmpeg-core.wasm',
'9f57947a5bd530d8f00c5b3f2cb2a3492faa7e5d823315342d6a8656d0a6b7b7',
],
[
'mt/ffmpeg-core.js',
'270a2e6ff945e173238610669a3f7132df5f9c52698a9bf708cf5c2ab6bda0de',
],
[
'mt/ffmpeg-core.wasm',
'be2c97605366b78f3f13e21b52e81a55a79e1f29c133b03a68ec187b1a2ec41a',
],
[
'mt/ffmpeg-core.worker.js',
'f77898d631dc010b45c29c23cb4379c611a7d7b131bf591d08a656bb729a4ca3',
],
]);
for (const asset of manifest.assets) {
if (!expectedPaths.delete(asset.path)) {
throw new Error(`Unexpected or duplicate FFmpeg asset: ${asset.path}`);
}
const bytes = await readFile(join(outputRoot, asset.path));
const digest = createHash('sha256').update(bytes).digest('hex');
if (
asset.bytes !== bytes.byteLength ||
asset.sha256 !== digest ||
expectedHashes.get(asset.path) !== digest
) {
throw new Error(`FFmpeg asset verification failed: ${asset.path}`);
}
}
if (expectedPaths.size > 0) {
throw new Error(
`FFmpeg assets are missing: ${[...expectedPaths].join(', ')}`
);
}
console.log(
`Verified 5 FFmpeg ${expectedVersion} assets in ${relative(repositoryRoot, outputRoot)}`
);

4584
src/App.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,37 @@
import { Component, type ErrorInfo, type ReactNode } from 'react';
interface Props {
children: ReactNode;
}
interface State {
error?: Error;
}
export class AppErrorBoundary extends Component<Props, State> {
state: State = {};
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo): void {
console.error('Unhandled av-tools application error', error, info);
}
render(): ReactNode {
if (this.state.error) {
return (
<main className="fatal-error" role="alert">
<p className="eyebrow">Application error</p>
<h1>The workspace could not continue.</h1>
<p>{this.state.error.message}</p>
<button type="button" onClick={() => globalThis.location.reload()}>
Reload application
</button>
</main>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,8 @@
import { useEffect } from 'react';
import App from '../App';
import { engineManagerLifecycle } from '../ffmpeg/engine-lifecycle';
export function ApplicationRuntime() {
useEffect(() => engineManagerLifecycle.acquire(), []);
return <App />;
}

View File

@@ -0,0 +1,67 @@
import type { MediaProbe } from '../media';
export type AssetPhase = 'queued' | 'probing' | 'ready' | 'error';
export interface ImportedMediaAsset {
id: string;
file: File;
objectUrl: string;
phase: AssetPhase;
probe?: MediaProbe;
rawProbe?: string;
error?: string;
}
export interface ExportResult {
id: string;
name: string;
mimeType: string;
bytes: number;
blob: Blob;
objectUrl: string;
createdAt: string;
}
export type WorkspaceMode = 'quick-convert' | 'edit';
export function createAsset(file: File): ImportedMediaAsset {
return {
id: crypto.randomUUID(),
file,
objectUrl: URL.createObjectURL(file),
phase: 'queued',
};
}
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return 'Unknown size';
if (bytes < 1_000) return `${bytes} B`;
const units = ['kB', 'MB', 'GB', 'TB'];
let value = bytes / 1_000;
let unit = units[0];
for (let index = 1; index < units.length && value >= 1_000; index += 1) {
value /= 1_000;
unit = units[index];
}
return `${value.toFixed(value >= 10 ? 1 : 2)} ${unit}`;
}
export function formatDuration(seconds: number | undefined): string {
if (seconds === undefined || !Number.isFinite(seconds)) return 'Unknown';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remaining = seconds % 60;
return [
...(hours > 0 ? [String(hours)] : []),
String(minutes).padStart(hours > 0 ? 2 : 1, '0'),
remaining.toFixed(2).padStart(5, '0'),
].join(':');
}
export function releaseAssets(assets: readonly ImportedMediaAsset[]): void {
for (const asset of assets) URL.revokeObjectURL(asset.objectUrl);
}
export function releaseResults(results: readonly ExportResult[]): void {
for (const result of results) URL.revokeObjectURL(result.objectUrl);
}

61
src/app/derived-cache.ts Normal file
View File

@@ -0,0 +1,61 @@
import {
createCacheKey,
createSourceFingerprint,
openDerivedCache,
type DerivedCache,
type DerivedCacheKind,
} from '../storage';
let cachePromise: Promise<DerivedCache> | undefined;
export function getDerivedCache(): Promise<DerivedCache> {
cachePromise ??= openDerivedCache();
return cachePromise;
}
function derivativeCacheKey(
file: File,
operation: string,
settings: Readonly<Record<string, unknown>>
): string {
return createCacheKey({
sourceFingerprint: createSourceFingerprint(file),
operation,
settings,
version: 1,
});
}
export async function readCachedDerivative(
file: File,
operation: string,
settings: Readonly<Record<string, unknown>>
): Promise<Blob | undefined> {
const cache = await getDerivedCache();
return (await cache.get(derivativeCacheKey(file, operation, settings)))?.data;
}
export async function cacheDerivative(
file: File,
operation: string,
kind: DerivedCacheKind,
projectId: string,
blob: Blob,
settings: Readonly<Record<string, unknown>>
): Promise<void> {
const cache = await getDerivedCache();
const key = derivativeCacheKey(file, operation, settings);
const expiresAt = new Date(
Date.now() + 7 * 24 * 60 * 60 * 1_000
).toISOString();
await cache.put(
{
key,
kind,
projectId,
mediaType: blob.type || 'application/octet-stream',
expiresAt,
},
blob
);
}

View File

@@ -0,0 +1,24 @@
export interface BrowserFeatures {
secureContext: boolean;
workers: boolean;
indexedDb: boolean;
opfs: boolean;
fileSystemAccess: boolean;
crossOriginIsolated: boolean;
sharedArrayBuffer: boolean;
}
export function detectBrowserFeatures(): BrowserFeatures {
const scope = globalThis as typeof globalThis & {
showSaveFilePicker?: unknown;
};
return {
secureContext: globalThis.isSecureContext === true,
workers: typeof Worker !== 'undefined',
indexedDb: typeof indexedDB !== 'undefined',
opfs: typeof navigator.storage?.getDirectory === 'function',
fileSystemAccess: typeof scope.showSaveFilePicker === 'function',
crossOriginIsolated: globalThis.crossOriginIsolated === true,
sharedArrayBuffer: typeof SharedArrayBuffer !== 'undefined',
};
}

BIN
src/assets/hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

366
src/commands/chapters.ts Normal file
View File

@@ -0,0 +1,366 @@
import type { SourceReference } from './command-utils';
import {
chapterSecondsToTicks,
DEFAULT_CHAPTER_TIME_BASE,
parseChapterTimeBase,
} from '../media/chapter-time-base';
import {
assertMuxerMatchesExtension,
createPlannedInput,
createPlannedOutput,
diagnostic,
workVirtualPath,
} from './command-utils';
import {
freezeCommandPlan,
type CommandDiagnostic,
type FFmpegCommandPlan,
} from './command-plan';
import { validateSplitMarkers } from './split';
export interface EditableChapter {
readonly id: string;
readonly title: string;
readonly startSeconds: number;
readonly endSeconds: number;
readonly timeBase?: string;
}
export interface ChapterValidationOptions {
readonly durationSeconds?: number;
readonly allowOverlap?: boolean;
}
export interface ChapterDocument {
readonly schemaVersion: 1;
readonly chapters: readonly EditableChapter[];
}
export function createChaptersFromMarkers(
markers: readonly number[],
durationSeconds: number,
titles: readonly string[] = []
): readonly EditableChapter[] {
const sorted = validateSplitMarkers(markers, durationSeconds);
const boundaries = [0, ...sorted, durationSeconds];
return Object.freeze(
boundaries.slice(0, -1).map((startSeconds, index) =>
Object.freeze({
id: `chapter-${String(index + 1).padStart(3, '0')}`,
title: titles[index]?.trim() || `Chapter ${index + 1}`,
startSeconds,
endSeconds: boundaries[index + 1] as number,
timeBase: '1/1000',
})
)
);
}
export function createChaptersAtIntervals(
durationSeconds: number,
intervalSeconds: number
): readonly EditableChapter[] {
if (
!Number.isFinite(durationSeconds) ||
durationSeconds <= 0 ||
!Number.isFinite(intervalSeconds) ||
intervalSeconds <= 0
) {
throw new RangeError('Chapter duration and interval must be positive');
}
const markers: number[] = [];
for (
let marker = intervalSeconds;
marker < durationSeconds;
marker += intervalSeconds
) {
markers.push(marker);
if (markers.length > 10_000) {
throw new RangeError('Fixed interval would create too many chapters');
}
}
return createChaptersFromMarkers(markers, durationSeconds);
}
export function validateChapters(
chapters: readonly EditableChapter[],
options: ChapterValidationOptions = {}
): readonly CommandDiagnostic[] {
const diagnostics: CommandDiagnostic[] = [];
const ids = new Set<string>();
const titles = new Set<string>();
let previousEnd = 0;
chapters.forEach((chapter, index) => {
if (!chapter.id || ids.has(chapter.id)) {
diagnostics.push(
diagnostic(
'chapter-id',
'error',
'Chapter IDs must be nonempty and unique.',
`${index}.id`
)
);
}
ids.add(chapter.id);
if (!chapter.title.trim()) {
diagnostics.push(
diagnostic(
'chapter-title',
'error',
'Chapter title is required.',
`${index}.title`
)
);
} else if (titles.has(chapter.title.trim())) {
diagnostics.push(
diagnostic(
'duplicate-chapter-title',
'warning',
`Duplicate chapter title: ${chapter.title.trim()}`,
`${index}.title`
)
);
}
titles.add(chapter.title.trim());
if (
!Number.isFinite(chapter.startSeconds) ||
!Number.isFinite(chapter.endSeconds) ||
chapter.startSeconds < 0 ||
chapter.endSeconds <= chapter.startSeconds
) {
diagnostics.push(
diagnostic(
'chapter-range',
'error',
'Chapter start must be nonnegative and before its end.',
`${index}.startSeconds`
)
);
}
const timeBase = parseChapterTimeBase(
chapter.timeBase ?? DEFAULT_CHAPTER_TIME_BASE
);
if (timeBase === undefined) {
diagnostics.push(
diagnostic(
'chapter-time-base',
'error',
'Chapter time base must be a positive integer fraction such as 1/1000.',
`${index}.timeBase`
)
);
} else {
const startTick = chapterSecondsToTicks(chapter.startSeconds, timeBase);
const endTick = chapterSecondsToTicks(chapter.endSeconds, timeBase);
if (
startTick === undefined ||
endTick === undefined ||
endTick <= startTick
) {
diagnostics.push(
diagnostic(
'chapter-time-base-resolution',
'error',
'Chapter time base is too coarse or large for this chapter range.',
`${index}.timeBase`
)
);
}
}
if (
options.durationSeconds !== undefined &&
chapter.endSeconds > options.durationSeconds
) {
diagnostics.push(
diagnostic(
'chapter-bounds',
'error',
'Chapter extends beyond output duration.',
`${index}.endSeconds`
)
);
}
if (chapter.startSeconds < previousEnd) {
diagnostics.push(
diagnostic(
options.allowOverlap
? 'chapter-overlap'
: 'chapter-overlap-forbidden',
options.allowOverlap ? 'warning' : 'error',
'Chapter overlaps or is out of chronological order.',
`${index}.startSeconds`
)
);
}
previousEnd = Math.max(previousEnd, chapter.endSeconds);
});
return Object.freeze(diagnostics);
}
export function escapeFFMetadata(value: string): string {
if (value.includes('\0')) {
throw new TypeError('FFMETADATA values cannot contain NUL');
}
return value
.replace(/\\/gu, '\\\\')
.replace(/\r\n?|\n/gu, '\\\n')
.replace(/([=;#])/gu, '\\$1');
}
export function serializeFFMetadata(
chapters: readonly EditableChapter[],
options: ChapterValidationOptions = {}
): string {
const diagnostics = validateChapters(chapters, options);
const errors = diagnostics.filter((entry) => entry.severity === 'error');
if (errors.length > 0) {
throw new RangeError(errors.map((entry) => entry.message).join('; '));
}
const lines = [';FFMETADATA1'];
for (const chapter of chapters) {
const timeBase = parseChapterTimeBase(
chapter.timeBase ?? DEFAULT_CHAPTER_TIME_BASE
);
if (timeBase === undefined) {
throw new TypeError('Chapter time base failed validation');
}
const startTick = chapterSecondsToTicks(chapter.startSeconds, timeBase);
const endTick = chapterSecondsToTicks(chapter.endSeconds, timeBase);
if (startTick === undefined || endTick === undefined) {
throw new RangeError('Chapter time cannot be represented safely');
}
lines.push(
'[CHAPTER]',
`TIMEBASE=${timeBase.normalized}`,
`START=${startTick}`,
`END=${endTick}`,
`title=${escapeFFMetadata(chapter.title)}`
);
}
return `${lines.join('\n')}\n`;
}
export function serializeChapterJson(
chapters: readonly EditableChapter[]
): string {
const document: ChapterDocument = { schemaVersion: 1, chapters };
const errors = validateChapters(chapters).filter(
(entry) => entry.severity === 'error'
);
if (errors.length > 0) {
throw new TypeError(errors.map((entry) => entry.message).join('; '));
}
return `${JSON.stringify(document, null, 2)}\n`;
}
export function parseChapterJson(json: string): ChapterDocument {
let value: unknown;
try {
value = JSON.parse(json) as unknown;
} catch {
throw new TypeError('Chapter JSON is malformed');
}
if (
!isRecord(value) ||
value.schemaVersion !== 1 ||
!Array.isArray(value.chapters)
) {
throw new TypeError('Chapter document must use schemaVersion 1');
}
const chapters = value.chapters.map((entry, index) =>
parseChapter(entry, index)
);
const errors = validateChapters(chapters).filter(
(entry) => entry.severity === 'error'
);
if (errors.length > 0) {
throw new TypeError(errors.map((entry) => entry.message).join('; '));
}
return Object.freeze({ schemaVersion: 1, chapters: Object.freeze(chapters) });
}
export interface ChapterMuxOptions {
readonly jobId: string;
readonly source: SourceReference;
readonly chapters: readonly EditableChapter[];
readonly targetExtension: string;
readonly targetMuxer: string;
readonly durationSeconds?: number;
}
export function buildChapterMuxPlan(
options: ChapterMuxOptions
): FFmpegCommandPlan {
assertMuxerMatchesExtension(options.targetMuxer, options.targetExtension);
const metadataContent = serializeFFMetadata(options.chapters, {
durationSeconds: options.durationSeconds,
});
const input = createPlannedInput(options.jobId, options.source);
const metadataPath = workVirtualPath(options.jobId, 'chapters.ffmeta');
const output = createPlannedOutput(options.jobId, {
baseName: options.source.fileName,
operation: 'chapters',
extension: options.targetExtension,
});
return freezeCommandPlan({
id: `${options.jobId}:chapters`,
operation: 'chapters',
inputs: [input],
temporaryFiles: [
{ path: metadataPath, content: metadataContent, purpose: 'metadata' },
],
args: [
'-i',
input.path,
'-i',
metadataPath,
'-map',
'0',
'-map_metadata',
'0',
'-map_chapters',
'1',
'-c',
'copy',
output.path,
],
outputs: [output],
...(options.durationSeconds !== undefined
? { expectedDurationSeconds: options.durationSeconds }
: {}),
requiredCapabilities: { muxers: [options.targetMuxer] },
diagnostics: [
diagnostic(
'chapter-round-trip',
'info',
'Re-probe the output to verify chapter count, times, and titles.'
),
],
});
}
function parseChapter(value: unknown, index: number): EditableChapter {
if (!isRecord(value)) {
throw new TypeError(`chapters.${index} must be an object`);
}
if (
typeof value.id !== 'string' ||
typeof value.title !== 'string' ||
typeof value.startSeconds !== 'number' ||
typeof value.endSeconds !== 'number' ||
(value.timeBase !== undefined && typeof value.timeBase !== 'string')
) {
throw new TypeError(`chapters.${index} has invalid fields`);
}
return {
id: value.id,
title: value.title,
startSeconds: value.startSeconds,
endSeconds: value.endSeconds,
...(typeof value.timeBase === 'string' ? { timeBase: value.timeBase } : {}),
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

View File

@@ -0,0 +1,196 @@
export type CapabilityKind = 'muxers' | 'encoders' | 'decoders' | 'filters';
export interface PresetRequirements {
readonly muxers?: readonly string[];
readonly encoders?: readonly string[];
readonly decoders?: readonly string[];
readonly filters?: readonly string[];
}
export interface PlannedInput {
readonly id: string;
readonly path: string;
readonly sourceIndex: number;
readonly kind: 'media' | 'subtitle' | 'font' | 'metadata' | 'generated';
}
export interface PlannedTemporaryFile {
readonly path: string;
readonly content: string | Uint8Array;
readonly purpose:
'concat-list' | 'metadata' | 'subtitle' | 'analysis' | 'other';
}
export interface PlannedOutput {
readonly id: string;
readonly path: string;
readonly fileName: string;
readonly mediaType: string;
/** Exact source interval represented by this output, when applicable. */
readonly timeRange?: {
readonly startSeconds: number;
readonly endSeconds: number;
};
readonly role:
'media' | 'audio' | 'subtitle' | 'thumbnail' | 'contact-sheet' | 'analysis';
}
export type DiagnosticSeverity = 'info' | 'warning' | 'error';
export interface CommandDiagnostic {
readonly code: string;
readonly severity: DiagnosticSeverity;
readonly message: string;
readonly field?: string;
}
export interface FFmpegCommandPlan {
readonly id: string;
readonly operation: string;
readonly inputs: readonly PlannedInput[];
readonly temporaryFiles: readonly PlannedTemporaryFile[];
/**
* Structured argv passed directly to FFmpeg. It is never interpreted by a
* shell and intentionally excludes the executable name.
*/
readonly args: readonly string[];
readonly outputs: readonly PlannedOutput[];
readonly expectedDurationSeconds?: number;
readonly timeoutMs?: number;
readonly requiredCapabilities: PresetRequirements;
readonly diagnostics: readonly CommandDiagnostic[];
}
export interface CommandPlanDefaults {
readonly timeoutMs?: number;
readonly expectedDurationSeconds?: number;
}
export function freezeCommandPlan(plan: FFmpegCommandPlan): FFmpegCommandPlan {
validateCommandPlanShape(plan);
const requirements: PresetRequirements = Object.freeze({
...(plan.requiredCapabilities.muxers
? { muxers: Object.freeze([...plan.requiredCapabilities.muxers]) }
: {}),
...(plan.requiredCapabilities.encoders
? { encoders: Object.freeze([...plan.requiredCapabilities.encoders]) }
: {}),
...(plan.requiredCapabilities.decoders
? { decoders: Object.freeze([...plan.requiredCapabilities.decoders]) }
: {}),
...(plan.requiredCapabilities.filters
? { filters: Object.freeze([...plan.requiredCapabilities.filters]) }
: {}),
});
return Object.freeze({
...plan,
inputs: Object.freeze(
plan.inputs.map((entry) => Object.freeze({ ...entry }))
),
temporaryFiles: Object.freeze(
plan.temporaryFiles.map((entry) => Object.freeze({ ...entry }))
),
args: Object.freeze([...plan.args]),
outputs: Object.freeze(
plan.outputs.map((entry) =>
Object.freeze({
...entry,
...(entry.timeRange
? { timeRange: Object.freeze({ ...entry.timeRange }) }
: {}),
})
)
),
requiredCapabilities: requirements,
diagnostics: Object.freeze(
plan.diagnostics.map((entry) => Object.freeze({ ...entry }))
),
});
}
function validateCommandPlanShape(plan: FFmpegCommandPlan): void {
if (!plan.id.trim() || !plan.operation.trim()) {
throw new TypeError('Command plan id and operation are required');
}
if (
plan.args.some(
(argument) => argument.length === 0 || argument.includes('\0')
)
) {
throw new TypeError('Command arguments cannot be empty or contain NUL');
}
if (
plan.expectedDurationSeconds !== undefined &&
(!Number.isFinite(plan.expectedDurationSeconds) ||
plan.expectedDurationSeconds <= 0)
) {
throw new RangeError('Expected command duration must be positive');
}
if (
plan.timeoutMs !== undefined &&
(!Number.isSafeInteger(plan.timeoutMs) || plan.timeoutMs <= 0)
) {
throw new RangeError('Command timeout must be a positive integer');
}
assertUnique(
plan.outputs.map((output) => output.id),
'output id'
);
assertUnique(
plan.outputs.map((output) => output.path),
'output path'
);
assertUnique(
plan.temporaryFiles.map((temporary) => temporary.path),
'temporary-file path'
);
const temporaryPaths = new Set(
plan.temporaryFiles.map((temporary) => temporary.path)
);
if (plan.outputs.some((output) => temporaryPaths.has(output.path))) {
throw new TypeError('Output and temporary-file paths must be distinct');
}
for (const output of plan.outputs) {
if (
output.timeRange &&
(!Number.isFinite(output.timeRange.startSeconds) ||
output.timeRange.startSeconds < 0 ||
!Number.isFinite(output.timeRange.endSeconds) ||
output.timeRange.endSeconds <= output.timeRange.startSeconds)
) {
throw new RangeError(
`Output "${output.id}" has an invalid source time range.`
);
}
if (!plan.args.some((argument) => argument.includes(output.path))) {
throw new TypeError(
`Declared output path is absent from command arguments: ${output.path}`
);
}
}
for (const temporary of plan.temporaryFiles) {
if (!plan.args.some((argument) => argument.includes(temporary.path))) {
throw new TypeError(
`Declared temporary path is absent from command arguments: ${temporary.path}`
);
}
}
}
function assertUnique(values: readonly string[], label: string): void {
const unique = new Set(values);
if (unique.size !== values.length) {
throw new TypeError(`Command plan contains a duplicate ${label}`);
}
}
export class CommandPlanError extends Error {
readonly diagnostics: readonly CommandDiagnostic[];
constructor(message: string, diagnostics: readonly CommandDiagnostic[]) {
super(message);
this.name = 'CommandPlanError';
this.diagnostics = diagnostics;
}
}

View File

@@ -0,0 +1,409 @@
import type {
CommandDiagnostic,
PlannedInput,
PlannedOutput,
PresetRequirements,
} from './command-plan';
// eslint-disable-next-line no-control-regex -- control characters are deliberately removed from virtual filenames.
const CONTROL_OR_SEPARATOR = /[\u0000-\u001f\u007f/\\]+/gu;
const UNSAFE_FILE_CHARACTER = /[^a-zA-Z0-9._-]+/gu;
const RESERVED_COMPONENT = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu;
export interface SourceReference {
readonly id: string;
readonly sourceIndex: number;
readonly fileName: string;
readonly extension?: string;
}
export interface OutputReference {
readonly id?: string;
readonly baseName: string;
readonly operation: string;
readonly extension: string;
readonly role?: PlannedOutput['role'];
readonly mediaType?: string;
readonly suffix?: string;
readonly timeRange?: PlannedOutput['timeRange'];
/** Optional user-facing leaf name. Its extension is always replaced. */
readonly fileName?: string;
}
export interface StreamSelection {
readonly video?: readonly number[];
readonly audio?: readonly number[];
readonly subtitles?: readonly number[];
readonly attachments?: readonly number[];
readonly data?: readonly number[];
}
export function normalizeExtension(extension: string): string {
const normalized = extension.trim().replace(/^\.+/u, '').toLowerCase();
if (!/^[a-z0-9]{1,10}$/u.test(normalized)) {
throw new TypeError(`Invalid file extension: ${extension}`);
}
return normalized;
}
export function safePathComponent(value: string, fallback = 'media'): string {
let result = value
.normalize('NFKC')
.replace(CONTROL_OR_SEPARATOR, '-')
.replace(UNSAFE_FILE_CHARACTER, '-')
.replace(/-+/gu, '-')
.replace(/^[ ._-]+|[ ._-]+$/gu, '')
.slice(0, 96);
if (
!result ||
result === '.' ||
result === '..' ||
RESERVED_COMPONENT.test(result)
) {
result = fallback;
}
return result;
}
export function sourceVirtualPath(
jobId: string,
source: SourceReference
): string {
const job = safePathComponent(jobId, 'job');
const extension = source.extension
? `.${normalizeExtension(source.extension)}`
: extensionFromFileName(source.fileName);
return `/input/job-${job}/source-${source.sourceIndex}${extension}`;
}
export function workVirtualPath(jobId: string, fileName: string): string {
return `/work/job-${safePathComponent(jobId, 'job')}/${safePathComponent(fileName)}`;
}
export function createPlannedInput(
jobId: string,
source: SourceReference,
kind: PlannedInput['kind'] = 'media'
): PlannedInput {
return {
id: source.id,
path: sourceVirtualPath(jobId, source),
sourceIndex: source.sourceIndex,
kind,
};
}
export function createPlannedOutput(
jobId: string,
reference: OutputReference
): PlannedOutput {
const extension = normalizeExtension(reference.extension);
const fileName = reference.fileName
? `${safePathComponent(stripExtension(reference.fileName), 'output').slice(
0,
180
)}.${extension}`
: `${[
safePathComponent(stripExtension(reference.baseName), 'output'),
safePathComponent(reference.operation, 'export'),
reference.suffix
? safePathComponent(reference.suffix, 'result')
: undefined,
]
.filter((part): part is string => Boolean(part))
.join('-')
.slice(0, 180)}.${extension}`;
return {
id: reference.id ?? `${reference.operation}-output`,
path: workVirtualPath(jobId, fileName),
fileName,
mediaType: reference.mediaType ?? mediaTypeForExtension(extension),
role: reference.role ?? 'media',
...(reference.timeRange ? { timeRange: reference.timeRange } : {}),
};
}
export function mediaTypeForExtension(extension: string): string {
const mediaTypes: Readonly<Record<string, string>> = {
mp4: 'video/mp4',
m4a: 'audio/mp4',
webm: 'video/webm',
mkv: 'video/x-matroska',
mp3: 'audio/mpeg',
ogg: 'audio/ogg',
opus: 'audio/ogg',
wav: 'audio/wav',
flac: 'audio/flac',
gif: 'image/gif',
webp: 'image/webp',
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
srt: 'application/x-subrip',
vtt: 'text/vtt',
ass: 'text/x-ssa',
json: 'application/json',
};
return (
mediaTypes[normalizeExtension(extension)] ?? 'application/octet-stream'
);
}
export function muxerForExtension(extension: string): string | undefined {
const muxers: Readonly<Record<string, string>> = {
mp4: 'mp4',
m4a: 'ipod',
m4v: 'ipod',
mov: 'mov',
webm: 'webm',
mkv: 'matroska',
mp3: 'mp3',
ogg: 'ogg',
opus: 'opus',
wav: 'wav',
flac: 'flac',
gif: 'gif',
webp: 'webp',
png: 'image2',
jpg: 'image2',
jpeg: 'image2',
srt: 'srt',
vtt: 'webvtt',
ass: 'ass',
};
return muxers[normalizeExtension(extension)];
}
export function assertMuxerMatchesExtension(
muxer: string,
extension: string
): void {
const inferred = muxerForExtension(extension);
if (!inferred) {
return;
}
const normalizedMuxer =
muxer === 'mkv' ? 'matroska' : muxer === 'm4a' ? 'ipod' : muxer;
if (normalizedMuxer !== inferred) {
throw new TypeError(
`Muxer ${muxer} does not match the .${normalizeExtension(extension)} output extension (expected ${inferred})`
);
}
}
export function stripExtension(fileName: string): string {
const cleaned = fileName.replace(CONTROL_OR_SEPARATOR, '-');
const lastDot = cleaned.lastIndexOf('.');
return lastDot > 0 ? cleaned.slice(0, lastDot) : cleaned;
}
function extensionFromFileName(fileName: string): string {
const lastDot = fileName.lastIndexOf('.');
if (lastDot <= 0 || lastDot === fileName.length - 1) {
return '';
}
try {
return `.${normalizeExtension(fileName.slice(lastDot + 1))}`;
} catch {
return '';
}
}
export function assertFiniteNonNegative(value: number, name: string): void {
if (!Number.isFinite(value) || value < 0) {
throw new RangeError(`${name} must be a finite nonnegative number`);
}
}
export function normalizeSeconds(value: number): string {
assertFiniteNonNegative(value, 'time');
if (value === 0) {
return '0';
}
return value.toFixed(6).replace(/0+$/u, '').replace(/\.$/u, '');
}
export function validateRange(
startSeconds: number,
endSeconds: number,
durationSeconds?: number,
minimumDurationSeconds = 0.04
): void {
assertFiniteNonNegative(startSeconds, 'startSeconds');
assertFiniteNonNegative(endSeconds, 'endSeconds');
if (endSeconds <= startSeconds) {
throw new RangeError('endSeconds must be greater than startSeconds');
}
if (endSeconds - startSeconds < minimumDurationSeconds) {
throw new RangeError(
`clip duration must be at least ${minimumDurationSeconds} seconds`
);
}
if (
durationSeconds !== undefined &&
(!Number.isFinite(durationSeconds) || endSeconds > durationSeconds + 1e-6)
) {
throw new RangeError('clip range exceeds source duration');
}
}
export function streamMapArguments(
selection: StreamSelection
): readonly string[] {
const result: string[] = [];
const selectedIndexes = new Set<number>();
const groups: readonly (keyof StreamSelection)[] = [
'video',
'audio',
'subtitles',
'attachments',
'data',
];
for (const key of groups) {
const indexes = selection[key];
if (!indexes) {
continue;
}
if (new Set(indexes).size !== indexes.length) {
throw new RangeError(`Duplicate ${key} stream selection`);
}
for (const index of indexes) {
if (!Number.isSafeInteger(index) || index < 0) {
throw new RangeError(`Invalid ${key} stream index: ${index}`);
}
if (selectedIndexes.has(index)) {
throw new RangeError(
`Stream index ${index} was selected in more than one stream group`
);
}
selectedIndexes.add(index);
// StreamSelection stores the absolute ffprobe stream index. A typed
// specifier such as 0:a:1 would instead mean "the second audio stream"
// and silently points at the wrong stream for common video=0/audio=1
// inputs.
result.push('-map', `0:${index}`);
}
}
return Object.freeze(result);
}
export function mergeRequirements(
...requirements: readonly PresetRequirements[]
): PresetRequirements {
const kinds = ['muxers', 'encoders', 'decoders', 'filters'] as const;
const result: Record<(typeof kinds)[number], string[]> = {
muxers: [],
encoders: [],
decoders: [],
filters: [],
};
for (const requirement of requirements) {
for (const kind of kinds) {
for (const value of requirement[kind] ?? []) {
if (!result[kind].includes(value)) {
result[kind].push(value);
}
}
}
}
return Object.fromEntries(
kinds
.filter((kind) => result[kind].length > 0)
.map((kind) => [kind, Object.freeze(result[kind])])
) as PresetRequirements;
}
export function diagnostic(
code: string,
severity: CommandDiagnostic['severity'],
message: string,
field?: string
): CommandDiagnostic {
return field
? { code, severity, message, field }
: { code, severity, message };
}
/**
* Shell-like rendering for diagnostics only. The returned string must never be
* executed; command plans always expose argv separately.
*/
export function displayArguments(args: readonly string[]): string {
return args.map(displayArgument).join(' ');
}
function displayArgument(argument: string): string {
if (/^[a-zA-Z0-9_./:=+%@,-]+$/u.test(argument)) {
return argument;
}
return `'${argument.replace(/'/gu, `'\\''`)}'`;
}
export function escapeConcatFilePath(path: string): string {
if (path.includes('\n') || path.includes('\r') || path.includes('\0')) {
throw new TypeError('Concat paths cannot contain line breaks or NUL');
}
return path.replace(/'/gu, "'\\''");
}
export function createConcatList(paths: readonly string[]): string {
if (paths.length === 0) {
throw new RangeError('At least one concat path is required');
}
return `${paths.map((path) => `file '${escapeConcatFilePath(path)}'`).join('\n')}\n`;
}
export interface ConcatListEntry {
readonly path: string;
readonly inPointSeconds?: number;
readonly outPointSeconds?: number;
}
export function createConcatListEntries(
entries: readonly ConcatListEntry[]
): string {
if (entries.length === 0) {
throw new RangeError('At least one concat entry is required');
}
const lines: string[] = [];
for (const entry of entries) {
lines.push(`file '${escapeConcatFilePath(entry.path)}'`);
if (entry.inPointSeconds !== undefined) {
lines.push(`inpoint ${normalizeSeconds(entry.inPointSeconds)}`);
}
if (entry.outPointSeconds !== undefined) {
lines.push(`outpoint ${normalizeSeconds(entry.outPointSeconds)}`);
}
if (
entry.inPointSeconds !== undefined &&
entry.outPointSeconds !== undefined &&
entry.outPointSeconds <= entry.inPointSeconds
) {
throw new RangeError('Concat outpoint must be after its inpoint');
}
}
return `${lines.join('\n')}\n`;
}
export function escapeFilterValue(value: string): string {
if (value.includes('\0') || value.includes('\n') || value.includes('\r')) {
throw new TypeError('Filter values cannot contain line breaks or NUL');
}
return value
.replace(/\\/gu, '\\\\')
.replace(/'/gu, "\\'")
.replace(/:/gu, '\\:')
.replace(/,/gu, '\\,')
.replace(/;/gu, '\\;')
.replace(/\[/gu, '\\[')
.replace(/\]/gu, '\\]');
}
export function escapeMetadataValue(value: string): string {
if (value.includes('\0')) {
throw new TypeError('Metadata values cannot contain NUL');
}
return value.replace(/\r\n?/gu, '\n');
}

499
src/commands/concatenate.ts Normal file
View File

@@ -0,0 +1,499 @@
import { presetEncodingArguments } from '../presets/preset-registry';
import type { ExportPreset } from './convert';
import { metadataPolicyArguments, requirementsForUserPreset } from './convert';
import {
assertMuxerMatchesExtension,
createConcatListEntries,
createPlannedInput,
createPlannedOutput,
diagnostic,
mergeRequirements,
normalizeSeconds,
validateRange,
workVirtualPath,
type SourceReference,
} from './command-utils';
import {
CommandPlanError,
freezeCommandPlan,
type CommandDiagnostic,
type FFmpegCommandPlan,
type PlannedTemporaryFile,
} from './command-plan';
export interface ConcatStreamDescription {
readonly kind: 'video' | 'audio' | 'subtitle';
readonly codec: string;
readonly width?: number;
readonly height?: number;
readonly pixelFormat?: string;
readonly timeBase?: string;
readonly sampleRate?: number;
readonly channelLayout?: string;
}
export interface ConcatSource extends SourceReference {
/** Full probed source duration. */
readonly durationSeconds: number;
readonly sourceInSeconds?: number;
readonly sourceOutSeconds?: number;
readonly streams: readonly ConcatStreamDescription[];
}
export type MissingAudioPolicy = 'insert-silence' | 'drop-all' | 'reject';
const MAX_CONCAT_SOURCES = 100;
export interface FastConcatOptions {
readonly jobId: string;
readonly sources: readonly ConcatSource[];
readonly targetExtension: string;
readonly targetMuxer: string;
}
export interface NormalizedConcatOptions extends FastConcatOptions {
readonly preset: ExportPreset;
readonly width: number;
readonly height: number;
readonly frameRate: number;
readonly sampleRate?: number;
readonly channelLayout?: 'mono' | 'stereo' | '5.1';
readonly missingAudioPolicy: MissingAudioPolicy;
readonly subtitlePolicy?: 'drop-all' | 'reject';
}
export function validateConcatCompatibility(
sources: readonly ConcatSource[]
): readonly CommandDiagnostic[] {
const diagnostics: CommandDiagnostic[] = [];
if (sources.length < 2) {
diagnostics.push(
diagnostic(
'concat-input-count',
'error',
'Concatenation requires at least two inputs.'
)
);
return diagnostics;
}
if (sources.length > MAX_CONCAT_SOURCES) {
diagnostics.push(
diagnostic(
'concat-input-limit',
'error',
`Concatenation is limited to ${MAX_CONCAT_SOURCES} browser inputs per operation.`
)
);
return diagnostics;
}
sources.forEach((source, index) => {
if (
!Number.isFinite(source.durationSeconds) ||
source.durationSeconds <= 0
) {
diagnostics.push(
diagnostic(
'source-duration',
'error',
`Clip ${index + 1} must have a positive finite duration.`,
`sources.${index}.durationSeconds`
)
);
}
try {
selectedSourceRange(source);
} catch (error) {
diagnostics.push(
diagnostic(
'source-range',
'error',
error instanceof Error
? `Clip ${index + 1}: ${error.message}`
: `Clip ${index + 1} has an invalid source range.`,
`sources.${index}.sourceInSeconds`
)
);
}
});
const baseline = sources[0]?.streams ?? [];
sources.slice(1).forEach((source, sourceIndex) => {
if (source.streams.length !== baseline.length) {
diagnostics.push(
diagnostic(
'stream-arrangement',
'error',
`Clip ${sourceIndex + 2} has a different stream count.`
)
);
return;
}
source.streams.forEach((stream, streamIndex) => {
const expected = baseline[streamIndex];
if (!expected || stream.kind !== expected.kind) {
diagnostics.push(
diagnostic(
'stream-arrangement',
'error',
`Clip ${sourceIndex + 2}, stream ${streamIndex} has a different stream type.`
)
);
return;
}
for (const key of comparableKeys(stream.kind)) {
if (stream[key] !== expected[key]) {
diagnostics.push(
diagnostic(
'stream-property',
'error',
`Clip ${sourceIndex + 2} has incompatible ${key}: ${String(
stream[key]
)} instead of ${String(expected[key])}.`,
`sources.${sourceIndex + 1}.streams.${streamIndex}.${key}`
)
);
}
}
});
});
return Object.freeze(diagnostics);
}
export function buildFastConcatPlan(
options: FastConcatOptions
): FFmpegCommandPlan {
assertMuxerMatchesExtension(options.targetMuxer, options.targetExtension);
const diagnostics = validateConcatCompatibility(options.sources);
if (diagnostics.some((entry) => entry.severity === 'error')) {
throw new CommandPlanError(
'Inputs are not stream-copy compatible',
diagnostics
);
}
const inputs = options.sources.map((source) =>
createPlannedInput(options.jobId, source)
);
const ranges = options.sources.map(selectedSourceRange);
const concatPath = workVirtualPath(options.jobId, 'concat-list.txt');
const temporaryFile: PlannedTemporaryFile = {
path: concatPath,
content: createConcatListEntries(
inputs.map((input, index) => {
const source = options.sources[index];
const range = ranges[index];
if (!source || !range) {
throw new TypeError(`Missing concat source ${index + 1}`);
}
const hasExplicitRange =
source.sourceInSeconds !== undefined ||
source.sourceOutSeconds !== undefined;
return {
path: input.path,
...(hasExplicitRange
? {
inPointSeconds: range.startSeconds,
outPointSeconds: range.endSeconds,
}
: {}),
};
})
),
purpose: 'concat-list',
};
const output = createPlannedOutput(options.jobId, {
baseName: options.sources[0]?.fileName ?? 'sequence',
operation: 'concat',
extension: options.targetExtension,
});
const duration = ranges.reduce(
(sum, range) => sum + range.durationSeconds,
0
);
const hasTrimmedClips = options.sources.some(
(source) =>
source.sourceInSeconds !== undefined ||
source.sourceOutSeconds !== undefined
);
return freezeCommandPlan({
id: `${options.jobId}:concat`,
operation: 'concat-fast',
inputs,
temporaryFiles: [temporaryFile],
args: [
'-f',
'concat',
'-safe',
'0',
'-i',
concatPath,
'-map',
'0',
'-c',
'copy',
'-progress',
'pipe:1',
'-nostats',
output.path,
],
outputs: [output],
expectedDurationSeconds: duration,
requiredCapabilities: { muxers: [options.targetMuxer] },
diagnostics: [
diagnostic(
'generated-concat-list',
'info',
'The concat list contains only generated virtual paths; original filenames are not inserted.'
),
...(hasTrimmedClips
? [
diagnostic(
'concat-keyframe-boundaries',
'warning' as const,
'Fast concat in/out points use stream copy; packets around sparse keyframes can extend beyond the requested clip boundaries.'
),
]
: []),
],
});
}
export function buildNormalizedConcatPlan(
options: NormalizedConcatOptions
): FFmpegCommandPlan {
assertMuxerMatchesExtension(options.targetMuxer, options.targetExtension);
if (
options.sources.length < 2 ||
options.sources.length > MAX_CONCAT_SOURCES
) {
throw new RangeError(
`Concatenation requires 2${MAX_CONCAT_SOURCES} inputs`
);
}
if (!options.preset.video) {
throw new TypeError('Normalized concatenation requires a video preset');
}
if (
options.targetExtension.replace(/^\.+/u, '').toLowerCase() !==
options.preset.fileExtension
) {
throw new TypeError(
`Normalized concat extension must match the preset (.${options.preset.fileExtension})`
);
}
validateOutputGeometry(options.width, options.height, options.frameRate);
if (
options.sampleRate !== undefined &&
(!Number.isSafeInteger(options.sampleRate) ||
options.sampleRate < 8_000 ||
options.sampleRate > 192_000)
) {
throw new RangeError(
'Normalized audio sample rate must be from 8000 to 192000 Hz'
);
}
const ranges = options.sources.map(selectedSourceRange);
for (const [index, source] of options.sources.entries()) {
if (
!Number.isFinite(source.durationSeconds) ||
source.durationSeconds <= 0
) {
throw new RangeError(`Clip ${index + 1} must have a positive duration`);
}
if (!source.streams.some((stream) => stream.kind === 'video')) {
throw new TypeError(
`Clip ${index + 1} has no video; synthetic video gaps are not implemented`
);
}
}
const hasAudio = options.sources.map((source) =>
source.streams.some((stream) => stream.kind === 'audio')
);
const mixedAudioPresence =
hasAudio.some(Boolean) && hasAudio.some((value) => !value);
if (mixedAudioPresence && options.missingAudioPolicy === 'reject') {
throw new CommandPlanError('Some clips have no audio stream', [
diagnostic(
'missing-audio',
'error',
'Choose insert silence or drop audio from every clip before concatenating.'
),
]);
}
const hasSubtitles = options.sources.some((source) =>
source.streams.some((stream) => stream.kind === 'subtitle')
);
if (hasSubtitles && options.subtitlePolicy !== 'drop-all') {
throw new CommandPlanError(
'Normalized concatenation cannot retain subtitle streams implicitly',
[
diagnostic(
'subtitle-policy-required',
'error',
'Choose the explicit drop-all subtitle policy, or remove subtitles before normalized concatenation.'
),
]
);
}
const includeAudio =
options.missingAudioPolicy !== 'drop-all' && hasAudio.some(Boolean);
const inputs = options.sources.map((source) =>
createPlannedInput(options.jobId, source)
);
const args: string[] = [];
inputs.forEach((input) => args.push('-i', input.path));
const graphParts: string[] = [];
options.sources.forEach((_, index) => {
const range = ranges[index];
if (!range) {
throw new TypeError(`Missing normalized concat range ${index + 1}`);
}
graphParts.push(
`[${index}:v:0]trim=start=${normalizeSeconds(range.startSeconds)}:end=${normalizeSeconds(range.endSeconds)},setpts=PTS-STARTPTS,scale=${options.width}:${options.height}:force_original_aspect_ratio=decrease,pad=${options.width}:${options.height}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=${options.frameRate},format=yuv420p[v${index}]`
);
if (includeAudio) {
if (hasAudio[index]) {
graphParts.push(
`[${index}:a:0]atrim=start=${normalizeSeconds(range.startSeconds)}:end=${normalizeSeconds(range.endSeconds)},asetpts=PTS-STARTPTS,aresample=${options.sampleRate ?? 48_000},aformat=sample_fmts=fltp:channel_layouts=${options.channelLayout ?? 'stereo'}[a${index}]`
);
} else {
graphParts.push(
`anullsrc=r=${options.sampleRate ?? 48_000}:cl=${options.channelLayout ?? 'stereo'},atrim=duration=${normalizeSeconds(range.durationSeconds)},aformat=sample_fmts=fltp:channel_layouts=${options.channelLayout ?? 'stereo'}[a${index}]`
);
}
}
});
const labels = options.sources
.map((_, index) => `[v${index}]${includeAudio ? `[a${index}]` : ''}`)
.join('');
graphParts.push(
`${labels}concat=n=${options.sources.length}:v=1:a=${includeAudio ? 1 : 0}[vout]${includeAudio ? '[aout]' : ''}`
);
args.push('-filter_complex', graphParts.join(';'), '-map', '[vout]');
if (includeAudio) {
args.push('-map', '[aout]');
}
args.push(...presetEncodingArguments(options.preset));
if (!includeAudio) {
args.push('-an');
}
args.push(
...metadataPolicyArguments(options.preset),
'-map_chapters',
'-1',
'-sn'
);
const output = createPlannedOutput(options.jobId, {
baseName: options.sources[0]?.fileName ?? 'sequence',
operation: 'concat',
extension: options.targetExtension,
});
args.push('-progress', 'pipe:1', '-nostats', output.path);
const presetRequirements =
'builtIn' in options.preset
? options.preset.requirements
: requirementsForUserPreset(options.preset);
return freezeCommandPlan({
id: `${options.jobId}:concat`,
operation: 'concat-normalized',
inputs,
temporaryFiles: [],
args,
outputs: [output],
expectedDurationSeconds: ranges.reduce(
(sum, range) => sum + range.durationSeconds,
0
),
requiredCapabilities: mergeRequirements(presetRequirements, {
muxers: [options.targetMuxer],
filters: [
'trim',
'setpts',
'scale',
'pad',
'setsar',
'fps',
'format',
'concat',
...(includeAudio ? ['atrim', 'asetpts', 'aresample', 'aformat'] : []),
...(mixedAudioPresence &&
options.missingAudioPolicy === 'insert-silence'
? ['anullsrc', 'atrim']
: []),
],
}),
diagnostics: [
diagnostic(
'normalized-concat',
'info',
'Clips are normalized to common video and audio properties, then re-encoded.'
),
...(mixedAudioPresence && options.missingAudioPolicy === 'insert-silence'
? [
diagnostic(
'inserted-silence',
'warning' as const,
'Silence is generated for clips without audio.'
),
]
: []),
...(hasSubtitles
? [
diagnostic(
'dropped-subtitles',
'warning' as const,
'Subtitle streams are explicitly omitted from every normalized concat input.'
),
]
: []),
],
});
}
function comparableKeys(
kind: ConcatStreamDescription['kind']
): readonly (keyof ConcatStreamDescription)[] {
if (kind === 'video') {
return ['kind', 'codec', 'width', 'height', 'pixelFormat', 'timeBase'];
}
if (kind === 'audio') {
return ['kind', 'codec', 'sampleRate', 'channelLayout', 'timeBase'];
}
return ['kind', 'codec', 'timeBase'];
}
function validateOutputGeometry(
width: number,
height: number,
frameRate: number
): void {
if (
!Number.isSafeInteger(width) ||
!Number.isSafeInteger(height) ||
width < 2 ||
height < 2 ||
width > 8192 ||
height > 8192 ||
width % 2 !== 0 ||
height % 2 !== 0
) {
throw new RangeError(
'Output dimensions must be even integer values from 2 to 8192'
);
}
if (!Number.isFinite(frameRate) || frameRate <= 0 || frameRate > 240) {
throw new RangeError('frameRate must be between 0 and 240');
}
}
function selectedSourceRange(source: ConcatSource): {
readonly startSeconds: number;
readonly endSeconds: number;
readonly durationSeconds: number;
} {
const startSeconds = source.sourceInSeconds ?? 0;
const endSeconds = source.sourceOutSeconds ?? source.durationSeconds;
validateRange(startSeconds, endSeconds, source.durationSeconds);
return Object.freeze({
startSeconds,
endSeconds,
durationSeconds: endSeconds - startSeconds,
});
}

242
src/commands/convert.ts Normal file
View File

@@ -0,0 +1,242 @@
import type {
BuiltInExportPreset,
UserExportPreset,
} from '../presets/preset.types';
import { presetEncodingArguments } from '../presets/preset-registry';
import {
assertMuxerMatchesExtension,
createPlannedInput,
createPlannedOutput,
diagnostic,
mergeRequirements,
streamMapArguments,
type SourceReference,
type StreamSelection,
} from './command-utils';
import {
freezeCommandPlan,
type FFmpegCommandPlan,
type PresetRequirements,
} from './command-plan';
export type ExportPreset = BuiltInExportPreset | UserExportPreset;
export interface ConvertOptions {
readonly jobId: string;
readonly source: SourceReference;
readonly preset: ExportPreset;
readonly streamSelection?: StreamSelection;
readonly expectedDurationSeconds?: number;
readonly timeoutMs?: number;
readonly operation?: string;
readonly videoFiltergraph?: string;
readonly audioFiltergraph?: string;
readonly additionalRequirements?: PresetRequirements;
}
export function buildConvertPlan(options: ConvertOptions): FFmpegCommandPlan {
assertMuxerMatchesExtension(
options.preset.container,
options.preset.fileExtension
);
const operation = options.operation ?? 'convert';
const input = createPlannedInput(options.jobId, options.source);
const output = createPlannedOutput(options.jobId, {
baseName: options.source.fileName,
operation,
extension: options.preset.fileExtension,
role: options.preset.kind === 'audio' ? 'audio' : 'media',
});
const args: string[] = ['-i', input.path];
const paletteGif = options.preset.video?.codec === 'gif';
if (options.streamSelection && !paletteGif) {
const mapArgs = streamMapArguments(options.streamSelection);
if (mapArgs.length === 0) {
throw new RangeError('At least one output stream must be selected');
}
args.push(...mapArgs);
}
const videoFilters = [
options.videoFiltergraph,
options.preset.video?.width || options.preset.video?.height
? `scale=${options.preset.video.width ?? -2}:${options.preset.video.height ?? -2}`
: undefined,
].filter((entry): entry is string => Boolean(entry));
if (paletteGif) {
const selection = options.streamSelection;
if (
(selection?.video?.length ?? 1) !== 1 ||
(selection?.audio?.length ?? 0) > 0 ||
(selection?.subtitles?.length ?? 0) > 0 ||
(selection?.attachments?.length ?? 0) > 0 ||
(selection?.data?.length ?? 0) > 0
) {
throw new RangeError(
'Animated GIF export requires exactly one selected video stream'
);
}
const videoIndex = selection?.video?.[0] ?? 0;
if (!Number.isSafeInteger(videoIndex) || videoIndex < 0) {
throw new RangeError('Invalid GIF video stream index');
}
const prefix = videoFilters.length > 0 ? `${videoFilters.join(',')},` : '';
args.push(
'-filter_complex',
`[0:${videoIndex}]${prefix}split[gif-base][gif-palette];[gif-palette]palettegen[gif-p];[gif-base][gif-p]paletteuse[gif-out]`,
'-map',
'[gif-out]'
);
} else if (videoFilters.length > 0) {
args.push('-vf', videoFilters.join(','));
}
if (options.audioFiltergraph) {
args.push('-af', options.audioFiltergraph);
}
args.push(...presetEncodingArguments(options.preset));
const subtitleHandling = subtitleArguments(options);
args.push(...subtitleHandling.args);
if ((options.streamSelection?.attachments?.length ?? 0) > 0) {
args.push('-c:t', 'copy');
}
if ((options.streamSelection?.data?.length ?? 0) > 0) {
args.push('-c:d', 'copy');
}
args.push(...metadataPolicyArguments(options.preset));
args.push('-progress', 'pipe:1', '-nostats', output.path);
const requirements = isBuiltIn(options.preset)
? options.preset.requirements
: requirementsForUserPreset(options.preset);
return freezeCommandPlan({
id: `${options.jobId}:${operation}`,
operation,
inputs: [input],
temporaryFiles: [],
args,
outputs: [output],
...(options.expectedDurationSeconds !== undefined
? { expectedDurationSeconds: options.expectedDurationSeconds }
: {}),
...(options.timeoutMs !== undefined
? { timeoutMs: options.timeoutMs }
: {}),
requiredCapabilities: mergeRequirements(
requirements,
options.additionalRequirements ?? {},
subtitleHandling.requirements,
options.preset.video?.width || options.preset.video?.height
? { filters: ['scale'] }
: {}
),
diagnostics: [
diagnostic(
'conversion-reencodes',
'info',
'Selected audio or video streams are re-encoded according to the preset.'
),
...(paletteGif
? [
diagnostic(
'gif-palette',
'info' as const,
'GIF export uses one palette-generation and palette-application filter graph.'
),
]
: []),
],
});
}
export function requirementsForUserPreset(
preset: UserExportPreset
): PresetRequirements {
const encoders = [
...(preset.video ? [preset.video.codec] : []),
...(preset.audio ? [preset.audio.codec] : []),
];
return {
muxers: [preset.container],
...(encoders.length > 0 ? { encoders } : {}),
...(preset.subtitlePolicy === 'burn-in' ? { filters: ['subtitles'] } : {}),
};
}
export function metadataPolicyArguments(
preset: Pick<
ExportPreset,
'metadataPolicy' | 'chapterPolicy' | 'subtitlePolicy'
>
): readonly string[] {
const args: string[] = [];
if (preset.metadataPolicy === 'remove') {
args.push('-map_metadata', '-1');
} else {
args.push('-map_metadata', '0');
}
args.push('-map_chapters', preset.chapterPolicy === 'remove' ? '-1' : '0');
if (preset.subtitlePolicy === 'none') {
args.push('-sn');
}
return Object.freeze(args);
}
function isBuiltIn(preset: ExportPreset): preset is BuiltInExportPreset {
return 'builtIn' in preset && preset.builtIn;
}
function subtitleArguments(options: ConvertOptions): {
readonly args: readonly string[];
readonly requirements: PresetRequirements;
} {
const count = options.streamSelection?.subtitles?.length ?? 0;
if (count === 0) {
return { args: [], requirements: {} };
}
if (
options.preset.subtitlePolicy === 'none' ||
options.preset.subtitlePolicy === 'burn-in'
) {
throw new RangeError(
`Preset subtitle policy ${options.preset.subtitlePolicy} cannot emit selected soft-subtitle streams`
);
}
return softSubtitlePolicyConfiguration(options.preset);
}
export function softSubtitlePolicyConfiguration(
preset: Pick<ExportPreset, 'container' | 'subtitlePolicy'>
): {
readonly args: readonly string[];
readonly requirements: PresetRequirements;
} {
if (preset.subtitlePolicy === 'none') {
return { args: [], requirements: {} };
}
if (preset.subtitlePolicy === 'burn-in') {
throw new TypeError(
'Burn-in requires an explicit local subtitle filter input'
);
}
if (preset.subtitlePolicy === 'copy-compatible') {
return { args: ['-c:s', 'copy'], requirements: {} };
}
const encoder =
preset.container === 'mp4' || preset.container === 'ipod'
? 'mov_text'
: preset.container === 'webm'
? 'webvtt'
: undefined;
if (!encoder && preset.container === 'matroska') {
return { args: ['-c:s', 'copy'], requirements: {} };
}
if (!encoder) {
throw new TypeError(
`No reviewed subtitle conversion policy exists for ${preset.container}`
);
}
return {
args: ['-c:s', encoder],
requirements: { encoders: [encoder] },
};
}

122
src/commands/fades.ts Normal file
View File

@@ -0,0 +1,122 @@
import type { ExportPreset } from './convert';
import { buildConvertPlan } from './convert';
import type { SourceReference } from './command-utils';
import type { FFmpegCommandPlan } from './command-plan';
import {
audioFadeFilters,
videoFadeFilters,
type FadeCurve,
type AudioFilterStage,
type FilterNode,
type VideoFilterStage,
} from './filtergraph-builder';
export {
audioFadeFilters,
videoFadeFilters,
type AudioFilterStage,
type FilterNode,
type VideoFilterStage,
};
export interface FadePlanOptions {
readonly jobId: string;
readonly source: SourceReference;
readonly preset: ExportPreset;
readonly durationSeconds: number;
readonly audioFadeInSeconds?: number;
readonly audioFadeOutSeconds?: number;
readonly videoFadeInSeconds?: number;
readonly videoFadeOutSeconds?: number;
readonly audioCurve?: FadeCurve;
readonly videoCurve?: FadeCurve;
}
export function buildFadePlan(options: FadePlanOptions): FFmpegCommandPlan {
if (
!Number.isFinite(options.durationSeconds) ||
options.durationSeconds <= 0
) {
throw new RangeError('Fade clip duration must be positive');
}
validatePair(
options.durationSeconds,
options.audioFadeInSeconds,
options.audioFadeOutSeconds,
'Audio'
);
validatePair(
options.durationSeconds,
options.videoFadeInSeconds,
options.videoFadeOutSeconds,
'Video'
);
const audioNodes = audioFadeFilters(
options.durationSeconds,
options.audioFadeInSeconds,
options.audioFadeOutSeconds,
options.audioCurve
);
const videoNodes = videoFadeFilters(
options.durationSeconds,
options.videoFadeInSeconds,
options.videoFadeOutSeconds,
options.videoCurve
);
if (audioNodes.length > 0 && !options.preset.audio) {
throw new TypeError('Audio fades require a preset with audio');
}
if (videoNodes.length > 0 && !options.preset.video) {
throw new TypeError('Video fades require a preset with video');
}
if (audioNodes.length === 0 && videoNodes.length === 0) {
throw new TypeError('At least one fade duration is required');
}
return buildConvertPlan({
jobId: options.jobId,
source: options.source,
preset: options.preset,
operation: 'fades',
expectedDurationSeconds: options.durationSeconds,
...(audioNodes.length > 0
? {
audioFiltergraph: audioNodes.map((node) => node.expression).join(','),
}
: {}),
...(videoNodes.length > 0
? {
videoFiltergraph: videoNodes.map((node) => node.expression).join(','),
}
: {}),
additionalRequirements: {
filters: [
...new Set(
[...audioNodes, ...videoNodes].flatMap(
(node) => node.requiredCapabilities?.filters ?? []
)
),
],
},
});
}
function validatePair(
durationSeconds: number,
fadeInSeconds: number | undefined,
fadeOutSeconds: number | undefined,
label: string
): void {
const fadeIn = fadeInSeconds ?? 0;
const fadeOut = fadeOutSeconds ?? 0;
if (
!Number.isFinite(fadeIn) ||
!Number.isFinite(fadeOut) ||
fadeIn < 0 ||
fadeOut < 0 ||
fadeIn + fadeOut > durationSeconds
) {
throw new RangeError(
`${label} fade durations must be nonnegative and cannot overlap`
);
}
}

View File

@@ -0,0 +1,224 @@
import {
escapeFilterValue,
mergeRequirements,
normalizeSeconds,
} from './command-utils';
import type { PresetRequirements } from './command-plan';
export type VideoFilterStage =
| 'trim'
| 'crop'
| 'rotate'
| 'resize'
| 'frame-rate'
| 'format'
| 'subtitles'
| 'fade';
export type AudioFilterStage =
'trim' | 'format' | 'gain' | 'normalization' | 'fade' | 'concat';
export const FADE_CURVES = ['tri', 'qsin', 'exp'] as const;
export type FadeCurve = (typeof FADE_CURVES)[number];
export interface FilterNode<TStage extends string> {
readonly id: string;
readonly stage: TStage;
readonly expression: string;
readonly requiredCapabilities?: PresetRequirements;
}
const VIDEO_ORDER: readonly VideoFilterStage[] = [
'trim',
'crop',
'rotate',
'resize',
'frame-rate',
'format',
'subtitles',
'fade',
];
const AUDIO_ORDER: readonly AudioFilterStage[] = [
'trim',
'format',
'gain',
'normalization',
'fade',
'concat',
];
export class FiltergraphBuilder<TStage extends string> {
readonly #order: readonly TStage[];
readonly #nodes: FilterNode<TStage>[] = [];
constructor(order: readonly TStage[]) {
this.#order = order;
}
add(node: FilterNode<TStage>): this {
if (!node.id.trim() || !node.expression.trim()) {
throw new TypeError('Filter node id and expression are required');
}
if (!this.#order.includes(node.stage)) {
throw new TypeError(`Unknown filter stage: ${node.stage}`);
}
if (this.#nodes.some((existing) => existing.id === node.id)) {
throw new TypeError(`Duplicate filter node id: ${node.id}`);
}
this.#nodes.push(Object.freeze({ ...node }));
return this;
}
build(): {
readonly graph?: string;
readonly requirements: PresetRequirements;
} {
const order = new Map(this.#order.map((stage, index) => [stage, index]));
const nodes = [...this.#nodes].sort(
(left, right) =>
(order.get(left.stage) ?? Number.MAX_SAFE_INTEGER) -
(order.get(right.stage) ?? Number.MAX_SAFE_INTEGER) ||
left.id.localeCompare(right.id)
);
const graph = nodes.map((node) => node.expression).join(',');
return {
...(graph ? { graph } : {}),
requirements: mergeRequirements(
...nodes.map((node) => node.requiredCapabilities ?? {})
),
};
}
}
export function createVideoFiltergraph(): FiltergraphBuilder<VideoFilterStage> {
return new FiltergraphBuilder(VIDEO_ORDER);
}
export function createAudioFiltergraph(): FiltergraphBuilder<AudioFilterStage> {
return new FiltergraphBuilder(AUDIO_ORDER);
}
export function subtitleFilter(path: string): FilterNode<VideoFilterStage> {
return {
id: 'subtitles',
stage: 'subtitles',
expression: `subtitles=filename='${escapeFilterValue(path)}'`,
requiredCapabilities: { filters: ['subtitles'] },
};
}
export function audioFadeFilters(
durationSeconds: number,
fadeInSeconds?: number,
fadeOutSeconds?: number,
curve: FadeCurve = 'tri'
): readonly FilterNode<AudioFilterStage>[] {
assertFadeCurve(curve);
const nodes: FilterNode<AudioFilterStage>[] = [];
if (fadeInSeconds !== undefined && fadeInSeconds > 0) {
if (fadeInSeconds > durationSeconds) {
throw new RangeError('Audio fade-in exceeds clip duration');
}
nodes.push({
id: 'audio-fade-in',
stage: 'fade',
expression: `afade=t=in:st=0:d=${normalizeSeconds(fadeInSeconds)}:curve=${curve}`,
requiredCapabilities: { filters: ['afade'] },
});
}
if (fadeOutSeconds !== undefined && fadeOutSeconds > 0) {
if (fadeOutSeconds > durationSeconds) {
throw new RangeError('Audio fade-out exceeds clip duration');
}
nodes.push({
id: 'audio-fade-out',
stage: 'fade',
expression: `afade=t=out:st=${normalizeSeconds(
durationSeconds - fadeOutSeconds
)}:d=${normalizeSeconds(fadeOutSeconds)}:curve=${curve}`,
requiredCapabilities: { filters: ['afade'] },
});
}
return Object.freeze(nodes);
}
export function videoFadeFilters(
durationSeconds: number,
fadeInSeconds?: number,
fadeOutSeconds?: number,
curve: FadeCurve = 'tri'
): readonly FilterNode<VideoFilterStage>[] {
assertFadeCurve(curve);
const nodes: FilterNode<VideoFilterStage>[] = [];
if (fadeInSeconds !== undefined && fadeInSeconds > 0) {
if (fadeInSeconds > durationSeconds) {
throw new RangeError('Video fade-in exceeds clip duration');
}
nodes.push({
id: 'video-fade-in',
stage: 'fade',
expression:
curve === 'tri'
? `fade=t=in:st=0:d=${normalizeSeconds(fadeInSeconds)}:color=black`
: curvedVideoFadeExpression(
durationSeconds,
fadeInSeconds,
'in',
curve
),
requiredCapabilities: { filters: [curve === 'tri' ? 'fade' : 'geq'] },
});
}
if (fadeOutSeconds !== undefined && fadeOutSeconds > 0) {
if (fadeOutSeconds > durationSeconds) {
throw new RangeError('Video fade-out exceeds clip duration');
}
nodes.push({
id: 'video-fade-out',
stage: 'fade',
expression:
curve === 'tri'
? `fade=t=out:st=${normalizeSeconds(
durationSeconds - fadeOutSeconds
)}:d=${normalizeSeconds(fadeOutSeconds)}:color=black`
: curvedVideoFadeExpression(
durationSeconds,
fadeOutSeconds,
'out',
curve
),
requiredCapabilities: { filters: [curve === 'tri' ? 'fade' : 'geq'] },
});
}
return Object.freeze(nodes);
}
function assertFadeCurve(value: unknown): asserts value is FadeCurve {
if (!FADE_CURVES.includes(value as FadeCurve)) {
throw new TypeError('Fade curve must be tri, qsin, or exp');
}
}
function curvedVideoFadeExpression(
durationSeconds: number,
fadeSeconds: number,
direction: 'in' | 'out',
curve: Exclude<FadeCurve, 'tri'>
): string {
const duration = normalizeSeconds(durationSeconds);
const fade = normalizeSeconds(fadeSeconds);
const progress =
direction === 'in'
? `clip(T/${fade},0,1)`
: `clip((${duration}-T)/${fade},0,1)`;
const gain =
curve === 'qsin'
? `sin(PI/2*${progress})`
: `((exp(5*${progress})-1)/(exp(5)-1))`;
return [
`geq=lum_expr='lum(X,Y)*${gain}'`,
`cb_expr='128+(cb(X,Y)-128)*${gain}'`,
`cr_expr='128+(cr(X,Y)-128)*${gain}'`,
].join(':');
}

20
src/commands/index.ts Normal file
View File

@@ -0,0 +1,20 @@
export * from './chapters';
export * from './command-plan';
export * from './command-utils';
export * from './concatenate';
export * from './convert';
export * from './fades';
export * from './filtergraph-builder';
export * from './loudness';
export * from './metadata';
export * from './preview-proxy';
export * from './probe';
export * from './remux';
export * from './split';
export * from './streams';
export * from './subtitles';
export * from './thumbnails';
export * from './timeline-export';
export * from './transform';
export * from './trim';
export * from './waveform';

457
src/commands/loudness.ts Normal file
View File

@@ -0,0 +1,457 @@
import type { ExportPreset } from './convert';
import { buildConvertPlan } from './convert';
import {
createPlannedInput,
diagnostic,
normalizeSeconds,
type SourceReference,
} from './command-utils';
import { freezeCommandPlan, type FFmpegCommandPlan } from './command-plan';
export interface LoudnessMeasurement {
readonly inputIntegratedLufs: number;
readonly inputTruePeakDbtp: number;
readonly inputLoudnessRangeLu: number;
readonly inputThresholdLufs: number;
readonly targetOffsetLu?: number;
}
export interface LoudnessTargets {
readonly integratedLufs: number;
readonly truePeakDbtp: number;
readonly loudnessRangeLu: number;
}
export interface LoudnessAnalysisOptions {
readonly jobId: string;
readonly source: SourceReference;
readonly targets: LoudnessTargets;
/** Absolute ffprobe stream index. */
readonly audioStreamIndex: number;
readonly expectedDurationSeconds?: number;
}
export interface LoudnessApplyOptions extends LoudnessAnalysisOptions {
readonly measurement: LoudnessMeasurement;
readonly preset: ExportPreset;
}
export interface PeakNormalizationOptions {
readonly jobId: string;
readonly source: SourceReference;
/** Absolute ffprobe stream index. */
readonly audioStreamIndex: number;
readonly targetPeakDb: number;
readonly measurement: PeakMeasurement;
readonly preset: ExportPreset;
readonly expectedDurationSeconds?: number;
}
export interface PeakMeasurement {
readonly inputPeakDbfs: number;
}
export interface PeakAnalysisOptions {
readonly jobId: string;
readonly source: SourceReference;
/** Absolute ffprobe stream index. */
readonly audioStreamIndex: number;
readonly preset: ExportPreset;
readonly expectedDurationSeconds?: number;
}
export const LOUDNESS_PROFILES: Readonly<
Record<'web-video' | 'podcast' | 'broadcast-style', LoudnessTargets>
> = Object.freeze({
'web-video': Object.freeze({
integratedLufs: -14,
truePeakDbtp: -1,
loudnessRangeLu: 11,
}),
podcast: Object.freeze({
integratedLufs: -16,
truePeakDbtp: -1,
loudnessRangeLu: 11,
}),
'broadcast-style': Object.freeze({
integratedLufs: -23,
truePeakDbtp: -2,
loudnessRangeLu: 7,
}),
});
export function buildLoudnessAnalysisPlan(
options: LoudnessAnalysisOptions
): FFmpegCommandPlan {
validateLoudnessTargets(options.targets);
const streamIndex = options.audioStreamIndex;
if (!Number.isSafeInteger(streamIndex) || streamIndex < 0) {
throw new RangeError('Invalid audio stream index');
}
const input = createPlannedInput(options.jobId, options.source);
const filter = loudnormFilter(options.targets, undefined, true);
return freezeCommandPlan({
id: `${options.jobId}:loudness-analysis`,
operation: 'loudness-analysis',
inputs: [input],
temporaryFiles: [],
args: [
'-i',
input.path,
'-map',
`0:${streamIndex}`,
'-af',
filter,
'-f',
'null',
'-',
],
outputs: [],
...(options.expectedDurationSeconds !== undefined
? { expectedDurationSeconds: options.expectedDurationSeconds }
: {}),
requiredCapabilities: { filters: ['loudnorm'], muxers: ['null'] },
diagnostics: [
diagnostic(
'loudnorm-first-pass',
'info',
'First pass measures loudness only and preserves its logs for diagnostics.'
),
],
});
}
export function buildLoudnessApplyPlan(
options: LoudnessApplyOptions
): FFmpegCommandPlan {
validateLoudnessTargets(options.targets);
validateLoudnessMeasurement(options.measurement);
if (!options.preset.audio || options.preset.video) {
throw new TypeError(
'Measured loudness normalization requires an audio-only export preset'
);
}
return buildConvertPlan({
jobId: options.jobId,
source: options.source,
preset: options.preset,
streamSelection: { audio: [options.audioStreamIndex] },
expectedDurationSeconds: options.expectedDurationSeconds,
operation: 'normalize',
audioFiltergraph: loudnormFilter(
options.targets,
options.measurement,
false
),
additionalRequirements: { filters: ['loudnorm'] },
});
}
export function buildPeakAnalysisPlan(
options: PeakAnalysisOptions
): FFmpegCommandPlan {
if (
!Number.isSafeInteger(options.audioStreamIndex) ||
options.audioStreamIndex < 0
) {
throw new RangeError('Invalid audio stream index');
}
validateAudioOnlyPreset(options.preset, 'Peak analysis');
const input = createPlannedInput(options.jobId, options.source);
const formatFilters = audioPresetFormatFilters(options.preset);
return freezeCommandPlan({
id: `${options.jobId}:peak-analysis`,
operation: 'peak-analysis',
inputs: [input],
temporaryFiles: [],
args: [
'-i',
input.path,
'-map',
`0:${options.audioStreamIndex}`,
'-af',
[...formatFilters, 'volumedetect'].join(','),
'-f',
'null',
'-',
],
outputs: [],
...(options.expectedDurationSeconds !== undefined
? { expectedDurationSeconds: options.expectedDurationSeconds }
: {}),
requiredCapabilities: {
filters: [
...formatFilters.map((filter) => filter.split('=')[0] as string),
'volumedetect',
],
muxers: ['null'],
},
diagnostics: [
diagnostic(
'peak-first-pass',
'info',
'A complete local first pass measures the selected stream peak before a constant gain is calculated.'
),
],
});
}
export function parsePeakMeasurement(logText: string): PeakMeasurement {
const matches = [
...logText.matchAll(
/max_volume:\s*(-?(?:\d+(?:\.\d+)?|\.\d+|inf))\s*dB/giu
),
];
const raw = matches.at(-1)?.[1];
if (!raw || raw.toLowerCase().includes('inf')) {
throw new TypeError(
'No finite max_volume measurement was found; silent audio cannot be peak-normalized'
);
}
const measurement = Object.freeze({ inputPeakDbfs: Number(raw) });
validatePeakMeasurement(measurement);
return measurement;
}
export function validatePeakMeasurement(measurement: PeakMeasurement): void {
if (
!Number.isFinite(measurement.inputPeakDbfs) ||
measurement.inputPeakDbfs < -200 ||
measurement.inputPeakDbfs > 100
) {
throw new TypeError(
'Peak measurement is incomplete or outside safe bounds'
);
}
}
export function buildPeakNormalizationFilter(
targetPeakDb: number,
measurement: PeakMeasurement
): string {
validatePeakMeasurement(measurement);
const inputPeakDbfs = measurement.inputPeakDbfs;
if (
!Number.isFinite(targetPeakDb) ||
targetPeakDb > 0 ||
targetPeakDb < -20
) {
throw new RangeError('Peak target must be between -20 and 0 dB');
}
if (!Number.isFinite(inputPeakDbfs)) {
throw new TypeError('Measured input peak must be finite');
}
const gainDb = targetPeakDb - inputPeakDbfs;
if (Math.abs(gainDb) > 60) {
throw new RangeError(
'Peak normalization would require more than 60 dB of gain change'
);
}
const linear = 10 ** (gainDb / 20);
return `volume=${linear.toFixed(8).replace(/0+$/u, '').replace(/\.$/u, '')}`;
}
export function buildPeakNormalizationPlan(
options: PeakNormalizationOptions
): FFmpegCommandPlan {
validateAudioOnlyPreset(options.preset, 'Peak normalization');
if (
!Number.isSafeInteger(options.audioStreamIndex) ||
options.audioStreamIndex < 0
) {
throw new RangeError('Invalid audio stream index');
}
validatePeakMeasurement(options.measurement);
const filter = buildPeakNormalizationFilter(
options.targetPeakDb,
options.measurement
);
const base = buildConvertPlan({
jobId: options.jobId,
source: options.source,
preset: options.preset,
streamSelection: { audio: [options.audioStreamIndex] },
operation: 'peak-normalize',
audioFiltergraph: [
...audioPresetFormatFilters(options.preset),
filter,
].join(','),
additionalRequirements: {
filters: [
...audioPresetFormatFilters(options.preset).map(
(entry) => entry.split('=')[0] as string
),
'volume',
],
},
...(options.expectedDurationSeconds !== undefined
? { expectedDurationSeconds: options.expectedDurationSeconds }
: {}),
});
return freezeCommandPlan({
...base,
diagnostics: [
diagnostic(
'measured-peak-gain',
'info',
`Measured ${normalizeSigned(options.measurement.inputPeakDbfs)} dBFS; applying ${normalizeSigned(options.targetPeakDb - options.measurement.inputPeakDbfs)} dB constant gain to target ${normalizeSigned(options.targetPeakDb)} dBFS.`
),
...base.diagnostics,
],
});
}
function validateAudioOnlyPreset(
preset: ExportPreset,
operation: string
): void {
if (!preset.audio || preset.video) {
throw new TypeError(`${operation} requires an audio-only export preset`);
}
}
function audioPresetFormatFilters(preset: ExportPreset): readonly string[] {
if (!preset.audio) {
return [];
}
const filters: string[] = [];
if (preset.audio.sampleRate !== undefined) {
filters.push(`aresample=${preset.audio.sampleRate}`);
}
if (preset.audio.channels !== undefined) {
const layout =
preset.audio.channels === 1
? 'mono'
: preset.audio.channels === 2
? 'stereo'
: '5.1';
filters.push(`aformat=channel_layouts=${layout}`);
}
return filters;
}
export function parseLoudnessMeasurement(logText: string): LoudnessMeasurement {
const blocks = logText.match(/\{[\s\S]*?\}/gu) ?? [];
for (const block of blocks.reverse()) {
let candidate: unknown;
try {
candidate = JSON.parse(block) as unknown;
} catch {
continue;
}
if (!isRecord(candidate) || !('input_i' in candidate)) {
continue;
}
const measurement: LoudnessMeasurement = {
inputIntegratedLufs: parseMeasurement(candidate.input_i, 'input_i'),
inputTruePeakDbtp: parseMeasurement(candidate.input_tp, 'input_tp'),
inputLoudnessRangeLu: parseMeasurement(candidate.input_lra, 'input_lra'),
inputThresholdLufs: parseMeasurement(
candidate.input_thresh,
'input_thresh'
),
...('target_offset' in candidate
? {
targetOffsetLu: parseMeasurement(
candidate.target_offset,
'target_offset'
),
}
: {}),
};
validateLoudnessMeasurement(measurement);
return Object.freeze(measurement);
}
throw new TypeError('No complete loudnorm measurement JSON was found');
}
export function validateLoudnessMeasurement(
measurement: LoudnessMeasurement
): void {
const values = [
measurement.inputIntegratedLufs,
measurement.inputTruePeakDbtp,
measurement.inputLoudnessRangeLu,
measurement.inputThresholdLufs,
...(measurement.targetOffsetLu === undefined
? []
: [measurement.targetOffsetLu]),
];
if (values.some((value) => !Number.isFinite(value))) {
throw new TypeError('Loudness measurement is incomplete or non-finite');
}
}
function loudnormFilter(
targets: LoudnessTargets,
measurement?: LoudnessMeasurement,
printJson = false
): string {
const values = [
`I=${normalizeSigned(targets.integratedLufs)}`,
`TP=${normalizeSigned(targets.truePeakDbtp)}`,
`LRA=${normalizeSeconds(targets.loudnessRangeLu)}`,
];
if (measurement) {
values.push(
`measured_I=${normalizeSigned(measurement.inputIntegratedLufs)}`,
`measured_TP=${normalizeSigned(measurement.inputTruePeakDbtp)}`,
`measured_LRA=${normalizeSeconds(measurement.inputLoudnessRangeLu)}`,
`measured_thresh=${normalizeSigned(measurement.inputThresholdLufs)}`,
`offset=${normalizeSigned(measurement.targetOffsetLu ?? 0)}`,
'linear=true'
);
}
if (printJson) {
values.push('print_format=json');
}
return `loudnorm=${values.join(':')}`;
}
function validateLoudnessTargets(targets: LoudnessTargets): void {
if (
!Number.isFinite(targets.integratedLufs) ||
targets.integratedLufs < -70 ||
targets.integratedLufs > -5
) {
throw new RangeError(
'Integrated loudness target must be between -70 and -5 LUFS'
);
}
if (
!Number.isFinite(targets.truePeakDbtp) ||
targets.truePeakDbtp < -9 ||
targets.truePeakDbtp > 0
) {
throw new RangeError('True peak target must be between -9 and 0 dBTP');
}
if (
!Number.isFinite(targets.loudnessRangeLu) ||
targets.loudnessRangeLu < 1 ||
targets.loudnessRangeLu > 50
) {
throw new RangeError('Loudness range target must be between 1 and 50 LU');
}
}
function normalizeSigned(value: number): string {
if (!Number.isFinite(value)) {
throw new RangeError('Value must be finite');
}
return value.toFixed(6).replace(/0+$/u, '').replace(/\.$/u, '');
}
function parseMeasurement(value: unknown, label: string): number {
if (typeof value !== 'string' && typeof value !== 'number') {
throw new TypeError(`${label} is missing`);
}
const parsed = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(parsed)) {
throw new TypeError(`${label} is not finite`);
}
return parsed;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

321
src/commands/metadata.ts Normal file
View File

@@ -0,0 +1,321 @@
import {
assertMuxerMatchesExtension,
createPlannedInput,
createPlannedOutput,
diagnostic,
escapeMetadataValue,
streamMapArguments,
type SourceReference,
type StreamSelection,
} from './command-utils';
import { freezeCommandPlan, type FFmpegCommandPlan } from './command-plan';
export type MetadataValue = string | number;
export const EDITABLE_STREAM_DISPOSITIONS = [
'default',
'dub',
'original',
'comment',
'lyrics',
'karaoke',
'forced',
'hearing_impaired',
'visual_impaired',
'clean_effects',
'attached_pic',
'timed_thumbnails',
'captions',
'descriptions',
'metadata',
'dependent',
'still_image',
] as const;
export type StreamDisposition = (typeof EDITABLE_STREAM_DISPOSITIONS)[number];
export interface MetadataEdits {
readonly format?: Readonly<Record<string, MetadataValue | null>>;
readonly streams?: Readonly<
Record<number, Readonly<Record<string, MetadataValue | null>>>
>;
readonly dispositions?: Readonly<
Record<number, readonly StreamDisposition[]>
>;
}
export interface MetadataEditPlanOptions {
readonly jobId: string;
readonly source: SourceReference;
readonly edits: MetadataEdits;
readonly targetExtension: string;
readonly targetMuxer: string;
readonly streamSelection?: StreamSelection;
readonly sourceMetadataPolicy?: 'copy' | 'remove';
readonly chapterPolicy?: 'keep' | 'remove';
readonly expectedDurationSeconds?: number;
readonly outputFileName?: string;
}
export function metadataArguments(edits: MetadataEdits): readonly string[] {
const args: string[] = [];
for (const [key, value] of sortedEntries(edits.format ?? {})) {
validateMetadataKey(key);
args.push(
'-metadata',
`${key}=${value === null ? '' : escapeMetadataValue(String(value))}`
);
}
const streamEntries = Object.entries(edits.streams ?? {}).sort(
([left], [right]) => Number(left) - Number(right)
);
for (const [indexText, tags] of streamEntries) {
const index = parseStreamIndex(indexText);
for (const [key, value] of sortedEntries(tags)) {
validateMetadataKey(key);
args.push(
`-metadata:s:${index}`,
`${key}=${value === null ? '' : escapeMetadataValue(String(value))}`
);
}
}
const dispositionEntries = Object.entries(edits.dispositions ?? {}).sort(
([left], [right]) => Number(left) - Number(right)
);
for (const [indexText, dispositions] of dispositionEntries) {
const index = parseStreamIndex(indexText);
const unique = [...new Set(dispositions)];
if (unique.length !== dispositions.length) {
throw new TypeError(
`Stream ${index} dispositions must not contain duplicates`
);
}
for (const disposition of unique) {
if (
!EDITABLE_STREAM_DISPOSITIONS.includes(disposition as StreamDisposition)
) {
throw new TypeError(
`Unsupported stream disposition: ${String(disposition)}`
);
}
}
const ordered = EDITABLE_STREAM_DISPOSITIONS.filter((disposition) =>
unique.includes(disposition)
);
args.push(
`-disposition:${index}`,
ordered.length > 0 ? ordered.join('+') : '0'
);
}
return Object.freeze(args);
}
export function buildMetadataEditPlan(
options: MetadataEditPlanOptions
): FFmpegCommandPlan {
assertMuxerMatchesExtension(options.targetMuxer, options.targetExtension);
const edits = metadataArguments(options.edits);
const input = createPlannedInput(options.jobId, options.source);
const output = createPlannedOutput(options.jobId, {
baseName: options.source.fileName,
operation: 'metadata',
extension: options.targetExtension,
...(options.outputFileName ? { fileName: options.outputFileName } : {}),
});
const args: string[] = ['-i', input.path];
if (options.streamSelection) {
const maps = streamMapArguments(options.streamSelection);
if (maps.length === 0) {
throw new RangeError('At least one output stream must remain');
}
args.push(...maps);
} else {
args.push('-map', '0');
}
args.push(
'-c',
'copy',
'-map_metadata',
options.sourceMetadataPolicy === 'remove' ? '-1' : '0',
'-map_chapters',
options.chapterPolicy === 'remove' ? '-1' : '0',
...edits,
output.path
);
return freezeCommandPlan({
id: `${options.jobId}:metadata`,
operation: 'metadata',
inputs: [input],
temporaryFiles: [],
args,
outputs: [output],
...(options.expectedDurationSeconds !== undefined
? { expectedDurationSeconds: options.expectedDurationSeconds }
: {}),
requiredCapabilities: { muxers: [options.targetMuxer] },
diagnostics: [
diagnostic(
'metadata-round-trip',
'info',
'Re-probe the result to report which container and stream tags survived muxing.'
),
...(options.sourceMetadataPolicy === 'remove'
? [
diagnostic(
'metadata-removal-scope',
'warning' as const,
'Common removable tags are not copied, but container-specific structural metadata may remain.'
),
]
: []),
],
});
}
export function serializeMetadataJson(edits: MetadataEdits): string {
metadataArguments(edits);
return `${JSON.stringify(edits, null, 2)}\n`;
}
export function parseMetadataJson(json: string): MetadataEdits {
let value: unknown;
try {
value = JSON.parse(json) as unknown;
} catch {
throw new TypeError('Metadata JSON is malformed');
}
if (!isRecord(value)) {
throw new TypeError('Metadata JSON must be an object');
}
const unknown = Object.keys(value).filter(
(key) => key !== 'format' && key !== 'streams' && key !== 'dispositions'
);
if (unknown.length > 0) {
throw new TypeError(`Unsupported metadata field: ${unknown[0]}`);
}
const format = parseTagRecord(value.format, 'format');
const streams: Record<
number,
Readonly<Record<string, MetadataValue | null>>
> = {};
if (value.streams !== undefined) {
if (!isRecord(value.streams)) {
throw new TypeError('streams must be an object');
}
for (const [indexText, tags] of Object.entries(value.streams)) {
if (!/^(?:0|[1-9][0-9]*)$/u.test(indexText)) {
throw new TypeError(`Invalid stream index: ${indexText}`);
}
const parsedTags = parseTagRecord(tags, `streams.${indexText}`);
if (!parsedTags) {
throw new TypeError(`streams.${indexText} must be an object`);
}
streams[Number(indexText)] = parsedTags;
}
}
const dispositions: Record<number, readonly StreamDisposition[]> = {};
if (value.dispositions !== undefined) {
if (!isRecord(value.dispositions)) {
throw new TypeError('dispositions must be an object');
}
for (const [indexText, entry] of Object.entries(value.dispositions)) {
const index = parseStreamIndex(indexText);
if (
!Array.isArray(entry) ||
!entry.every((disposition) => typeof disposition === 'string')
) {
throw new TypeError(
`dispositions.${indexText} must be an array of disposition names`
);
}
const parsed = entry as string[];
if (
parsed.some(
(disposition) =>
!EDITABLE_STREAM_DISPOSITIONS.includes(
disposition as StreamDisposition
)
)
) {
throw new TypeError(
`dispositions.${indexText} contains an unsupported disposition`
);
}
if (new Set(parsed).size !== parsed.length) {
throw new TypeError(
`dispositions.${indexText} must not contain duplicates`
);
}
dispositions[index] = Object.freeze(
EDITABLE_STREAM_DISPOSITIONS.filter((disposition) =>
parsed.includes(disposition)
)
);
}
}
return Object.freeze({
...(format ? { format: Object.freeze(format) } : {}),
...(Object.keys(streams).length > 0
? { streams: Object.freeze(streams) }
: {}),
...(Object.keys(dispositions).length > 0
? { dispositions: Object.freeze(dispositions) }
: {}),
});
}
function parseStreamIndex(indexText: string): number {
if (!/^(?:0|[1-9][0-9]*)$/u.test(indexText)) {
throw new TypeError(`Invalid stream index: ${indexText}`);
}
const index = Number(indexText);
if (!Number.isSafeInteger(index)) {
throw new TypeError(`Invalid stream index: ${indexText}`);
}
return index;
}
function parseTagRecord(
value: unknown,
label: string
): Readonly<Record<string, MetadataValue | null>> | undefined {
if (value === undefined) {
return undefined;
}
if (!isRecord(value)) {
throw new TypeError(`${label} must be an object`);
}
const result: Record<string, MetadataValue | null> = {};
for (const [key, entry] of Object.entries(value)) {
validateMetadataKey(key);
if (
entry !== null &&
typeof entry !== 'string' &&
(typeof entry !== 'number' || !Number.isFinite(entry))
) {
throw new TypeError(
`${label}.${key} must be a string, finite number or null`
);
}
result[key] = entry as MetadataValue | null;
}
return result;
}
function validateMetadataKey(key: string): void {
if (!/^[a-zA-Z0-9_.-]{1,64}$/u.test(key)) {
throw new TypeError(`Invalid metadata key: ${key}`);
}
}
function sortedEntries<T>(
value: Readonly<Record<string, T>>
): readonly (readonly [string, T])[] {
return Object.entries(value).sort(([left], [right]) =>
left.localeCompare(right)
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

View File

@@ -0,0 +1,145 @@
import {
createPlannedInput,
createPlannedOutput,
diagnostic,
normalizeSeconds,
type SourceReference,
} from './command-utils';
import { freezeCommandPlan, type FFmpegCommandPlan } from './command-plan';
export interface PreviewProxyOptions {
readonly jobId: string;
readonly source: SourceReference;
readonly sourceDurationSeconds: number;
readonly startSeconds?: number;
readonly maxDurationSeconds?: number;
readonly maxWidth?: number;
readonly includeAudio?: boolean;
/**
* Defaults to true for backwards compatibility. Set false for an
* audio-only source; the proxy then becomes a bounded AAC/M4A derivative.
*/
readonly hasVideo?: boolean;
}
/**
* Builds a deliberately bounded H.264/AAC MP4 or audio-only AAC/M4A preview
* derivative. The proxy is disposable cache data and never replaces or
* mutates the source.
*/
export function buildPreviewProxyPlan(
options: PreviewProxyOptions
): FFmpegCommandPlan {
if (
!Number.isFinite(options.sourceDurationSeconds) ||
options.sourceDurationSeconds <= 0
) {
throw new RangeError('Preview source duration must be positive');
}
const startSeconds = options.startSeconds ?? 0;
const maxDurationSeconds = options.maxDurationSeconds ?? 30;
const maxWidth = options.maxWidth ?? 960;
const hasVideo = options.hasVideo !== false;
const includeAudio = options.includeAudio !== false;
if (
!Number.isFinite(startSeconds) ||
startSeconds < 0 ||
startSeconds >= options.sourceDurationSeconds
) {
throw new RangeError('Preview start must be inside the source duration');
}
if (
!Number.isFinite(maxDurationSeconds) ||
maxDurationSeconds <= 0 ||
maxDurationSeconds > 300
) {
throw new RangeError('Preview duration must be from 0 to 300 seconds');
}
if (
hasVideo &&
(!Number.isSafeInteger(maxWidth) || maxWidth < 160 || maxWidth > 1920)
) {
throw new RangeError(
'Preview maximum width must be an integer from 160 to 1920'
);
}
if (!hasVideo && !includeAudio) {
throw new RangeError(
'An audio-only preview proxy must include an audio stream'
);
}
const durationSeconds = Math.min(
maxDurationSeconds,
options.sourceDurationSeconds - startSeconds
);
const input = createPlannedInput(options.jobId, options.source);
const output = createPlannedOutput(options.jobId, {
baseName: options.source.fileName,
operation: 'preview',
extension: hasVideo ? 'mp4' : 'm4a',
});
const args = [
'-ss',
normalizeSeconds(startSeconds),
'-t',
normalizeSeconds(durationSeconds),
'-i',
input.path,
...(hasVideo
? [
'-map',
'0:v:0',
...(includeAudio ? ['-map', '0:a:0?'] : []),
'-vf',
`scale=w='max(2,trunc(min(${maxWidth},iw)/2)*2)':h=-2`,
'-c:v',
'libx264',
'-preset',
'veryfast',
'-crf',
'28',
'-pix_fmt',
'yuv420p',
]
: ['-map', '0:a:0', '-vn']),
...(includeAudio
? ['-c:a', 'aac', '-b:a', '96k', '-ac', '2', '-ar', '48000']
: ['-an']),
'-movflags',
'+faststart',
'-map_metadata',
'-1',
'-map_chapters',
'-1',
'-progress',
'pipe:1',
'-nostats',
output.path,
];
return freezeCommandPlan({
id: `${options.jobId}:preview-proxy`,
operation: 'preview-proxy',
inputs: [input],
temporaryFiles: [],
args,
outputs: [output],
expectedDurationSeconds: durationSeconds,
requiredCapabilities: {
muxers: [hasVideo ? 'mp4' : 'ipod'],
encoders: [
...(hasVideo ? ['libx264'] : []),
...(includeAudio ? ['aac'] : []),
],
filters: hasVideo ? ['scale'] : [],
},
diagnostics: [
diagnostic(
'disposable-preview',
'info',
hasVideo
? `Preview is limited to ${normalizeSeconds(durationSeconds)} seconds and ${maxWidth}px width.`
: `Audio preview is limited to ${normalizeSeconds(durationSeconds)} seconds at 96 kbit/s AAC.`
),
],
});
}

15
src/commands/probe.ts Normal file
View File

@@ -0,0 +1,15 @@
export function ffprobeArguments(inputPath: string): readonly string[] {
if (!inputPath.startsWith('/') || inputPath.includes('\0')) {
throw new TypeError('Probe input must be a safe absolute virtual path');
}
return Object.freeze([
'-v',
'error',
'-show_format',
'-show_streams',
'-show_chapters',
'-print_format',
'json',
inputPath,
]);
}

198
src/commands/remux.ts Normal file
View File

@@ -0,0 +1,198 @@
import {
assertMuxerMatchesExtension,
createPlannedInput,
createPlannedOutput,
diagnostic,
streamMapArguments,
type SourceReference,
type StreamSelection,
} from './command-utils';
import {
CommandPlanError,
freezeCommandPlan,
type CommandDiagnostic,
type FFmpegCommandPlan,
} from './command-plan';
export type StreamKind = 'video' | 'audio' | 'subtitle' | 'attachment' | 'data';
export interface RemuxStream {
readonly index: number;
readonly kind: StreamKind;
readonly codec: string;
}
export interface RemuxInputDescription {
readonly streams: readonly RemuxStream[];
readonly hasChapters?: boolean;
readonly hasMetadata?: boolean;
}
export interface RemuxOptions {
readonly jobId: string;
readonly source: SourceReference;
readonly targetContainer: 'mp4' | 'webm' | 'matroska' | 'ogg' | 'ipod';
readonly fileExtension: string;
readonly description: RemuxInputDescription;
readonly streamSelection?: StreamSelection;
readonly metadataPolicy?: 'copy' | 'remove';
readonly chapterPolicy?: 'keep' | 'remove';
readonly expectedDurationSeconds?: number;
}
const CONTAINER_CODECS: Readonly<
Record<string, Readonly<Partial<Record<StreamKind, ReadonlySet<string>>>>>
> = {
mp4: {
video: new Set(['h264', 'hevc', 'av1', 'mpeg4', 'mjpeg']),
audio: new Set(['aac', 'mp3', 'alac', 'ac3', 'eac3']),
subtitle: new Set(['mov_text']),
attachment: new Set(),
data: new Set(['bin_data']),
},
webm: {
video: new Set(['vp8', 'vp9', 'av1']),
audio: new Set(['opus', 'vorbis']),
subtitle: new Set(['webvtt']),
attachment: new Set(),
data: new Set(),
},
matroska: {},
ogg: {
video: new Set(['theora']),
audio: new Set(['vorbis', 'opus', 'flac']),
subtitle: new Set(),
attachment: new Set(),
data: new Set(),
},
ipod: {
video: new Set(),
audio: new Set(['aac', 'alac', 'mp3']),
subtitle: new Set(),
attachment: new Set(),
data: new Set(),
},
};
export function validateRemuxCompatibility(
description: RemuxInputDescription,
container: RemuxOptions['targetContainer'],
selection?: StreamSelection
): readonly CommandDiagnostic[] {
const diagnostics: CommandDiagnostic[] = [];
const rules = CONTAINER_CODECS[container];
const selectedStreams = description.streams.filter((stream) =>
isSelected(stream, selection)
);
if (selectedStreams.length === 0) {
diagnostics.push(
diagnostic(
'no-streams',
'error',
'At least one media stream must be selected.'
)
);
}
for (const stream of selectedStreams) {
const allowed = rules?.[stream.kind];
if (allowed && !allowed.has(stream.codec)) {
diagnostics.push(
diagnostic(
'incompatible-codec',
'error',
`${stream.kind} codec ${stream.codec} is not stream-copy compatible with ${container}.`,
`stream.${stream.index}`
)
);
}
}
if (
description.hasChapters &&
(container === 'webm' || container === 'ogg')
) {
diagnostics.push(
diagnostic(
'chapter-support',
'warning',
`Chapter preservation should be verified after remuxing to ${container}.`
)
);
}
return Object.freeze(diagnostics);
}
export function buildRemuxPlan(options: RemuxOptions): FFmpegCommandPlan {
assertMuxerMatchesExtension(options.targetContainer, options.fileExtension);
const diagnostics = validateRemuxCompatibility(
options.description,
options.targetContainer,
options.streamSelection
);
if (diagnostics.some((entry) => entry.severity === 'error')) {
throw new CommandPlanError(
'The selected streams cannot be remuxed',
diagnostics
);
}
const input = createPlannedInput(options.jobId, options.source);
const output = createPlannedOutput(options.jobId, {
baseName: options.source.fileName,
operation: 'remux',
extension: options.fileExtension,
});
const args: string[] = ['-i', input.path];
if (options.streamSelection) {
args.push(...streamMapArguments(options.streamSelection));
} else {
args.push('-map', '0');
}
args.push('-c', 'copy');
if (options.metadataPolicy === 'remove') {
args.push('-map_metadata', '-1');
} else {
args.push('-map_metadata', '0');
}
args.push('-map_chapters', options.chapterPolicy === 'remove' ? '-1' : '0');
if (options.targetContainer === 'mp4' || options.targetContainer === 'ipod') {
args.push('-movflags', '+faststart');
}
args.push('-progress', 'pipe:1', '-nostats', output.path);
return freezeCommandPlan({
id: `${options.jobId}:remux`,
operation: 'remux',
inputs: [input],
temporaryFiles: [],
args,
outputs: [output],
...(options.expectedDurationSeconds !== undefined
? { expectedDurationSeconds: options.expectedDurationSeconds }
: {}),
requiredCapabilities: { muxers: [options.targetContainer] },
diagnostics: [
diagnostic(
'stream-copy',
'info',
'Fast remux copies compatible streams without re-encoding; it does not change their quality.'
),
...diagnostics,
],
});
}
function isSelected(stream: RemuxStream, selection?: StreamSelection): boolean {
if (!selection) {
return true;
}
const indexes =
stream.kind === 'video'
? selection.video
: stream.kind === 'audio'
? selection.audio
: stream.kind === 'subtitle'
? selection.subtitles
: stream.kind === 'attachment'
? selection.attachments
: selection.data;
return indexes?.includes(stream.index) ?? false;
}

273
src/commands/split.ts Normal file
View File

@@ -0,0 +1,273 @@
import type { ExportPreset } from './convert';
import { presetEncodingArguments } from '../presets/preset-registry';
import {
metadataPolicyArguments,
softSubtitlePolicyConfiguration,
} from './convert';
import {
createPlannedInput,
createPlannedOutput,
diagnostic,
mergeRequirements,
muxerForExtension,
normalizeSeconds,
validateRange,
type SourceReference,
} from './command-utils';
import { freezeCommandPlan, type FFmpegCommandPlan } from './command-plan';
import {
requirementsForUserPreset,
type ExportPreset as Preset,
} from './convert';
import {
GENERATED_OUTPUT_LIMIT_EXPLANATION,
MAX_GENERATED_OUTPUT_FILES,
MAX_SPLIT_MARKERS,
} from '../limits';
export interface TimeRange {
readonly startSeconds: number;
readonly endSeconds: number;
readonly label?: string;
}
export type SplitDefinition =
| { readonly type: 'markers'; readonly markers: readonly number[] }
| { readonly type: 'equal-duration'; readonly segmentDurationSeconds: number }
| { readonly type: 'part-count'; readonly parts: number }
| { readonly type: 'chapters'; readonly chapters: readonly TimeRange[] }
| { readonly type: 'clips'; readonly clips: readonly TimeRange[] };
export interface SplitOptions {
readonly jobId: string;
readonly source: SourceReference;
readonly durationSeconds: number;
readonly definition: SplitDefinition;
readonly mode: 'fast' | 'accurate';
readonly targetExtension: string;
readonly preset?: ExportPreset;
}
export function rangesForSplit(
definition: SplitDefinition,
durationSeconds: number
): readonly TimeRange[] {
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) {
throw new RangeError('durationSeconds must be positive');
}
let ranges: TimeRange[];
if (definition.type === 'markers') {
const markers = validateSplitMarkers(definition.markers, durationSeconds);
const boundaries = [0, ...markers, durationSeconds];
ranges = boundaries.slice(0, -1).map((start, index) => ({
startSeconds: start,
endSeconds: boundaries[index + 1] as number,
}));
} else if (definition.type === 'equal-duration') {
if (
!Number.isFinite(definition.segmentDurationSeconds) ||
definition.segmentDurationSeconds <= 0
) {
throw new RangeError('segmentDurationSeconds must be positive');
}
if (
Math.ceil(durationSeconds / definition.segmentDurationSeconds) >
MAX_GENERATED_OUTPUT_FILES
) {
throw new RangeError(
`A split operation can create at most ${MAX_GENERATED_OUTPUT_FILES} outputs. ${GENERATED_OUTPUT_LIMIT_EXPLANATION}`
);
}
ranges = [];
for (
let start = 0;
start < durationSeconds;
start += definition.segmentDurationSeconds
) {
ranges.push({
startSeconds: start,
endSeconds: Math.min(
durationSeconds,
start + definition.segmentDurationSeconds
),
});
}
} else if (definition.type === 'part-count') {
if (
!Number.isSafeInteger(definition.parts) ||
definition.parts < 1 ||
definition.parts > MAX_GENERATED_OUTPUT_FILES
) {
throw new RangeError(
`parts must be an integer between 1 and ${MAX_GENERATED_OUTPUT_FILES}. ${GENERATED_OUTPUT_LIMIT_EXPLANATION}`
);
}
const segmentDuration = durationSeconds / definition.parts;
ranges = Array.from({ length: definition.parts }, (_, index) => ({
startSeconds: segmentDuration * index,
endSeconds:
index === definition.parts - 1
? durationSeconds
: segmentDuration * (index + 1),
}));
} else {
ranges = [
...(definition.type === 'chapters'
? definition.chapters
: definition.clips),
];
}
if (ranges.length === 0 || ranges.length > MAX_GENERATED_OUTPUT_FILES) {
throw new RangeError(
`A split operation must create 1${MAX_GENERATED_OUTPUT_FILES} outputs. ${GENERATED_OUTPUT_LIMIT_EXPLANATION}`
);
}
ranges.forEach((range) =>
validateRange(range.startSeconds, range.endSeconds, durationSeconds)
);
return Object.freeze(ranges.map((range) => Object.freeze({ ...range })));
}
export function validateSplitMarkers(
markers: readonly number[],
durationSeconds: number
): readonly number[] {
if (markers.length > MAX_SPLIT_MARKERS) {
throw new RangeError(
`A split operation accepts at most ${MAX_SPLIT_MARKERS} markers (${MAX_GENERATED_OUTPUT_FILES} outputs). ${GENERATED_OUTPUT_LIMIT_EXPLANATION}`
);
}
const sorted = [...markers].sort((left, right) => left - right);
for (let index = 0; index < sorted.length; index += 1) {
const marker = sorted[index] as number;
if (!Number.isFinite(marker) || marker <= 0 || marker >= durationSeconds) {
throw new RangeError('Split markers must be inside the source duration');
}
if (index > 0 && Math.abs(marker - (sorted[index - 1] as number)) < 0.04) {
throw new RangeError(
'Split markers must be unique and at least 0.04 seconds apart'
);
}
}
return Object.freeze(sorted);
}
export function buildSplitPlan(options: SplitOptions): FFmpegCommandPlan {
const ranges = rangesForSplit(options.definition, options.durationSeconds);
if (options.mode === 'accurate' && !options.preset) {
throw new TypeError('Accurate split requires an export preset');
}
if (
options.mode === 'accurate' &&
options.preset &&
options.targetExtension.replace(/^\.+/u, '').toLowerCase() !==
options.preset.fileExtension
) {
throw new TypeError(
`Accurate split extension must match the preset (.${options.preset.fileExtension})`
);
}
const input = createPlannedInput(options.jobId, options.source);
const outputs = ranges.map((range, index) =>
createPlannedOutput(options.jobId, {
id: `segment-${index + 1}`,
baseName: options.source.fileName,
operation: 'split',
suffix: range.label
? `${String(index + 1).padStart(3, '0')}-${range.label}`
: String(index + 1).padStart(3, '0'),
extension: options.targetExtension,
timeRange: {
startSeconds: range.startSeconds,
endSeconds: range.endSeconds,
},
})
);
const args: string[] = [];
if (options.mode === 'fast') {
// Each output needs its own demuxer cursor. Reusing one input while
// stream-copying several timestamp windows can leave later outputs without
// video when keyframes are sparse.
ranges.forEach((range) => {
args.push('-ss', normalizeSeconds(range.startSeconds), '-i', input.path);
});
} else {
args.push('-i', input.path);
}
args.push('-progress', 'pipe:1', '-nostats');
ranges.forEach((range, index) => {
if (options.mode === 'accurate') {
args.push('-ss', normalizeSeconds(range.startSeconds));
}
args.push(
'-t',
normalizeSeconds(range.endSeconds - range.startSeconds),
'-map',
options.mode === 'fast' ? String(index) : '0'
);
if (options.mode === 'fast') {
args.push(
'-c',
'copy',
'-map_metadata',
String(index),
'-map_chapters',
'-1',
'-avoid_negative_ts',
'make_zero'
);
} else {
args.push(...presetEncodingArguments(options.preset as Preset));
args.push(
...softSubtitlePolicyConfiguration(options.preset as Preset).args
);
args.push(
...metadataPolicyArguments(options.preset as Preset),
'-map_chapters',
'-1'
);
}
const output = outputs[index];
if (!output) {
throw new TypeError(`Missing planned split output ${index + 1}`);
}
args.push(output.path);
});
const requirements =
options.mode === 'accurate' && options.preset
? mergeRequirements(
'builtIn' in options.preset
? options.preset.requirements
: requirementsForUserPreset(options.preset),
softSubtitlePolicyConfiguration(options.preset).requirements
)
: {
...(muxerForExtension(options.targetExtension)
? { muxers: [muxerForExtension(options.targetExtension)!] }
: {}),
};
return freezeCommandPlan({
id: `${options.jobId}:split`,
operation: options.mode === 'fast' ? 'split-fast' : 'split-accurate',
inputs: [input],
temporaryFiles: [],
args,
outputs,
expectedDurationSeconds: options.durationSeconds,
requiredCapabilities: requirements,
diagnostics: [
diagnostic(
options.mode === 'fast' ? 'split-keyframes' : 'split-reencode',
options.mode === 'fast' ? 'warning' : 'info',
options.mode === 'fast'
? 'Fast split boundaries can move to nearby keyframes.'
: 'Accurate split re-encodes each segment at the requested boundaries.'
),
diagnostic(
'split-chapters-omitted',
'info',
'Source chapters are omitted because their timestamps do not automatically match individual split outputs.'
),
],
});
}

184
src/commands/streams.ts Normal file
View File

@@ -0,0 +1,184 @@
import type { ExportPreset } from './convert';
import { buildConvertPlan } from './convert';
import {
assertMuxerMatchesExtension,
createPlannedInput,
createPlannedOutput,
diagnostic,
streamMapArguments,
type SourceReference,
type StreamSelection,
} from './command-utils';
import {
CommandPlanError,
freezeCommandPlan,
type FFmpegCommandPlan,
} from './command-plan';
export interface AudioExtractionOptions {
readonly jobId: string;
readonly source: SourceReference;
/** Absolute ffprobe stream index. */
readonly audioStreamIndex: number;
readonly expectedDurationSeconds?: number;
readonly mode: 'copy' | 'encode';
readonly inputCodec?: string;
readonly targetMuxer?: string;
readonly targetExtension?: string;
readonly preset?: ExportPreset;
}
export function buildAudioExtractionPlan(
options: AudioExtractionOptions
): FFmpegCommandPlan {
const audioStreamIndex = options.audioStreamIndex;
if (!Number.isSafeInteger(audioStreamIndex) || audioStreamIndex < 0) {
throw new RangeError('Invalid audio stream index');
}
if (options.mode === 'encode') {
if (!options.preset || options.preset.video) {
throw new TypeError(
'Encoded audio extraction requires an audio-only preset'
);
}
return buildConvertPlan({
jobId: options.jobId,
source: options.source,
preset: options.preset,
streamSelection: { audio: [audioStreamIndex] },
operation: 'audio',
expectedDurationSeconds: options.expectedDurationSeconds,
});
}
if (!options.inputCodec || !options.targetMuxer || !options.targetExtension) {
throw new TypeError(
'Stream-copy audio extraction requires codec, target muxer, and extension'
);
}
assertMuxerMatchesExtension(options.targetMuxer, options.targetExtension);
if (!isAudioCopyCompatible(options.inputCodec, options.targetMuxer)) {
throw new CommandPlanError(
'Audio codec is not compatible with the target container',
[
diagnostic(
'audio-copy-incompatible',
'error',
`${options.inputCodec} cannot be safely copied into ${options.targetMuxer}.`
),
]
);
}
const input = createPlannedInput(options.jobId, options.source);
const output = createPlannedOutput(options.jobId, {
baseName: options.source.fileName,
operation: 'audio',
extension: options.targetExtension,
role: 'audio',
});
return freezeCommandPlan({
id: `${options.jobId}:audio-extract`,
operation: 'audio-extract-copy',
inputs: [input],
temporaryFiles: [],
args: [
'-i',
input.path,
'-map',
`0:${audioStreamIndex}`,
'-vn',
'-sn',
'-dn',
'-c:a',
'copy',
output.path,
],
outputs: [output],
...(options.expectedDurationSeconds !== undefined
? { expectedDurationSeconds: options.expectedDurationSeconds }
: {}),
requiredCapabilities: { muxers: [options.targetMuxer] },
diagnostics: [
diagnostic(
'audio-stream-copy',
'info',
'The selected compatible audio stream is retained without re-encoding.'
),
],
});
}
export interface StreamRemovalOptions {
readonly jobId: string;
readonly source: SourceReference;
readonly selection: StreamSelection;
readonly targetExtension: string;
readonly targetMuxer: string;
readonly expectedDurationSeconds?: number;
}
export function buildStreamRemovalPlan(
options: StreamRemovalOptions
): FFmpegCommandPlan {
assertMuxerMatchesExtension(options.targetMuxer, options.targetExtension);
const mapArgs = streamMapArguments(options.selection);
if (mapArgs.length === 0) {
throw new RangeError('At least one output stream must remain');
}
const input = createPlannedInput(options.jobId, options.source);
const output = createPlannedOutput(options.jobId, {
baseName: options.source.fileName,
operation: 'streams',
extension: options.targetExtension,
});
return freezeCommandPlan({
id: `${options.jobId}:stream-removal`,
operation: 'stream-removal',
inputs: [input],
temporaryFiles: [],
args: [
'-i',
input.path,
...mapArgs,
'-c',
'copy',
'-map_metadata',
'0',
'-map_chapters',
'0',
output.path,
],
outputs: [output],
...(options.expectedDurationSeconds !== undefined
? { expectedDurationSeconds: options.expectedDurationSeconds }
: {}),
requiredCapabilities: { muxers: [options.targetMuxer] },
diagnostics: [
diagnostic(
'stream-removal-copy',
'info',
'Only explicitly selected streams are copied to the output.'
),
],
});
}
function isAudioCopyCompatible(codec: string, muxer: string): boolean {
const rules: Readonly<Record<string, ReadonlySet<string>>> = {
mp3: new Set(['mp3']),
ipod: new Set(['aac', 'alac', 'mp3']),
ogg: new Set(['vorbis', 'opus', 'flac']),
opus: new Set(['opus']),
wav: new Set(['pcm_s16le', 'pcm_s24le', 'pcm_f32le']),
flac: new Set(['flac']),
matroska: new Set([
'aac',
'alac',
'mp3',
'vorbis',
'opus',
'flac',
'pcm_s16le',
]),
};
return rules[muxer]?.has(codec) ?? false;
}

428
src/commands/subtitles.ts Normal file
View File

@@ -0,0 +1,428 @@
import type { ExportPreset } from './convert';
import { buildConvertPlan } from './convert';
import {
assertMuxerMatchesExtension,
createPlannedInput,
createPlannedOutput,
diagnostic,
escapeFilterValue,
normalizeSeconds,
type SourceReference,
} from './command-utils';
import {
CommandPlanError,
freezeCommandPlan,
type CommandDiagnostic,
type FFmpegCommandPlan,
} from './command-plan';
import { validateRemuxCompatibility, type RemuxStream } from './remux';
export type SubtitleFormat = 'srt' | 'vtt' | 'ass';
export type SubtitleClassification = 'text' | 'bitmap' | 'unknown';
export interface SubtitleStreamDescription {
readonly index: number;
readonly codec: string;
readonly language?: string;
readonly title?: string;
readonly default?: boolean;
readonly forced?: boolean;
}
const TEXT_CODECS = new Set([
'subrip',
'srt',
'webvtt',
'ass',
'ssa',
'mov_text',
'text',
]);
const BITMAP_CODECS = new Set([
'dvd_subtitle',
'hdmv_pgs_subtitle',
'dvb_subtitle',
'xsub',
]);
export function classifySubtitleCodec(codec: string): SubtitleClassification {
if (TEXT_CODECS.has(codec)) {
return 'text';
}
if (BITMAP_CODECS.has(codec)) {
return 'bitmap';
}
return 'unknown';
}
export function validateSoftSubtitleCompatibility(
container: 'mp4' | 'webm' | 'matroska',
codec: string
): readonly CommandDiagnostic[] {
const compatible =
container === 'mp4'
? codec === 'mov_text'
: container === 'webm'
? codec === 'webvtt'
: classifySubtitleCodec(codec) !== 'unknown';
return compatible
? Object.freeze([])
: Object.freeze([
diagnostic(
'subtitle-container-incompatible',
'error',
`${codec} subtitles are not soft-mux compatible with ${container}; choose an explicit text conversion or burn-in.`
),
]);
}
export interface SubtitleExtractionOptions {
readonly jobId: string;
readonly source: SourceReference;
readonly stream: SubtitleStreamDescription;
readonly outputFormat: SubtitleFormat;
}
export function buildSubtitleExtractionPlan(
options: SubtitleExtractionOptions
): FFmpegCommandPlan {
if (!Number.isSafeInteger(options.stream.index) || options.stream.index < 0) {
throw new RangeError('Invalid subtitle stream index');
}
const classification = classifySubtitleCodec(options.stream.codec);
if (classification !== 'text') {
throw new CommandPlanError(
classification === 'bitmap'
? 'Bitmap subtitles cannot be converted to text automatically'
: 'Unknown subtitle codecs cannot be converted to text automatically',
[
diagnostic(
classification === 'bitmap'
? 'bitmap-not-text'
: 'unknown-subtitle-codec',
'error',
classification === 'bitmap'
? 'Extraction can preserve bitmap data in a compatible format, but it is not OCR or text conversion.'
: 'Only a recognized text subtitle codec can be converted to SRT, WebVTT, or ASS.'
),
]
);
}
const input = createPlannedInput(options.jobId, options.source);
const output = createPlannedOutput(options.jobId, {
baseName: options.source.fileName,
operation: 'subtitle',
suffix: options.stream.language ?? `stream-${options.stream.index}`,
extension: options.outputFormat,
role: 'subtitle',
});
const encoder = subtitleEncoder(options.outputFormat);
return freezeCommandPlan({
id: `${options.jobId}:subtitle-extract`,
operation: 'subtitle-extract',
inputs: [input],
temporaryFiles: [],
args: [
'-i',
input.path,
'-map',
`0:${options.stream.index}`,
'-c:s',
encoder,
output.path,
],
outputs: [output],
requiredCapabilities: {
encoders: [encoder],
muxers: [subtitleMuxer(options.outputFormat)],
},
diagnostics: [
diagnostic(
'subtitle-extraction',
'info',
`Text subtitle stream ${options.stream.index} is converted to ${options.outputFormat.toUpperCase()}.`
),
],
});
}
export interface SubtitleConversionOptions {
readonly jobId: string;
readonly subtitle: SourceReference;
readonly outputFormat: SubtitleFormat;
readonly offsetSeconds?: number;
}
export function buildSubtitleConversionPlan(
options: SubtitleConversionOptions
): FFmpegCommandPlan {
const input = createPlannedInput(options.jobId, options.subtitle, 'subtitle');
const output = createPlannedOutput(options.jobId, {
baseName: options.subtitle.fileName,
operation: 'subtitle',
extension: options.outputFormat,
role: 'subtitle',
});
const encoder = subtitleEncoder(options.outputFormat);
const args: string[] = [];
if (options.offsetSeconds !== undefined) {
if (
!Number.isFinite(options.offsetSeconds) ||
Math.abs(options.offsetSeconds) > 86_400
) {
throw new RangeError('Subtitle offset must be within 24 hours');
}
args.push('-itsoffset', signedSeconds(options.offsetSeconds));
}
args.push('-i', input.path, '-map', '0:s:0', '-c:s', encoder, output.path);
return freezeCommandPlan({
id: `${options.jobId}:subtitle-convert`,
operation: 'subtitle-convert',
inputs: [input],
temporaryFiles: [],
args,
outputs: [output],
requiredCapabilities: {
encoders: [encoder],
muxers: [subtitleMuxer(options.outputFormat)],
},
diagnostics: [],
});
}
export interface SubtitleMuxOptions {
readonly jobId: string;
readonly source: SourceReference;
readonly subtitle: SourceReference;
readonly subtitleCodec: string;
readonly sourceStreams: readonly RemuxStream[];
readonly targetContainer: 'mp4' | 'webm' | 'matroska';
readonly targetExtension: string;
readonly language?: string;
readonly title?: string;
readonly default?: boolean;
readonly forced?: boolean;
readonly expectedDurationSeconds?: number;
}
export function buildSubtitleMuxPlan(
options: SubtitleMuxOptions
): FFmpegCommandPlan {
assertMuxerMatchesExtension(options.targetContainer, options.targetExtension);
const outputCodec =
options.targetContainer === 'mp4'
? 'mov_text'
: options.targetContainer === 'webm'
? 'webvtt'
: options.subtitleCodec;
const diagnostics = [
...validateSoftSubtitleCompatibility(options.targetContainer, outputCodec),
...validateRemuxCompatibility(
{ streams: options.sourceStreams },
options.targetContainer
),
];
validateSourceStreams(options.sourceStreams);
if (options.sourceStreams.length === 0) {
throw new CommandPlanError('No source media streams were declared', [
diagnostic(
'no-source-streams',
'error',
'Soft-subtitle muxing requires inspected source streams.'
),
]);
}
const existingSubtitleCount = options.sourceStreams.filter(
(stream) => stream.kind === 'subtitle'
).length;
if (diagnostics.some((entry) => entry.severity === 'error')) {
throw new CommandPlanError(
'Subtitle is incompatible with target container',
diagnostics
);
}
if (
classifySubtitleCodec(options.subtitleCodec) === 'bitmap' &&
outputCodec !== options.subtitleCodec
) {
throw new CommandPlanError('Bitmap subtitle conversion is not supported', [
diagnostic(
'bitmap-not-text',
'error',
'Bitmap subtitles cannot be converted to a text subtitle codec automatically.'
),
]);
}
const mediaInput = createPlannedInput(options.jobId, options.source);
const subtitleInput = createPlannedInput(
options.jobId,
options.subtitle,
'subtitle'
);
const output = createPlannedOutput(options.jobId, {
baseName: options.source.fileName,
operation: 'subtitled',
extension: options.targetExtension,
});
const requiresConversion = outputCodec !== options.subtitleCodec;
if (
requiresConversion &&
classifySubtitleCodec(options.subtitleCodec) !== 'text'
) {
throw new CommandPlanError(
'Only text subtitles can be converted for this target container',
[
diagnostic(
'subtitle-conversion-unsupported',
'error',
'Choose a recognized SRT, WebVTT, ASS/SSA, mov_text, or text subtitle input.'
),
]
);
}
const args = [
'-i',
mediaInput.path,
'-i',
subtitleInput.path,
...[...options.sourceStreams]
.sort((left, right) => left.index - right.index)
.flatMap((stream) => ['-map', `0:${stream.index}`]),
'-map',
'1:s:0',
'-c',
'copy',
`-c:s:${existingSubtitleCount}`,
requiresConversion ? outputCodec : 'copy',
];
if (options.language) {
args.push(
`-metadata:s:s:${existingSubtitleCount}`,
`language=${safeSubtitleTag(options.language)}`
);
}
if (options.title) {
const title = safeSubtitleTag(options.title);
args.push(`-metadata:s:s:${existingSubtitleCount}`, `title=${title}`);
if (options.targetContainer === 'mp4') {
args.push(
`-metadata:s:s:${existingSubtitleCount}`,
`handler_name=${title}`
);
}
}
const dispositions =
[
options.default ? 'default' : undefined,
options.forced ? 'forced' : undefined,
]
.filter((value): value is string => Boolean(value))
.join('+') || '0';
args.push(
`-disposition:s:${existingSubtitleCount}`,
dispositions,
output.path
);
return freezeCommandPlan({
id: `${options.jobId}:subtitle-mux`,
operation: 'subtitle-mux',
inputs: [mediaInput, subtitleInput],
temporaryFiles: [],
args,
outputs: [output],
...(options.expectedDurationSeconds !== undefined
? { expectedDurationSeconds: options.expectedDurationSeconds }
: {}),
requiredCapabilities: {
muxers: [options.targetContainer],
...(requiresConversion ? { encoders: [outputCodec] } : {}),
},
diagnostics,
});
}
export interface SubtitleBurnOptions {
readonly jobId: string;
readonly source: SourceReference;
readonly subtitle: SourceReference;
readonly preset: ExportPreset;
readonly expectedDurationSeconds?: number;
}
export function buildSubtitleBurnPlan(
options: SubtitleBurnOptions
): FFmpegCommandPlan {
if (!options.preset.video) {
throw new TypeError('Subtitle burn-in requires a video export preset');
}
const subtitleInput = createPlannedInput(
options.jobId,
options.subtitle,
'subtitle'
);
const base = buildConvertPlan({
jobId: options.jobId,
source: options.source,
preset: options.preset,
operation: 'subtitle-burn',
expectedDurationSeconds: options.expectedDurationSeconds,
videoFiltergraph: `subtitles=filename='${escapeFilterValue(subtitleInput.path)}'`,
additionalRequirements: { filters: ['subtitles'] },
});
return freezeCommandPlan({
...base,
inputs: [...base.inputs, subtitleInput],
diagnostics: [
diagnostic(
'subtitle-burn-in',
'info',
'Subtitle pixels are rendered locally into the video using the bundled subtitle filter.'
),
],
});
}
function subtitleEncoder(format: SubtitleFormat): string {
return format === 'srt' ? 'srt' : format === 'vtt' ? 'webvtt' : 'ass';
}
function subtitleMuxer(format: SubtitleFormat): string {
return format === 'vtt' ? 'webvtt' : format;
}
function signedSeconds(value: number): string {
return value < 0
? `-${normalizeSeconds(Math.abs(value))}`
: normalizeSeconds(value);
}
function safeSubtitleTag(value: string): string {
if (
value.includes('\0') ||
value.includes('\n') ||
value.includes('\r') ||
value.length > 128
) {
throw new TypeError('Subtitle tag contains unsupported characters');
}
return value;
}
function validateSourceStreams(streams: readonly RemuxStream[]): void {
const indexes = new Set<number>();
for (const stream of streams) {
if (
!Number.isSafeInteger(stream.index) ||
stream.index < 0 ||
indexes.has(stream.index)
) {
throw new RangeError(
'Source stream indexes must be unique nonnegative integers'
);
}
if (!stream.codec.trim()) {
throw new TypeError(`Source stream ${stream.index} codec is required`);
}
indexes.add(stream.index);
}
}

393
src/commands/thumbnails.ts Normal file
View File

@@ -0,0 +1,393 @@
import {
createPlannedInput,
createPlannedOutput,
diagnostic,
normalizeSeconds,
safePathComponent,
type SourceReference,
} from './command-utils';
import {
GENERATED_OUTPUT_LIMIT_EXPLANATION,
MAX_GENERATED_OUTPUT_FILES,
} from '../limits';
import { freezeCommandPlan, type FFmpegCommandPlan } from './command-plan';
export type ImageFormat = 'png' | 'jpeg' | 'webp';
export interface ThumbnailSize {
readonly width?: number;
readonly height?: number;
readonly maintainAspect?: boolean;
}
export interface SingleThumbnailOptions {
readonly jobId: string;
readonly source: SourceReference;
readonly timeSeconds: number;
readonly size?: ThumbnailSize;
readonly format: ImageFormat;
readonly quality?: number;
}
export interface ThumbnailSeriesOptions {
readonly jobId: string;
readonly source: SourceReference;
readonly durationSeconds: number;
readonly timesSeconds?: readonly number[];
readonly intervalSeconds?: number;
readonly count?: number;
readonly size?: ThumbnailSize;
readonly format: ImageFormat;
readonly quality?: number;
}
export interface ContactSheetOptions {
readonly jobId: string;
readonly source: SourceReference;
readonly durationSeconds: number;
readonly frameCount: number;
readonly rows: number;
readonly columns: number;
readonly cellWidth: number;
readonly spacing?: number;
readonly background?: string;
readonly maintainAspect?: boolean;
readonly timestampLabels?: boolean;
readonly sourceLabel?: boolean;
readonly format: ImageFormat;
readonly quality?: number;
readonly drawtextAvailable?: boolean;
}
export function buildSingleThumbnailPlan(
options: SingleThumbnailOptions
): FFmpegCommandPlan {
if (!Number.isFinite(options.timeSeconds) || options.timeSeconds < 0) {
throw new RangeError('Thumbnail time must be nonnegative');
}
const input = createPlannedInput(options.jobId, options.source);
const extension = extensionForImageFormat(options.format);
const output = createPlannedOutput(options.jobId, {
baseName: options.source.fileName,
operation: 'thumbnail',
suffix: `${Math.round(options.timeSeconds * 1000)}ms`,
extension,
role: 'thumbnail',
});
const args = [
'-ss',
normalizeSeconds(options.timeSeconds),
'-i',
input.path,
'-frames:v',
'1',
...imageFilterArguments(options.size),
...imageQualityArguments(options.format, options.quality),
output.path,
];
return freezeCommandPlan({
id: `${options.jobId}:thumbnail`,
operation: 'thumbnail',
inputs: [input],
temporaryFiles: [],
args,
outputs: [output],
requiredCapabilities: imageRequirements(options.format),
diagnostics: [],
});
}
export function buildThumbnailSeriesPlan(
options: ThumbnailSeriesOptions
): FFmpegCommandPlan {
const times = resolveThumbnailTimes(options);
const input = createPlannedInput(options.jobId, options.source);
const extension = extensionForImageFormat(options.format);
const outputs = times.map((time, index) =>
createPlannedOutput(options.jobId, {
id: `thumbnail-${index + 1}`,
baseName: options.source.fileName,
operation: 'thumbnail',
suffix: `${String(index + 1).padStart(3, '0')}-${Math.round(time * 1000)}ms`,
extension,
role: 'thumbnail',
})
);
const size = scaleFilter(options.size);
const splitOutputs = times
.map((_, index) => `[thumb-source-${index}]`)
.join('');
const filtergraph = [
`[0:v]split=${times.length}${splitOutputs}`,
...times.map((time, index) => {
const filters = [
`select='gte(t\\,${normalizeSeconds(time)})'`,
'setpts=N/FRAME_RATE/TB',
...(size ? [size] : []),
];
return `[thumb-source-${index}]${filters.join(',')}[thumb-${index}]`;
}),
].join(';');
const outputArguments = outputs.flatMap((output, index) => [
'-map',
`[thumb-${index}]`,
'-frames:v',
'1',
...imageQualityArguments(options.format, options.quality),
output.path,
]);
return freezeCommandPlan({
id: `${options.jobId}:thumbnail-series`,
operation: 'thumbnail-series',
inputs: [input],
temporaryFiles: [],
args: [
'-i',
input.path,
'-filter_complex',
filtergraph,
...outputArguments,
],
outputs,
expectedDurationSeconds: options.durationSeconds,
requiredCapabilities: {
...imageRequirements(options.format),
filters: ['split', 'select', 'setpts', ...(size ? ['scale'] : [])],
},
diagnostics: [
diagnostic(
'single-pass-series',
'info',
'The thumbnail series is decoded in one FFmpeg invocation.'
),
],
});
}
export function resolveThumbnailTimes(
options: Pick<
ThumbnailSeriesOptions,
'durationSeconds' | 'timesSeconds' | 'intervalSeconds' | 'count'
>
): readonly number[] {
if (
!Number.isFinite(options.durationSeconds) ||
options.durationSeconds <= 0
) {
throw new RangeError('Source duration must be positive');
}
let times: number[];
if (options.timesSeconds) {
times = [...options.timesSeconds];
} else if (options.intervalSeconds !== undefined) {
if (
!Number.isFinite(options.intervalSeconds) ||
options.intervalSeconds <= 0
) {
throw new RangeError('Thumbnail interval must be positive');
}
if (
Math.ceil(options.durationSeconds / options.intervalSeconds) >
MAX_GENERATED_OUTPUT_FILES
) {
throw new RangeError(
`A thumbnail series can contain at most ${MAX_GENERATED_OUTPUT_FILES} images. ${GENERATED_OUTPUT_LIMIT_EXPLANATION}`
);
}
times = [];
for (
let time = 0;
time < options.durationSeconds;
time += options.intervalSeconds
) {
times.push(time);
}
} else {
const count = options.count ?? 12;
if (
!Number.isSafeInteger(count) ||
count < 1 ||
count > MAX_GENERATED_OUTPUT_FILES
) {
throw new RangeError(
`Thumbnail count must be an integer from 1 to ${MAX_GENERATED_OUTPUT_FILES}. ${GENERATED_OUTPUT_LIMIT_EXPLANATION}`
);
}
times = Array.from(
{ length: count },
(_, index) => (options.durationSeconds * (index + 1)) / (count + 1)
);
}
const unique = [...new Set(times)].sort((left, right) => left - right);
if (
unique.length === 0 ||
unique.length > MAX_GENERATED_OUTPUT_FILES ||
unique.some(
(time) =>
!Number.isFinite(time) || time < 0 || time >= options.durationSeconds
)
) {
throw new RangeError('Thumbnail times must be within the source duration');
}
return Object.freeze(unique);
}
export function buildContactSheetPlan(
options: ContactSheetOptions
): FFmpegCommandPlan {
if (
!Number.isFinite(options.durationSeconds) ||
options.durationSeconds <= 0
) {
throw new RangeError('Contact sheet source duration must be positive');
}
if (
!Number.isSafeInteger(options.frameCount) ||
options.frameCount < 1 ||
options.frameCount > 500 ||
!Number.isSafeInteger(options.rows) ||
!Number.isSafeInteger(options.columns) ||
options.rows < 1 ||
options.columns < 1 ||
options.rows * options.columns < options.frameCount ||
options.rows * options.columns > 500
) {
throw new RangeError(
'Contact sheet grid must contain 1500 cells and fit all frames'
);
}
if (
!Number.isSafeInteger(options.cellWidth) ||
options.cellWidth < 32 ||
options.cellWidth > 4096
) {
throw new RangeError('Contact sheet cell width must be 324096 pixels');
}
const spacing = options.spacing ?? 4;
if (!Number.isSafeInteger(spacing) || spacing < 0 || spacing > 100) {
throw new RangeError('Contact sheet spacing must be 0100 pixels');
}
const background = options.background ?? 'black';
if (!/^(?:[a-z]{3,20}|#[0-9a-f]{6})$/iu.test(background)) {
throw new TypeError('Background must be a named colour or #RRGGBB');
}
const input = createPlannedInput(options.jobId, options.source);
const output = createPlannedOutput(options.jobId, {
baseName: options.source.fileName,
operation: 'contact-sheet',
extension: extensionForImageFormat(options.format),
role: 'contact-sheet',
});
const fps = options.frameCount / options.durationSeconds;
const filters = [
`fps=${fps.toFixed(8).replace(/0+$/u, '').replace(/\.$/u, '')}`,
options.maintainAspect === false
? `scale=${options.cellWidth}:${options.cellWidth}`
: `scale=${options.cellWidth}:-2:force_original_aspect_ratio=decrease`,
];
let labelsEnabled = Boolean(options.timestampLabels || options.sourceLabel);
const diagnostics = [];
if (labelsEnabled && !options.drawtextAvailable) {
labelsEnabled = false;
diagnostics.push(
diagnostic(
'labels-unavailable',
'warning',
'Timestamp and filename labels were omitted because drawtext or a safe local font is unavailable.'
)
);
}
if (labelsEnabled) {
const text = options.sourceLabel
? `${safePathComponent(options.source.fileName)} %{pts\\:hms}`
: '%{pts\\:hms}';
filters.push(
`drawtext=text='${text}':x=8:y=h-th-8:fontcolor=white:box=1:boxcolor=black@0.6`
);
}
filters.push(
`tile=${options.columns}x${options.rows}:padding=${spacing}:margin=${spacing}:color=${background}`
);
return freezeCommandPlan({
id: `${options.jobId}:contact-sheet`,
operation: 'contact-sheet',
inputs: [input],
temporaryFiles: [],
args: [
'-i',
input.path,
'-vf',
filters.join(','),
'-frames:v',
'1',
...imageQualityArguments(options.format, options.quality),
output.path,
],
outputs: [output],
expectedDurationSeconds: options.durationSeconds,
requiredCapabilities: {
...imageRequirements(options.format),
filters: ['fps', 'scale', 'tile', ...(labelsEnabled ? ['drawtext'] : [])],
},
diagnostics,
});
}
function imageFilterArguments(size?: ThumbnailSize): readonly string[] {
const filter = scaleFilter(size);
return filter ? ['-vf', filter] : [];
}
function scaleFilter(size?: ThumbnailSize): string | undefined {
if (!size || (size.width === undefined && size.height === undefined)) {
return undefined;
}
for (const value of [size.width, size.height]) {
if (
value !== undefined &&
(!Number.isSafeInteger(value) || value < 1 || value > 8192)
) {
throw new RangeError(
'Thumbnail dimensions must be integer values from 1 to 8192'
);
}
}
const dimensions = `scale=${size.width ?? -2}:${size.height ?? -2}`;
return size.maintainAspect === false
? dimensions
: `${dimensions}:force_original_aspect_ratio=decrease`;
}
function imageQualityArguments(
format: ImageFormat,
quality?: number
): readonly string[] {
if (quality === undefined) {
return [];
}
if (!Number.isFinite(quality) || quality < 1 || quality > 100) {
throw new RangeError('Image quality must be between 1 and 100');
}
if (format === 'jpeg') {
return ['-q:v', String(Math.max(2, Math.round(31 - quality * 0.29)))];
}
if (format === 'webp') {
return ['-quality', String(Math.round(quality))];
}
return [];
}
function imageRequirements(format: ImageFormat): {
readonly encoders: readonly string[];
readonly muxers: readonly string[];
} {
return format === 'jpeg'
? { encoders: ['mjpeg'], muxers: ['image2'] }
: format === 'png'
? { encoders: ['png'], muxers: ['image2'] }
: { encoders: ['libwebp'], muxers: ['webp'] };
}
function extensionForImageFormat(format: ImageFormat): string {
return format === 'jpeg' ? 'jpg' : format;
}

View File

@@ -0,0 +1,407 @@
import { presetEncodingArguments } from '../presets/preset-registry';
import type { ExportPreset } from './convert';
import { requirementsForUserPreset } from './convert';
import {
createPlannedInput,
createPlannedOutput,
diagnostic,
mergeRequirements,
normalizeSeconds,
validateRange,
type SourceReference,
} from './command-utils';
import { audioFadeFilters, videoFadeFilters } from './filtergraph-builder';
import type { FadeCurve } from './filtergraph-builder';
import type {
LoudnessMeasurement,
LoudnessTargets,
PeakMeasurement,
} from './loudness';
import {
buildPeakNormalizationFilter,
validateLoudnessMeasurement,
} from './loudness';
import { buildTransformFiltergraph, type TransformSettings } from './transform';
import { freezeCommandPlan, type FFmpegCommandPlan } from './command-plan';
export type TimelineAudioNormalization =
| {
readonly mode: 'peak';
readonly targetPeakDb: number;
readonly measurement: PeakMeasurement;
}
| {
readonly mode: 'loudness';
readonly targets: LoudnessTargets;
readonly measurement: LoudnessMeasurement;
};
export interface TimelineExportClip {
readonly id: string;
readonly source: SourceReference;
readonly sourceInSeconds: number;
readonly sourceOutSeconds: number;
readonly sourceDurationSeconds?: number;
readonly hasVideo: boolean;
readonly hasAudio: boolean;
readonly transform?: Omit<
TransformSettings,
'frameRate' | 'pixelFormat' | 'fadeNodes' | 'subtitleFilterExpression'
>;
readonly audioGainDb?: number;
readonly audioNormalization?: TimelineAudioNormalization;
readonly audioFadeInSeconds?: number;
readonly audioFadeOutSeconds?: number;
readonly audioFadeCurve?: FadeCurve;
readonly videoFadeInSeconds?: number;
readonly videoFadeOutSeconds?: number;
readonly videoFadeCurve?: FadeCurve;
}
export interface TimelineExportOptions {
readonly jobId: string;
readonly clips: readonly TimelineExportClip[];
readonly preset: ExportPreset;
readonly outputWidth: number;
readonly outputHeight: number;
readonly frameRate: number;
readonly sampleRate?: number;
readonly channelLayout?: 'mono' | 'stereo' | '5.1';
readonly missingAudioPolicy: 'insert-silence' | 'drop-all' | 'reject';
readonly outputFileName?: string;
}
/**
* Creates one normalized sequential export command. It composes per-clip
* transforms into a deterministic filter_complex graph and emits one output.
* Loudness clips require a measurement produced by the first-pass builder.
*/
export function buildTimelineExportPlan(
options: TimelineExportOptions
): FFmpegCommandPlan {
if (options.clips.length === 0 || options.clips.length > 100) {
throw new RangeError('Timeline export requires 1100 clips');
}
if (!options.preset.video) {
throw new TypeError('Timeline export requires a video preset');
}
validateOutput(options.outputWidth, options.outputHeight, options.frameRate);
if (
options.sampleRate !== undefined &&
(!Number.isSafeInteger(options.sampleRate) ||
options.sampleRate < 8_000 ||
options.sampleRate > 192_000)
) {
throw new RangeError(
'Timeline audio sample rate must be from 8000 to 192000 Hz'
);
}
const ids = new Set<string>();
for (const clip of options.clips) {
if (!clip.id || ids.has(clip.id)) {
throw new TypeError('Timeline clip IDs must be nonempty and unique');
}
ids.add(clip.id);
if (!clip.hasVideo) {
throw new TypeError(
'Timeline export currently requires video in every clip'
);
}
validateRange(
clip.sourceInSeconds,
clip.sourceOutSeconds,
clip.sourceDurationSeconds
);
}
const audioPresence = options.clips.map((clip) => clip.hasAudio);
const mixedAudio =
audioPresence.some(Boolean) && audioPresence.some((value) => !value);
if (mixedAudio && options.missingAudioPolicy === 'reject') {
throw new TypeError(
'Some clips have no audio; choose insert-silence or drop-all explicitly'
);
}
const includeAudio =
options.missingAudioPolicy !== 'drop-all' && audioPresence.some(Boolean);
if (includeAudio && !options.preset.audio) {
throw new TypeError('Selected preset does not include an audio encoder');
}
const inputs = options.clips.map((clip) =>
createPlannedInput(options.jobId, clip.source)
);
const args: string[] = [];
inputs.forEach((input) => args.push('-i', input.path));
const graph: string[] = [];
const requiredFilters = new Set<string>([
'trim',
'setpts',
'scale',
'pad',
'setsar',
'fps',
'format',
'concat',
]);
if (includeAudio) {
['atrim', 'asetpts', 'aresample', 'aformat'].forEach((filter) =>
requiredFilters.add(filter)
);
}
options.clips.forEach((clip, index) => {
const duration = clip.sourceOutSeconds - clip.sourceInSeconds;
validateFadePair(
duration,
clip.videoFadeInSeconds,
clip.videoFadeOutSeconds,
'Video'
);
validateFadePair(
duration,
clip.audioFadeInSeconds,
clip.audioFadeOutSeconds,
'Audio'
);
const transform = clip.transform
? buildTransformFiltergraph(clip.transform)
: { graph: undefined, requirements: {} };
for (const filter of transform.requirements?.filters ?? []) {
requiredFilters.add(filter);
}
const videoChain = [
`trim=start=${normalizeSeconds(clip.sourceInSeconds)}:end=${normalizeSeconds(clip.sourceOutSeconds)}`,
'setpts=PTS-STARTPTS',
...(transform.graph ? [transform.graph] : []),
`scale=${options.outputWidth}:${options.outputHeight}:force_original_aspect_ratio=decrease`,
`pad=${options.outputWidth}:${options.outputHeight}:(ow-iw)/2:(oh-ih)/2`,
'setsar=1',
`fps=${options.frameRate}`,
'format=yuv420p',
...videoFadeFilters(
duration,
clip.videoFadeInSeconds,
clip.videoFadeOutSeconds,
clip.videoFadeCurve
).map((node) => {
for (const filter of node.requiredCapabilities?.filters ?? []) {
requiredFilters.add(filter);
}
return node.expression;
}),
];
graph.push(`[${index}:v:0]${videoChain.join(',')}[v${index}]`);
if (includeAudio) {
if (clip.hasAudio) {
const audioChain = [
`atrim=start=${normalizeSeconds(clip.sourceInSeconds)}:end=${normalizeSeconds(clip.sourceOutSeconds)}`,
'asetpts=PTS-STARTPTS',
`aresample=${options.sampleRate ?? 48_000}`,
`aformat=sample_fmts=fltp:channel_layouts=${options.channelLayout ?? 'stereo'}`,
];
if (clip.audioGainDb !== undefined) {
if (
!Number.isFinite(clip.audioGainDb) ||
Math.abs(clip.audioGainDb) > 60
) {
throw new RangeError('Audio gain must be between -60 and 60 dB');
}
audioChain.push(`volume=${signed(clip.audioGainDb)}dB`);
requiredFilters.add('volume');
}
if (clip.audioNormalization) {
audioChain.push(normalizationFilter(clip.audioNormalization));
requiredFilters.add(
clip.audioNormalization.mode === 'peak' ? 'volume' : 'loudnorm'
);
}
audioChain.push(
...audioFadeFilters(
duration,
clip.audioFadeInSeconds,
clip.audioFadeOutSeconds,
clip.audioFadeCurve
).map((node) => {
requiredFilters.add('afade');
return node.expression;
})
);
graph.push(`[${index}:a:0]${audioChain.join(',')}[a${index}]`);
} else {
requiredFilters.add('anullsrc');
graph.push(
`anullsrc=r=${options.sampleRate ?? 48_000}:cl=${options.channelLayout ?? 'stereo'},atrim=duration=${normalizeSeconds(duration)},aformat=sample_fmts=fltp:channel_layouts=${options.channelLayout ?? 'stereo'}[a${index}]`
);
}
}
});
const labels = options.clips
.map((_, index) => `[v${index}]${includeAudio ? `[a${index}]` : ''}`)
.join('');
graph.push(
`${labels}concat=n=${options.clips.length}:v=1:a=${includeAudio ? 1 : 0}[vout]${includeAudio ? '[aout]' : ''}`
);
args.push('-filter_complex', graph.join(';'), '-map', '[vout]');
if (includeAudio) {
args.push('-map', '[aout]');
}
args.push(...presetEncodingArguments(options.preset));
if (!includeAudio) {
args.push('-an');
}
args.push(
'-map_metadata',
options.preset.metadataPolicy === 'remove' ? '-1' : '0',
'-map_chapters',
'-1',
'-sn'
);
const output = createPlannedOutput(options.jobId, {
baseName: options.clips[0]?.source.fileName ?? 'timeline',
operation: 'timeline',
extension: options.preset.fileExtension,
...(options.outputFileName ? { fileName: options.outputFileName } : {}),
});
args.push('-progress', 'pipe:1', '-nostats', output.path);
const presetRequirements =
'builtIn' in options.preset
? options.preset.requirements
: requirementsForUserPreset(options.preset);
return freezeCommandPlan({
id: `${options.jobId}:timeline-export`,
operation: 'timeline-export',
inputs,
temporaryFiles: [],
args,
outputs: [output],
expectedDurationSeconds: options.clips.reduce(
(sum, clip) => sum + clip.sourceOutSeconds - clip.sourceInSeconds,
0
),
requiredCapabilities: mergeRequirements(presetRequirements, {
filters: [...requiredFilters],
}),
diagnostics: [
diagnostic(
'timeline-reencode',
'info',
'Sequential timeline clips are trimmed, normalized, concatenated, and re-encoded in one command.'
),
...(options.preset.chapterPolicy !== 'remove'
? [
diagnostic(
'timeline-chapters-omitted',
'warning' as const,
'Source chapters are omitted because their times do not automatically match the edited timeline; mux validated project chapters explicitly.'
),
]
: []),
...(options.preset.subtitlePolicy !== 'none'
? [
diagnostic(
'timeline-subtitles-omitted',
'warning' as const,
'Subtitle streams are omitted from this filtered timeline plan; use an explicit compatible mux or burn-in plan.'
),
]
: []),
...(mixedAudio && options.missingAudioPolicy === 'insert-silence'
? [
diagnostic(
'inserted-silence',
'warning' as const,
'Silence is inserted for timeline clips without audio.'
),
]
: []),
],
});
}
function normalizationFilter(
normalization: TimelineAudioNormalization
): string {
if (normalization.mode === 'peak') {
return buildPeakNormalizationFilter(
normalization.targetPeakDb,
normalization.measurement
);
}
validateLoudnessMeasurement(normalization.measurement);
const { targets, measurement } = normalization;
if (
!Number.isFinite(targets.integratedLufs) ||
targets.integratedLufs < -70 ||
targets.integratedLufs > -5 ||
!Number.isFinite(targets.truePeakDbtp) ||
targets.truePeakDbtp < -9 ||
targets.truePeakDbtp > 0 ||
!Number.isFinite(targets.loudnessRangeLu) ||
targets.loudnessRangeLu < 1 ||
targets.loudnessRangeLu > 50
) {
throw new RangeError('Loudness targets are outside supported ranges');
}
return [
`loudnorm=I=${signed(targets.integratedLufs)}`,
`TP=${signed(targets.truePeakDbtp)}`,
`LRA=${normalizeSeconds(targets.loudnessRangeLu)}`,
`measured_I=${signed(measurement.inputIntegratedLufs)}`,
`measured_TP=${signed(measurement.inputTruePeakDbtp)}`,
`measured_LRA=${normalizeSeconds(measurement.inputLoudnessRangeLu)}`,
`measured_thresh=${signed(measurement.inputThresholdLufs)}`,
`offset=${signed(measurement.targetOffsetLu ?? 0)}`,
'linear=true',
].join(':');
}
function validateOutput(
width: number,
height: number,
frameRate: number
): void {
if (
!Number.isSafeInteger(width) ||
!Number.isSafeInteger(height) ||
width < 2 ||
height < 2 ||
width > 8192 ||
height > 8192 ||
width % 2 !== 0 ||
height % 2 !== 0
) {
throw new RangeError(
'Timeline dimensions must be even integers from 2 to 8192'
);
}
if (!Number.isFinite(frameRate) || frameRate <= 0 || frameRate > 240) {
throw new RangeError('Timeline frame rate must be between 0 and 240');
}
}
function signed(value: number): string {
if (!Number.isFinite(value)) {
throw new RangeError('Filter value must be finite');
}
return value.toFixed(6).replace(/0+$/u, '').replace(/\.$/u, '');
}
function validateFadePair(
durationSeconds: number,
fadeInSeconds: number | undefined,
fadeOutSeconds: number | undefined,
label: string
): void {
const fadeIn = fadeInSeconds ?? 0;
const fadeOut = fadeOutSeconds ?? 0;
if (
!Number.isFinite(fadeIn) ||
!Number.isFinite(fadeOut) ||
fadeIn < 0 ||
fadeOut < 0 ||
fadeIn + fadeOut > durationSeconds
) {
throw new RangeError(
`${label} fade durations must be nonnegative and cannot overlap`
);
}
}

290
src/commands/transform.ts Normal file
View File

@@ -0,0 +1,290 @@
import {
createVideoFiltergraph,
type FilterNode,
type VideoFilterStage,
} from './filtergraph-builder';
import type { ExportPreset } from './convert';
import { buildConvertPlan } from './convert';
import type { SourceReference } from './command-utils';
import type { FFmpegCommandPlan } from './command-plan';
export interface CropSettings {
readonly x: number;
readonly y: number;
readonly width: number;
readonly height: number;
readonly sourceWidth: number;
readonly sourceHeight: number;
readonly requireEvenDimensions?: boolean;
}
export type ResizeMode = 'fit' | 'contain' | 'cover' | 'stretch';
export interface ResizeSettings {
readonly sourceWidth: number;
readonly sourceHeight: number;
readonly width?: number;
readonly height?: number;
readonly mode: ResizeMode;
readonly noUpscaling?: boolean;
readonly algorithm?: 'fast_bilinear' | 'bilinear' | 'bicubic' | 'lanczos';
readonly paddingColor?: string;
}
export interface TransformSettings {
readonly crop?: CropSettings;
readonly resize?: ResizeSettings;
readonly rotation?: 0 | 90 | 180 | 270;
readonly frameRate?: number;
readonly pixelFormat?: 'yuv420p' | 'yuv422p' | 'yuv444p' | 'rgba';
readonly subtitleFilterExpression?: string;
readonly fadeNodes?: readonly FilterNode<VideoFilterStage>[];
}
export interface Dimensions {
readonly width: number;
readonly height: number;
}
export interface TransformPlanOptions {
readonly jobId: string;
readonly source: SourceReference;
readonly preset: ExportPreset;
readonly settings: TransformSettings;
readonly expectedDurationSeconds?: number;
}
export function buildTransformPlan(
options: TransformPlanOptions
): FFmpegCommandPlan {
const filtergraph = buildTransformFiltergraph(options.settings);
if (!filtergraph.graph) {
throw new TypeError(
'At least one crop, resize, rotation, frame-rate, or format transform is required'
);
}
return buildConvertPlan({
jobId: options.jobId,
source: options.source,
preset: options.preset,
operation: 'transform',
videoFiltergraph: filtergraph.graph,
additionalRequirements: filtergraph.requirements,
...(options.expectedDurationSeconds !== undefined
? { expectedDurationSeconds: options.expectedDurationSeconds }
: {}),
});
}
export function validateCrop(settings: CropSettings): CropSettings {
const fields = [
settings.x,
settings.y,
settings.width,
settings.height,
settings.sourceWidth,
settings.sourceHeight,
];
if (fields.some((value) => !Number.isSafeInteger(value))) {
throw new RangeError('Crop coordinates and dimensions must be integers');
}
if (
settings.x < 0 ||
settings.y < 0 ||
settings.width <= 0 ||
settings.height <= 0 ||
settings.x + settings.width > settings.sourceWidth ||
settings.y + settings.height > settings.sourceHeight
) {
throw new RangeError('Crop rectangle must be inside the source dimensions');
}
if (!settings.requireEvenDimensions) {
return Object.freeze({ ...settings });
}
const width = settings.width - (settings.width % 2);
const height = settings.height - (settings.height % 2);
const x = settings.x - (settings.x % 2);
const y = settings.y - (settings.y % 2);
if (width < 2 || height < 2) {
throw new RangeError('Even crop dimensions must be at least 2 × 2');
}
return Object.freeze({ ...settings, x, y, width, height });
}
export function calculateResize(settings: ResizeSettings): Dimensions {
const { sourceWidth, sourceHeight } = settings;
if (
!Number.isFinite(sourceWidth) ||
!Number.isFinite(sourceHeight) ||
sourceWidth <= 0 ||
sourceHeight <= 0
) {
throw new RangeError('Source dimensions must be positive');
}
if (settings.width === undefined && settings.height === undefined) {
throw new RangeError('At least one output dimension is required');
}
for (const dimension of [settings.width, settings.height]) {
if (
dimension !== undefined &&
(!Number.isSafeInteger(dimension) || dimension < 1 || dimension > 8192)
) {
throw new RangeError(
'Requested output dimensions must be integers from 1 to 8192'
);
}
}
const requestedWidth =
settings.width ??
(sourceWidth * (settings.height as number)) / sourceHeight;
const requestedHeight =
settings.height ??
(sourceHeight * (settings.width as number)) / sourceWidth;
if (
!Number.isFinite(requestedWidth) ||
!Number.isFinite(requestedHeight) ||
requestedWidth < 1 ||
requestedHeight < 1 ||
requestedWidth > 8192 ||
requestedHeight > 8192
) {
throw new RangeError('Output dimensions must be between 1 and 8192');
}
if (settings.mode === 'stretch') {
return {
width: even(requestedWidth),
height: even(requestedHeight),
};
}
const scale =
settings.mode === 'cover'
? Math.max(requestedWidth / sourceWidth, requestedHeight / sourceHeight)
: Math.min(requestedWidth / sourceWidth, requestedHeight / sourceHeight);
const boundedScale = settings.noUpscaling ? Math.min(1, scale) : scale;
if (
settings.mode === 'cover' &&
settings.noUpscaling &&
boundedScale < scale
) {
throw new RangeError(
'Cover resize cannot fill the requested dimensions without upscaling'
);
}
return {
width: even(sourceWidth * boundedScale),
height: even(sourceHeight * boundedScale),
};
}
export function buildTransformFiltergraph(settings: TransformSettings): {
readonly graph?: string;
readonly requirements: ReturnType<
ReturnType<typeof createVideoFiltergraph>['build']
>['requirements'];
} {
const builder = createVideoFiltergraph();
if (settings.crop) {
const crop = validateCrop(settings.crop);
builder.add({
id: 'crop',
stage: 'crop',
expression: `crop=${crop.width}:${crop.height}:${crop.x}:${crop.y}`,
requiredCapabilities: { filters: ['crop'] },
});
}
if (settings.rotation) {
const expression =
settings.rotation === 90
? 'transpose=clock'
: settings.rotation === 270
? 'transpose=cclock'
: 'hflip,vflip';
builder.add({
id: 'rotation',
stage: 'rotate',
expression,
requiredCapabilities: {
filters: settings.rotation === 180 ? ['hflip', 'vflip'] : ['transpose'],
},
});
}
if (settings.resize) {
if (
settings.resize.paddingColor !== undefined &&
!/^(?:#[0-9a-f]{6}|[a-z]{3,20})$/iu.test(settings.resize.paddingColor)
) {
throw new TypeError(
'Resize padding colour must be a named colour or #RRGGBB'
);
}
const size = calculateResize(settings.resize);
const flags = settings.resize.algorithm ?? 'lanczos';
let expression = `scale=${size.width}:${size.height}:flags=${flags}`;
if (
settings.resize.mode === 'contain' &&
settings.resize.width &&
settings.resize.height
) {
expression += `,pad=${settings.resize.width}:${settings.resize.height}:(ow-iw)/2:(oh-ih)/2:${settings.resize.paddingColor ?? 'black'}`;
} else if (
settings.resize.mode === 'cover' &&
settings.resize.width &&
settings.resize.height
) {
expression += `,crop=${settings.resize.width}:${settings.resize.height}`;
}
builder.add({
id: 'resize',
stage: 'resize',
expression,
requiredCapabilities: {
filters:
settings.resize.mode === 'contain'
? ['scale', 'pad']
: settings.resize.mode === 'cover'
? ['scale', 'crop']
: ['scale'],
},
});
}
if (settings.frameRate !== undefined) {
if (
!Number.isFinite(settings.frameRate) ||
settings.frameRate <= 0 ||
settings.frameRate > 240
) {
throw new RangeError('Frame rate must be between 0 and 240');
}
builder.add({
id: 'frame-rate',
stage: 'frame-rate',
expression: `fps=${settings.frameRate}`,
requiredCapabilities: { filters: ['fps'] },
});
}
if (settings.pixelFormat) {
builder.add({
id: 'pixel-format',
stage: 'format',
expression: `format=${settings.pixelFormat}`,
requiredCapabilities: { filters: ['format'] },
});
}
if (settings.subtitleFilterExpression) {
builder.add({
id: 'subtitles',
stage: 'subtitles',
expression: settings.subtitleFilterExpression,
requiredCapabilities: { filters: ['subtitles'] },
});
}
for (const node of settings.fadeNodes ?? []) {
builder.add(node);
}
return builder.build();
}
function even(value: number): number {
const rounded = Math.max(2, Math.round(value));
return rounded - (rounded % 2);
}

111
src/commands/trim.ts Normal file
View File

@@ -0,0 +1,111 @@
import type { ExportPreset } from './convert';
import { buildConvertPlan } from './convert';
import {
createPlannedInput,
createPlannedOutput,
diagnostic,
normalizeSeconds,
streamMapArguments,
validateRange,
type SourceReference,
type StreamSelection,
} from './command-utils';
import { freezeCommandPlan, type FFmpegCommandPlan } from './command-plan';
export interface TrimOptions {
readonly jobId: string;
readonly source: SourceReference;
readonly mode: 'fast' | 'accurate';
readonly startSeconds: number;
readonly endSeconds: number;
readonly sourceDurationSeconds?: number;
readonly streamSelection?: StreamSelection;
readonly preset?: ExportPreset;
readonly targetExtension?: string;
}
export function buildTrimPlan(options: TrimOptions): FFmpegCommandPlan {
validateRange(
options.startSeconds,
options.endSeconds,
options.sourceDurationSeconds
);
const expectedDurationSeconds = options.endSeconds - options.startSeconds;
if (options.mode === 'accurate') {
if (!options.preset) {
throw new TypeError('Accurate trim requires an export preset');
}
const base = buildConvertPlan({
jobId: options.jobId,
source: options.source,
preset: options.preset,
...(options.streamSelection
? { streamSelection: options.streamSelection }
: {}),
expectedDurationSeconds,
operation: 'trim',
});
const inputIndex = base.args.indexOf('-i');
const args = [...base.args];
args.splice(
inputIndex,
0,
'-ss',
normalizeSeconds(options.startSeconds),
'-to',
normalizeSeconds(options.endSeconds)
);
return freezeCommandPlan({
...base,
args,
diagnostics: [
diagnostic(
'accurate-trim',
'info',
'Accurate trim decodes and re-encodes selected streams at the requested boundaries.'
),
],
});
}
if (!options.targetExtension) {
throw new TypeError('Fast trim requires a targetExtension');
}
const input = createPlannedInput(options.jobId, options.source);
const output = createPlannedOutput(options.jobId, {
baseName: options.source.fileName,
operation: 'trim',
extension: options.targetExtension,
});
const args: string[] = [
'-ss',
normalizeSeconds(options.startSeconds),
'-to',
normalizeSeconds(options.endSeconds),
'-i',
input.path,
];
if (options.streamSelection) {
args.push(...streamMapArguments(options.streamSelection));
} else {
args.push('-map', '0');
}
args.push('-c', 'copy', '-progress', 'pipe:1', '-nostats', output.path);
return freezeCommandPlan({
id: `${options.jobId}:trim`,
operation: 'trim-fast',
inputs: [input],
temporaryFiles: [],
args,
outputs: [output],
expectedDurationSeconds,
requiredCapabilities: {},
diagnostics: [
diagnostic(
'keyframe-boundary',
'warning',
'Fast trim uses stream copy and can begin at a nearby keyframe rather than the exact requested frame.'
),
],
});
}

165
src/commands/waveform.ts Normal file
View File

@@ -0,0 +1,165 @@
import {
createPlannedInput,
createPlannedOutput,
diagnostic,
type SourceReference,
} from './command-utils';
import { freezeCommandPlan, type FFmpegCommandPlan } from './command-plan';
export interface WaveformOptions {
readonly jobId: string;
readonly source: SourceReference;
/** Absolute ffprobe stream index. */
readonly audioStreamIndex: number;
readonly sampleRate?: number;
readonly expectedDurationSeconds?: number;
}
export interface StaticWaveformOptions {
readonly jobId: string;
readonly source: SourceReference;
/** Absolute ffprobe stream index. */
readonly audioStreamIndex: number;
readonly width?: number;
readonly height?: number;
readonly color?: string;
readonly background?: string;
readonly expectedDurationSeconds?: number;
}
export function buildStaticWaveformPlan(
options: StaticWaveformOptions
): FFmpegCommandPlan {
const width = options.width ?? 1200;
const height = options.height ?? 240;
if (
!Number.isSafeInteger(width) ||
!Number.isSafeInteger(height) ||
width < 64 ||
height < 32 ||
width > 8192 ||
height > 8192
) {
throw new RangeError(
'Static waveform dimensions must be integers from 64×32 through 8192×8192'
);
}
if (
!Number.isSafeInteger(options.audioStreamIndex) ||
options.audioStreamIndex < 0
) {
throw new RangeError('Invalid audio stream index');
}
const color = options.color ?? '#62d6c8';
const background = options.background ?? '#101b22';
if (
!/^(?:#[0-9a-f]{6}|[a-z]{3,20})$/iu.test(color) ||
!/^(?:#[0-9a-f]{6}|[a-z]{3,20})$/iu.test(background)
) {
throw new TypeError('Waveform colours must be named colours or #RRGGBB');
}
const input = createPlannedInput(options.jobId, options.source);
const output = createPlannedOutput(options.jobId, {
baseName: options.source.fileName,
operation: 'waveform',
suffix: 'static',
extension: 'png',
role: 'analysis',
mediaType: 'image/png',
});
return freezeCommandPlan({
id: `${options.jobId}:static-waveform`,
operation: 'waveform-static',
inputs: [input],
temporaryFiles: [],
args: [
'-i',
input.path,
'-filter_complex',
`color=c=${background}:s=${width}x${height}:d=1[background];[0:${options.audioStreamIndex}]aformat=channel_layouts=mono,showwavespic=s=${width}x${height}:colors=${color}[foreground];[background][foreground]overlay=shortest=1[waveform]`,
'-map',
'[waveform]',
'-frames:v',
'1',
output.path,
],
outputs: [output],
...(options.expectedDurationSeconds !== undefined
? { expectedDurationSeconds: options.expectedDurationSeconds }
: {}),
requiredCapabilities: {
filters: ['color', 'aformat', 'showwavespic', 'overlay'],
encoders: ['png'],
muxers: ['image2'],
},
diagnostics: [
diagnostic(
'static-waveform',
'info',
'A bounded PNG waveform is generated from the selected local audio stream.'
),
],
});
}
export function buildWaveformAnalysisPlan(
options: WaveformOptions
): FFmpegCommandPlan {
const sampleRate = options.sampleRate ?? 8_000;
if (
!Number.isSafeInteger(sampleRate) ||
sampleRate < 1_000 ||
sampleRate > 48_000
) {
throw new RangeError(
'Waveform sample rate must be between 1000 and 48000 Hz'
);
}
const streamIndex = options.audioStreamIndex;
if (!Number.isSafeInteger(streamIndex) || streamIndex < 0) {
throw new RangeError('Invalid audio stream index');
}
const input = createPlannedInput(options.jobId, options.source);
const output = createPlannedOutput(options.jobId, {
baseName: options.source.fileName,
operation: 'waveform',
extension: 'wav',
role: 'analysis',
mediaType: 'audio/wav',
});
return freezeCommandPlan({
id: `${options.jobId}:waveform`,
operation: 'waveform-analysis',
inputs: [input],
temporaryFiles: [],
args: [
'-i',
input.path,
'-map',
`0:${streamIndex}`,
'-vn',
'-ac',
'1',
'-ar',
String(sampleRate),
'-c:a',
'pcm_s16le',
'-progress',
'pipe:1',
'-nostats',
output.path,
],
outputs: [output],
...(options.expectedDurationSeconds !== undefined
? { expectedDurationSeconds: options.expectedDurationSeconds }
: {}),
requiredCapabilities: { encoders: ['pcm_s16le'], muxers: ['wav'] },
diagnostics: [
diagnostic(
'reduced-analysis-audio',
'info',
'Waveform analysis uses a reduced-rate mono PCM derivative; source media is unchanged.'
),
],
});
}

View File

@@ -0,0 +1,852 @@
.advanced-operations {
min-width: 0;
overflow: hidden;
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) + 0.25rem);
background: var(--toolbox-surface);
color: var(--toolbox-text);
box-shadow: 0 8px 25px rgb(24 34 68 / 5%);
}
.advanced-operations *,
.advanced-operations *::before,
.advanced-operations *::after {
box-sizing: border-box;
}
.advanced-operations h2,
.advanced-operations h3,
.advanced-operations h4,
.advanced-operations p {
margin-block-start: 0;
}
.advanced-operations__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1.5rem;
padding: 1.1rem 1.2rem;
border-bottom: 1px solid var(--toolbox-border);
}
.advanced-operations__header h2 {
margin-block-end: 0.28rem;
font-size: 1.05rem;
}
.advanced-operations__header p:not(.advanced-operations__eyebrow) {
max-width: 50rem;
margin-block-end: 0;
color: var(--toolbox-muted);
font-size: 0.78rem;
line-height: 1.55;
}
.advanced-operations__eyebrow {
margin-block-end: 0.25rem;
color: var(--toolbox-muted);
font-size: 0.65rem;
font-weight: 800;
letter-spacing: 0.11em;
text-transform: uppercase;
}
.advanced-operations__local {
flex: none;
padding: 0.45rem 0.65rem;
border: 1px solid
color-mix(
in srgb,
var(--toolbox-success, #2f8f5b) 34%,
var(--toolbox-border)
);
border-radius: 999px;
background: color-mix(
in srgb,
var(--toolbox-success, #2f8f5b) 8%,
var(--toolbox-surface)
);
color: var(--toolbox-success, #2f8f5b);
font-size: 0.68rem;
font-weight: 800;
}
.advanced-operations__tabs {
display: flex;
gap: 0.25rem;
padding: 0.45rem;
overflow-x: auto;
border-bottom: 1px solid var(--toolbox-border);
background: var(--toolbox-surface-soft);
scrollbar-width: thin;
}
.advanced-operations__tabs button {
min-height: 2.35rem;
flex: 1 0 auto;
padding: 0.45rem 0.7rem;
border: 1px solid transparent;
border-radius: calc(var(--toolbox-radius) - 0.12rem);
background: transparent;
color: var(--toolbox-muted);
cursor: pointer;
font-size: 0.7rem;
font-weight: 760;
white-space: nowrap;
}
.advanced-operations__tabs button[aria-selected='true'] {
border-color: var(--toolbox-border);
background: var(--toolbox-surface);
color: var(--toolbox-accent);
box-shadow: 0 2px 8px rgb(24 34 68 / 8%);
}
.advanced-operations__tab-short {
display: none;
}
.advanced-operations__panel {
padding: 1rem;
}
.advanced-operations__panel[hidden] {
display: none;
}
.advanced-operations__section-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: start;
gap: 1rem;
}
.advanced-operations__stack {
display: grid;
gap: 1rem;
}
.advanced-operation-card {
min-width: 0;
padding: 1rem;
border: 1px solid var(--toolbox-border);
border-radius: var(--toolbox-radius);
background: color-mix(
in srgb,
var(--toolbox-surface) 94%,
var(--toolbox-background)
);
}
.advanced-operation-card--wide {
grid-column: 1 / -1;
}
.advanced-operation-card > header {
display: flex;
align-items: flex-start;
gap: 0.7rem;
margin-block-end: 1rem;
}
.advanced-operation-card > header h3 {
margin: 0 0 0.2rem;
font-size: 0.87rem;
}
.advanced-operation-card > header p {
max-width: 48rem;
margin-block-end: 0;
color: var(--toolbox-muted);
font-size: 0.72rem;
line-height: 1.45;
}
.advanced-operation-card > h4 {
margin: 1.2rem 0 0.65rem;
color: var(--toolbox-muted);
font-size: 0.7rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.advanced-operation-card__step {
width: 1.8rem;
height: 1.8rem;
display: inline-grid;
flex: none;
place-items: center;
border-radius: 0.55rem;
background: var(--toolbox-accent-soft);
color: var(--toolbox-accent);
font-size: 0.72rem;
font-weight: 850;
}
.advanced-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
margin-block-end: 0.85rem;
}
.advanced-form-grid--three {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.advanced-form-grid--four {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.advanced-field,
.advanced-file-field {
min-width: 0;
display: grid;
align-content: start;
gap: 0.34rem;
margin-block-end: 0.85rem;
color: var(--toolbox-text);
font-size: 0.7rem;
font-weight: 740;
}
.advanced-field > span:first-child,
.advanced-file-field > span:first-child {
min-height: 1rem;
}
.advanced-field > span small {
color: var(--toolbox-muted);
font-weight: 650;
}
.advanced-field input,
.advanced-field select,
.advanced-file-field input {
width: 100%;
min-height: 2.5rem;
padding: 0.48rem 0.62rem;
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) - 0.14rem);
background: var(--toolbox-surface);
color: var(--toolbox-text);
font: inherit;
font-weight: 620;
}
.advanced-field input:focus-visible,
.advanced-field select:focus-visible,
.advanced-file-field input:focus-visible,
.advanced-operations button:focus-visible {
outline: 3px solid var(--toolbox-focus);
outline-offset: 2px;
}
.advanced-field > small,
.advanced-file-field > small {
color: var(--toolbox-muted);
font-size: 0.64rem;
font-weight: 580;
line-height: 1.4;
}
.advanced-field--color > span:last-child {
min-height: 2.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.32rem 0.5rem;
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) - 0.14rem);
background: var(--toolbox-surface);
}
.advanced-field--color input {
width: 2rem;
min-height: 1.75rem;
padding: 0;
border: 0;
cursor: pointer;
}
.advanced-field--color code {
color: var(--toolbox-muted);
font-size: 0.68rem;
}
.advanced-inline-fields {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
gap: 0.6rem;
}
.advanced-inline-fields .advanced-button {
margin-block-end: 0.85rem;
}
.advanced-choice-cards {
display: grid;
gap: 0.55rem;
margin: 0 0 0.85rem;
padding: 0;
border: 0;
}
.advanced-choice-cards label {
display: flex;
align-items: flex-start;
gap: 0.6rem;
padding: 0.72rem;
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) - 0.12rem);
background: var(--toolbox-surface);
cursor: pointer;
}
.advanced-choice-cards label:has(input:checked) {
border-color: color-mix(
in srgb,
var(--toolbox-accent) 48%,
var(--toolbox-border)
);
background: var(--toolbox-accent-soft);
}
.advanced-choice-cards input {
margin-block-start: 0.15rem;
accent-color: var(--toolbox-accent);
}
.advanced-choice-cards span,
.advanced-choice-cards strong,
.advanced-choice-cards small {
display: block;
}
.advanced-choice-cards strong {
margin-block-end: 0.18rem;
font-size: 0.74rem;
}
.advanced-choice-cards small {
color: var(--toolbox-muted);
font-size: 0.65rem;
line-height: 1.4;
}
.advanced-check-grid {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
margin-block-end: 0.9rem;
}
.advanced-check {
display: inline-flex;
align-items: center;
gap: 0.45rem;
min-height: 2.2rem;
padding: 0.4rem 0.6rem;
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) - 0.14rem);
background: var(--toolbox-surface);
cursor: pointer;
font-size: 0.68rem;
font-weight: 680;
}
.advanced-check input {
accent-color: var(--toolbox-accent);
}
.advanced-stream-metadata {
display: grid;
gap: 0.7rem;
margin-block: 0.85rem;
}
.advanced-stream-metadata fieldset {
min-width: 0;
margin: 0;
padding: 0.75rem;
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) - 0.08rem);
background: var(--toolbox-surface-soft);
}
.advanced-stream-metadata legend {
padding-inline: 0.35rem;
color: var(--toolbox-muted);
font-size: 0.65rem;
font-weight: 780;
}
.advanced-disposition-details {
border-block-start: 1px solid var(--toolbox-border);
padding-block-start: 0.55rem;
}
.advanced-disposition-details summary {
cursor: pointer;
color: var(--toolbox-muted);
font-size: 0.65rem;
font-weight: 720;
}
.advanced-disposition-details .advanced-check-grid {
margin-block: 0.65rem 0;
}
.advanced-button,
.advanced-file-button {
min-height: 2.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.58rem 0.82rem;
border: 1px solid transparent;
border-radius: var(--toolbox-radius);
cursor: pointer;
font: inherit;
font-size: 0.72rem;
font-weight: 780;
line-height: 1.1;
text-align: center;
}
.advanced-button--primary {
border-color: var(--toolbox-accent);
background: var(--toolbox-accent);
color: var(--toolbox-accent-contrast);
}
.advanced-button--primary:hover:not(:disabled) {
border-color: var(--toolbox-accent-hover);
background: var(--toolbox-accent-hover);
}
.advanced-button--secondary,
.advanced-file-button {
border-color: var(--toolbox-border);
background: var(--toolbox-surface);
color: var(--toolbox-accent);
}
.advanced-button--secondary:hover:not(:disabled),
.advanced-file-button:hover {
border-color: color-mix(
in srgb,
var(--toolbox-accent) 35%,
var(--toolbox-border)
);
background: var(--toolbox-accent-soft);
}
.advanced-button--danger {
border-color: color-mix(
in srgb,
var(--toolbox-danger) 35%,
var(--toolbox-border)
);
background: color-mix(
in srgb,
var(--toolbox-danger) 8%,
var(--toolbox-surface)
);
color: var(--toolbox-danger);
}
.advanced-button:disabled,
.advanced-file-button--disabled {
cursor: not-allowed;
opacity: 0.52;
}
.advanced-file-button {
position: relative;
overflow: hidden;
}
.advanced-file-button input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
}
.advanced-action {
min-width: min(100%, 13rem);
display: grid;
align-content: start;
gap: 0.28rem;
}
.advanced-action .advanced-button {
width: 100%;
}
.advanced-action__reason {
max-width: 22rem;
color: var(--toolbox-muted);
font-size: 0.61rem;
line-height: 1.35;
}
.advanced-action-row {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 0.6rem;
margin-block-start: 0.85rem;
}
.advanced-divider {
height: 1px;
margin: 1rem 0;
background: var(--toolbox-border);
}
.advanced-chip-list {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin: 0 0 0.85rem;
padding: 0;
list-style: none;
}
.advanced-chip-list li {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.35rem 0.25rem 0.55rem;
border: 1px solid var(--toolbox-border);
border-radius: 999px;
background: var(--toolbox-surface-soft);
font-size: 0.67rem;
font-weight: 700;
}
.advanced-chip-list button {
width: 1.35rem;
height: 1.35rem;
padding: 0;
border: 0;
border-radius: 999px;
background: transparent;
color: var(--toolbox-muted);
cursor: pointer;
}
.advanced-chip-list button:hover {
background: var(--toolbox-surface);
color: var(--toolbox-danger);
}
.advanced-segment-list {
display: grid;
gap: 0.45rem;
margin-block-end: 0.85rem;
}
.advanced-segment-list > strong {
font-size: 0.67rem;
}
.advanced-segment-list ol,
.advanced-segment-status {
max-height: 16rem;
display: grid;
gap: 0.3rem;
margin: 0;
padding: 0;
overflow: auto;
list-style: none;
scrollbar-width: thin;
}
.advanced-segment-list li,
.advanced-segment-status li {
min-width: 0;
display: grid;
grid-template-columns: minmax(7rem, 0.8fr) minmax(9rem, 1fr) minmax(
7rem,
0.8fr
);
gap: 0.55rem;
align-items: center;
padding: 0.48rem 0.58rem;
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) - 0.18rem);
background: var(--toolbox-surface-soft);
font-size: 0.63rem;
font-variant-numeric: tabular-nums;
}
.advanced-segment-list li span,
.advanced-segment-list li small,
.advanced-segment-status li span,
.advanced-segment-status li small {
color: var(--toolbox-muted);
}
.advanced-segment-status {
max-height: 20rem;
}
.advanced-segment-status li {
grid-template-columns: minmax(10rem, 1fr) auto;
}
.advanced-segment-status li > div {
min-width: 0;
display: grid;
gap: 0.12rem;
}
.advanced-segment-status li > div strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.advanced-segment-status li > b {
padding: 0.18rem 0.38rem;
border-radius: 999px;
background: var(--toolbox-accent-soft);
color: var(--toolbox-accent);
font-size: 0.56rem;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.advanced-segment-status li > small {
grid-column: 1 / -1;
color: var(--toolbox-warning, #a96700);
line-height: 1.4;
}
.advanced-segment-status--failed,
.advanced-segment-status--cancelled {
border-color: color-mix(
in srgb,
var(--toolbox-danger) 35%,
var(--toolbox-border)
) !important;
}
.advanced-summary,
.advanced-hint,
.advanced-empty,
.advanced-warning {
margin-block-end: 0.8rem;
padding: 0.58rem 0.68rem;
border-radius: calc(var(--toolbox-radius) - 0.15rem);
color: var(--toolbox-muted);
font-size: 0.67rem;
line-height: 1.45;
}
.advanced-summary,
.advanced-hint,
.advanced-empty {
background: var(--toolbox-surface-soft);
}
.advanced-warning {
border: 1px solid
color-mix(
in srgb,
var(--toolbox-warning, #a96700) 28%,
var(--toolbox-border)
);
background: color-mix(
in srgb,
var(--toolbox-warning, #a96700) 8%,
var(--toolbox-surface)
);
color: color-mix(
in srgb,
var(--toolbox-warning, #a96700) 72%,
var(--toolbox-text)
);
}
.advanced-measurement {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.55rem;
margin: 0.8rem 0;
}
.advanced-measurement div {
min-width: 0;
padding: 0.6rem;
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) - 0.14rem);
background: var(--toolbox-surface-soft);
}
.advanced-measurement dt {
margin-block-end: 0.22rem;
color: var(--toolbox-muted);
font-size: 0.62rem;
}
.advanced-measurement dd {
margin: 0;
font-size: 0.74rem;
font-weight: 780;
}
.advanced-stream-list {
display: grid;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
}
.advanced-stream-list li {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.2rem 0.55rem;
padding: 0.65rem;
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) - 0.14rem);
background: var(--toolbox-surface);
font-size: 0.68rem;
}
.advanced-stream-list li > span {
padding: 0.18rem 0.38rem;
border-radius: 999px;
background: var(--toolbox-surface-soft);
color: var(--toolbox-muted);
font-size: 0.58rem;
font-weight: 800;
text-transform: uppercase;
}
.advanced-stream-list small {
grid-column: 1 / -1;
color: var(--toolbox-muted);
}
.advanced-preset-toolbar {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: 0.6rem;
}
.advanced-preset-toolbar .advanced-field {
min-width: min(100%, 18rem);
flex: 1;
margin-block-end: 0;
}
.advanced-preset-toolbar .advanced-action {
min-width: auto;
}
.advanced-operations__status {
min-height: 1.25rem;
padding: 0 1rem 0.7rem;
color: var(--toolbox-muted);
font-size: 0.68rem;
}
.advanced-visually-hidden {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
@media (max-width: 64rem) {
.advanced-operations__section-grid {
grid-template-columns: 1fr;
}
.advanced-form-grid--four {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.advanced-operations__tab-long {
display: none;
}
.advanced-operations__tab-short {
display: inline;
}
}
@media (max-width: 40rem) {
.advanced-operations__header {
display: grid;
gap: 0.75rem;
}
.advanced-operations__local {
justify-self: start;
}
.advanced-operations__panel {
padding: 0.7rem;
}
.advanced-operation-card {
padding: 0.8rem;
}
.advanced-form-grid,
.advanced-form-grid--three,
.advanced-form-grid--four,
.advanced-inline-fields,
.advanced-measurement {
grid-template-columns: 1fr;
}
.advanced-inline-fields .advanced-button {
margin-block: -0.35rem 0.85rem;
}
.advanced-action,
.advanced-action .advanced-button,
.advanced-action-row > .advanced-button,
.advanced-action-row > .advanced-file-button {
width: 100%;
}
.advanced-segment-list li,
.advanced-segment-status li {
grid-template-columns: 1fr;
}
.advanced-segment-status li > b {
justify-self: start;
}
.advanced-segment-status li > small {
grid-column: auto;
}
}
@media (prefers-reduced-motion: reduce) {
.advanced-operations *,
.advanced-operations *::before,
.advanced-operations *::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,327 @@
import { FFMPEG_CORE_VERSION, FFMPEG_WRAPPER_VERSION } from '../version';
import type { EnginePreference, EngineState } from '../ffmpeg/ffmpeg.types';
import {
describeEngineMode,
detectEngineEnvironment,
} from '../ffmpeg/engine-mode';
import type { ResourcePolicy } from '../storage';
import { Icon } from './Icon';
export interface CapabilityPanelProps {
state: EngineState;
preference: EnginePreference;
resourcePolicy: ResourcePolicy;
deviceMemoryGiB?: number;
onPreferenceChange: (preference: EnginePreference) => void;
onInitialize: () => void;
}
export function CapabilityPanel({
state,
preference,
resourcePolicy,
deviceMemoryGiB,
onPreferenceChange,
onInitialize,
}: CapabilityPanelProps) {
const environment = detectEngineEnvironment();
const mode =
state.status === 'ready' ||
state.status === 'loading' ||
state.status === 'running'
? state.mode
: undefined;
return (
<details className="capability-panel">
<summary>
<span>
<Icon name="settings" />
Engine & browser
</span>
<span className={`engine-pill engine-pill--${state.status}`}>
{state.status === 'idle'
? 'Not loaded'
: state.status === 'loading'
? 'Loading'
: state.status === 'ready'
? describeEngineMode(state.mode)
: state.status === 'running'
? 'Processing'
: state.status === 'recovering'
? 'Recovering'
: 'Unavailable'}
</span>
</summary>
<div className="capability-panel__body">
<label>
Engine mode
<select
value={preference}
disabled={state.status === 'running'}
onChange={(event) =>
onPreferenceChange(event.currentTarget.value as EnginePreference)
}
>
<option value="automatic">Automatic</option>
<option value="prefer-multithread">Prefer multithread</option>
<option value="force-single-thread">Force single-thread</option>
</select>
</label>
<dl className="capability-grid">
<div>
<dt>Selected</dt>
<dd>{mode ? describeEngineMode(mode) : 'After first import'}</dd>
</div>
<div>
<dt>Isolation</dt>
<dd>
{environment.crossOriginIsolated ? 'Enabled' : 'Unavailable'}
</dd>
</div>
<div>
<dt>Processors</dt>
<dd>{environment.hardwareConcurrency ?? 'Not exposed'}</dd>
</div>
<div>
<dt>Wrapper / core</dt>
<dd>
{FFMPEG_WRAPPER_VERSION} / {FFMPEG_CORE_VERSION}
</dd>
</div>
{state.status === 'ready' ? (
<>
<div>
<dt>FFmpeg</dt>
<dd title={state.capabilities.versionText}>
{versionLine(state.capabilities.versionText)}
</dd>
</div>
<div>
<dt>Demuxers</dt>
<dd>{state.capabilities.demuxers.size}</dd>
</div>
<div>
<dt>Muxers</dt>
<dd>{state.capabilities.muxers.size}</dd>
</div>
<div>
<dt>Decoders</dt>
<dd>{state.capabilities.decoders.size}</dd>
</div>
<div>
<dt>Encoders</dt>
<dd>{state.capabilities.encoders.size}</dd>
</div>
<div>
<dt>Filters</dt>
<dd>{state.capabilities.filters.size}</dd>
</div>
</>
) : null}
</dl>
<section
className="capability-limits"
aria-labelledby="active-resource-limits"
>
<div className="capability-limits__heading">
<h3 id="active-resource-limits">Active resource limits</h3>
<span>
{deviceMemoryGiB === undefined
? 'Conservative defaults'
: `Adjusted for ${deviceMemoryGiB} GiB device memory`}
</span>
</div>
<dl className="capability-grid capability-grid--limits">
<div>
<dt>Input warning / file limit</dt>
<dd>
{formatBytes(resourcePolicy.softInputBytes)} /{' '}
{formatBytes(resourcePolicy.hardSingleInputBytes)}
</dd>
</div>
<div>
<dt>Total input limit</dt>
<dd>{formatBytes(resourcePolicy.hardTotalInputBytes)}</dd>
</div>
<div>
<dt>Output warning / limit</dt>
<dd>
{formatBytes(resourcePolicy.softOutputEstimateBytes)} /{' '}
{formatBytes(resourcePolicy.hardOutputEstimateBytes)}
</dd>
</div>
<div>
<dt>Image limit</dt>
<dd>
{resourcePolicy.maxImageDimension.toLocaleString()} px / side
</dd>
</div>
<div>
<dt>Timeline / streams</dt>
<dd>
{resourcePolicy.maxTimelineClips} clips /{' '}
{resourcePolicy.maxStreamsPerInput} streams
</dd>
</div>
<div>
<dt>Generated files / images</dt>
<dd>
{resourcePolicy.maxGeneratedOutputs} /{' '}
{resourcePolicy.maxThumbnailCount}
</dd>
</div>
<div>
<dt>FFmpeg concurrency</dt>
<dd>{resourcePolicy.maxConcurrentFFmpegJobs}</dd>
</div>
<div>
<dt>Runtime limit</dt>
<dd>{formatDuration(resourcePolicy.maxJobRuntimeMs)}</dd>
</div>
</dl>
<p>
These product safety limits are conservative estimates, not media
format limits or a guarantee that a browser job will complete. The
generated-file limit reserves one ZIP entry for the optional export
report.
</p>
</section>
{state.status === 'idle' ? (
<button className="button button--secondary" onClick={onInitialize}>
Initialize engine
</button>
) : null}
{state.status === 'loading' ? (
<div className="load-stage" role="status">
<progress value={state.progress} max="1" />
<span>{state.stage ?? 'Loading local engine…'}</span>
</div>
) : null}
{state.status === 'ready' && state.warning ? (
<p className="notice notice--warning">
<Icon name="warning" />
{state.warning.message}
</p>
) : null}
{state.status === 'ready' ? (
<>
<details className="capability-build">
<summary>
Detected capability inventory (
{capabilityCount(state.capabilities)} entries)
</summary>
<pre>{capabilityInventory(state.capabilities)}</pre>
</details>
<details className="capability-build">
<summary>
FFmpeg build configuration (
{state.capabilities.buildConfiguration.length} flags)
</summary>
<pre>
{state.capabilities.buildConfiguration.length > 0
? state.capabilities.buildConfiguration.join('\n')
: 'No build flags were reported.'}
</pre>
</details>
</>
) : null}
{state.status === 'error' ? (
<div className="capability-error" role="alert">
<p className="notice notice--error">
<Icon name="warning" />
<span>
<strong>Engine initialization failed</strong>
{state.error.message}
</span>
</p>
<dl>
<div>
<dt>Error code</dt>
<dd>{state.error.code}</dd>
</div>
{state.error.exitCode === undefined ? null : (
<div>
<dt>FFmpeg exit code</dt>
<dd>{state.error.exitCode}</dd>
</div>
)}
</dl>
{state.error.details ? (
<details>
<summary>Technical details</summary>
<pre>{state.error.details}</pre>
</details>
) : null}
<button className="button button--secondary" onClick={onInitialize}>
Retry initialization
</button>
</div>
) : null}
</div>
</details>
);
}
function capabilityCount(
capabilities: Extract<EngineState, { status: 'ready' }>['capabilities']
): number {
return (
capabilities.demuxers.size +
capabilities.muxers.size +
capabilities.decoders.size +
capabilities.encoders.size +
capabilities.filters.size
);
}
function capabilityInventory(
capabilities: Extract<EngineState, { status: 'ready' }>['capabilities']
): string {
return (
[
['Demuxers', capabilities.demuxers],
['Muxers', capabilities.muxers],
['Decoders', capabilities.decoders],
['Encoders', capabilities.encoders],
['Filters', capabilities.filters],
] as const
)
.map(
([label, values]) =>
`${label} (${values.size})\n${[...values].sort().join(', ')}`
)
.join('\n\n');
}
function versionLine(versionText: string): string {
return (
versionText
.split(/\r?\n/u)
.map((line) => line.trim())
.find((line) => /^ffmpeg version\b/iu.test(line)) ??
versionText.trim().split(/\r?\n/u)[0] ??
'Unknown'
);
}
function formatBytes(bytes: number): string {
const mebibytes = bytes / (1024 * 1024);
if (mebibytes >= 1024) {
return `${new Intl.NumberFormat(undefined, {
maximumFractionDigits: 1,
}).format(mebibytes / 1024)} GiB`;
}
return `${new Intl.NumberFormat(undefined, {
maximumFractionDigits: 0,
}).format(mebibytes)} MiB`;
}
function formatDuration(milliseconds: number): string {
const hours = milliseconds / (60 * 60 * 1000);
return Number.isInteger(hours)
? `${hours} h`
: `${new Intl.NumberFormat(undefined, {
maximumFractionDigits: 1,
}).format(hours)} h`;
}

View File

@@ -0,0 +1,952 @@
import {
useId,
useMemo,
useRef,
useState,
type ChangeEvent,
type KeyboardEvent,
type PointerEvent,
type ReactNode,
} from 'react';
import type { CropSettings } from '../project';
import './visual-editor.css';
type Orientation = 0 | 90 | 180 | 270;
type ResizeHandle = 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | 'nw';
type DragMode = 'move' | ResizeHandle;
type CropField = keyof CropSettings;
type RatioChoice = 'current' | 'source' | '16:9' | '4:3' | '1:1' | '9:16';
interface Rectangle {
x: number;
y: number;
width: number;
height: number;
}
interface Dimensions {
width: number;
height: number;
}
interface ActiveDrag {
mode: DragMode;
pointerId: number;
startClientX: number;
startClientY: number;
startRectangle: Rectangle;
}
interface CropDraftState {
sourceKey: string;
values: Record<CropField, string>;
}
export interface CropOverlayProps {
/** Media element rendered below the crop controls. */
children: ReactNode;
/** Encoded source width, before applying display orientation. */
sourceWidth: number;
/** Encoded source height, before applying display orientation. */
sourceHeight: number;
/** Clockwise display orientation applied by this component. */
orientation?: Orientation;
/** Crop rectangle in encoded source pixels. */
value?: CropSettings;
onChange: (crop: CropSettings) => void;
disabled?: boolean;
requireEvenDimensions?: boolean;
minimumSize?: number;
ariaLabel?: string;
}
const handles: readonly ResizeHandle[] = [
'n',
'ne',
'e',
'se',
's',
'sw',
'w',
'nw',
];
const handleNames: Readonly<Record<ResizeHandle, string>> = {
n: 'top',
ne: 'top right',
e: 'right',
se: 'bottom right',
s: 'bottom',
sw: 'bottom left',
w: 'left',
nw: 'top left',
};
const ratioValues: Readonly<Record<Exclude<RatioChoice, 'current'>, number>> = {
source: 1,
'16:9': 16 / 9,
'4:3': 4 / 3,
'1:1': 1,
'9:16': 9 / 16,
};
/**
* A visual and numeric editor for one crop rectangle. Values emitted to the
* project remain in encoded-source coordinates even when the preview is
* displayed at a rotated orientation.
*/
export function CropOverlay({
children,
sourceWidth,
sourceHeight,
orientation = 0,
value,
onChange,
disabled = false,
requireEvenDimensions = false,
minimumSize = requireEvenDimensions ? 2 : 1,
ariaLabel = 'Visual crop editor',
}: CropOverlayProps) {
const instanceId = useId();
const errorId = `${instanceId}-crop-errors`;
const stageRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<ActiveDrag | undefined>(undefined);
const sourceDimensions = useMemo(
() => validateSourceDimensions(sourceWidth, sourceHeight),
[sourceHeight, sourceWidth]
);
const effectiveMinimum = normalizeMinimumSize(
minimumSize,
requireEvenDimensions
);
validateCropBounds(sourceDimensions, effectiveMinimum, requireEvenDimensions);
const initialCrop = useMemo(
() =>
resolveCropValue(
value,
sourceDimensions,
effectiveMinimum,
requireEvenDimensions
),
[effectiveMinimum, requireEvenDimensions, sourceDimensions, value]
);
const cropSourceKey = createCropSourceKey(
initialCrop,
sourceDimensions,
requireEvenDimensions
);
const [draftState, setDraftState] = useState<CropDraftState>(() => ({
sourceKey: cropSourceKey,
values: cropToDraft(initialCrop),
}));
const [aspectLocked, setAspectLocked] = useState(false);
const [ratioChoice, setRatioChoice] = useState<RatioChoice>('current');
const [lockedDisplayRatio, setLockedDisplayRatio] = useState(() => {
const displayed = cropToDisplayedRectangle(
initialCrop,
sourceDimensions,
orientation
);
return displayed.width / displayed.height;
});
const crop = initialCrop;
const draft =
draftState.sourceKey === cropSourceKey
? draftState.values
: cropToDraft(initialCrop);
const displayedDimensions = getDisplayedDimensions(
sourceDimensions,
orientation
);
const displayedCrop = cropToDisplayedRectangle(
crop,
sourceDimensions,
orientation
);
const draftResult = validateCropDraft(
draft,
sourceDimensions,
effectiveMinimum,
requireEvenDimensions
);
const externalValueErrors =
value === undefined
? []
: validateCrop(
value,
sourceDimensions,
effectiveMinimum,
requireEvenDimensions
);
const visibleErrors =
draftResult.errors.length > 0 ? draftResult.errors : externalValueErrors;
const displayStyle = rectangleToPercentStyle(
displayedCrop,
displayedDimensions
);
const mediaStyle = getOrientedMediaStyle(
sourceDimensions,
displayedDimensions,
orientation
);
const emitDisplayedRectangle = (rectangle: Rectangle) => {
const encoded = displayedRectangleToCrop(
rectangle,
sourceDimensions,
orientation
);
const normalized = normalizeCrop(
encoded,
sourceDimensions,
effectiveMinimum,
requireEvenDimensions
);
setDraftState({
sourceKey: cropSourceKey,
values: cropToDraft(normalized),
});
onChange(normalized);
};
const beginDrag = (
event: PointerEvent<HTMLButtonElement>,
mode: DragMode
) => {
if (disabled) return;
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture?.(event.pointerId);
dragRef.current = {
mode,
pointerId: event.pointerId,
startClientX: event.clientX,
startClientY: event.clientY,
startRectangle: displayedCrop,
};
};
const continueDrag = (event: PointerEvent<HTMLButtonElement>) => {
const active = dragRef.current;
const bounds = stageRef.current?.getBoundingClientRect();
if (
disabled ||
active === undefined ||
active.pointerId !== event.pointerId ||
bounds === undefined ||
bounds.width <= 0 ||
bounds.height <= 0
) {
return;
}
event.preventDefault();
const deltaX =
((event.clientX - active.startClientX) / bounds.width) *
displayedDimensions.width;
const deltaY =
((event.clientY - active.startClientY) / bounds.height) *
displayedDimensions.height;
const next =
active.mode === 'move'
? moveRectangle(
active.startRectangle,
deltaX,
deltaY,
displayedDimensions
)
: resizeRectangle(
active.startRectangle,
active.mode,
deltaX,
deltaY,
displayedDimensions,
effectiveMinimum,
aspectLocked ? lockedDisplayRatio : undefined
);
emitDisplayedRectangle(next);
};
const endDrag = (event: PointerEvent<HTMLButtonElement>) => {
if (dragRef.current?.pointerId === event.pointerId) {
event.currentTarget.releasePointerCapture?.(event.pointerId);
dragRef.current = undefined;
}
};
const handleKeyboard = (
event: KeyboardEvent<HTMLButtonElement>,
mode: DragMode
) => {
if (disabled) return;
const step = event.shiftKey ? 10 : 1;
const delta = arrowDelta(event.key, step);
if (delta === undefined) return;
event.preventDefault();
const next =
mode === 'move'
? moveRectangle(displayedCrop, delta.x, delta.y, displayedDimensions)
: resizeRectangle(
displayedCrop,
mode,
delta.x,
delta.y,
displayedDimensions,
effectiveMinimum,
aspectLocked ? lockedDisplayRatio : undefined
);
emitDisplayedRectangle(next);
};
const updateDraft = (
field: CropField,
event: ChangeEvent<HTMLInputElement>
) => {
const nextDraft = { ...draft, [field]: event.currentTarget.value };
setDraftState({ sourceKey: cropSourceKey, values: nextDraft });
const result = validateCropDraft(
nextDraft,
sourceDimensions,
effectiveMinimum,
requireEvenDimensions
);
if (result.crop !== undefined) {
onChange(result.crop);
}
};
const reset = () => {
const fullCrop = normalizeCrop(
{
x: 0,
y: 0,
width: sourceDimensions.width,
height: sourceDimensions.height,
},
sourceDimensions,
effectiveMinimum,
requireEvenDimensions
);
setRatioChoice('source');
setLockedDisplayRatio(
getDisplayedDimensions(sourceDimensions, orientation).width /
getDisplayedDimensions(sourceDimensions, orientation).height
);
setDraftState({
sourceKey: cropSourceKey,
values: cropToDraft(fullCrop),
});
onChange(fullCrop);
};
const selectRatio = (event: ChangeEvent<HTMLSelectElement>) => {
const choice = event.currentTarget.value as RatioChoice;
const ratio =
choice === 'current'
? displayedCrop.width / displayedCrop.height
: choice === 'source'
? displayedDimensions.width / displayedDimensions.height
: ratioValues[choice];
setRatioChoice(choice);
setAspectLocked(true);
setLockedDisplayRatio(ratio);
emitDisplayedRectangle(
fitRectangleToRatio(displayedCrop, ratio, displayedDimensions)
);
};
const toggleAspectLock = (event: ChangeEvent<HTMLInputElement>) => {
const locked = event.currentTarget.checked;
setAspectLocked(locked);
if (locked) {
setRatioChoice('current');
setLockedDisplayRatio(displayedCrop.width / displayedCrop.height);
}
};
return (
<section
className="crop-editor"
aria-label={ariaLabel}
data-orientation={orientation}
>
<div
ref={stageRef}
className="crop-editor__stage"
style={{
aspectRatio: `${displayedDimensions.width} / ${displayedDimensions.height}`,
}}
>
<div className="crop-editor__media" style={mediaStyle}>
{children}
</div>
<div className="crop-editor__shade" aria-hidden="true" />
<div className="crop-editor__rectangle" style={displayStyle}>
<button
type="button"
className="crop-editor__move"
aria-label="Move crop rectangle"
aria-keyshortcuts="ArrowUp ArrowRight ArrowDown ArrowLeft"
disabled={disabled}
onPointerDown={(event) => beginDrag(event, 'move')}
onPointerMove={continueDrag}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onKeyDown={(event) => handleKeyboard(event, 'move')}
>
<span aria-hidden="true">Move</span>
</button>
{handles.map((handle) => (
<button
key={handle}
type="button"
className={`crop-editor__handle crop-editor__handle--${handle}`}
aria-label={`Resize crop from ${handleNames[handle]}`}
aria-keyshortcuts="ArrowUp ArrowRight ArrowDown ArrowLeft"
disabled={disabled}
onPointerDown={(event) => beginDrag(event, handle)}
onPointerMove={continueDrag}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onKeyDown={(event) => handleKeyboard(event, handle)}
/>
))}
</div>
</div>
<div className="crop-editor__toolbar">
<label className="crop-editor__lock">
<input
type="checkbox"
checked={aspectLocked}
disabled={disabled}
onChange={toggleAspectLock}
/>
Lock aspect ratio
</label>
<label>
<span>Common ratio</span>
<select
value={ratioChoice}
disabled={disabled}
onChange={selectRatio}
>
<option value="current">Current crop</option>
<option value="source">Source display ratio</option>
<option value="16:9">16:9</option>
<option value="4:3">4:3</option>
<option value="1:1">1:1</option>
<option value="9:16">9:16</option>
</select>
</label>
<button type="button" disabled={disabled} onClick={reset}>
Reset crop
</button>
</div>
<fieldset className="crop-editor__numeric" disabled={disabled}>
<legend>Encoded source pixels</legend>
{(['x', 'y', 'width', 'height'] as const).map((field) => (
<label key={field}>
<span>
{field === 'x' || field === 'y'
? field.toUpperCase()
: capitalize(field)}
</span>
<input
type="number"
inputMode="numeric"
step={requireEvenDimensions ? 2 : 1}
min={
field === 'width' || field === 'height' ? effectiveMinimum : 0
}
max={
field === 'x' || field === 'width'
? sourceDimensions.width
: sourceDimensions.height
}
value={draft[field]}
aria-invalid={visibleErrors.length > 0}
aria-describedby={visibleErrors.length > 0 ? errorId : undefined}
onChange={(event) => updateDraft(field, event)}
/>
</label>
))}
</fieldset>
<p className="crop-editor__hint">
Display orientation: {orientation}°. Crop values are saved against the
encoded {sourceDimensions.width}×{sourceDimensions.height} source.
</p>
{visibleErrors.length > 0 ? (
<ul id={errorId} className="crop-editor__errors" role="alert">
{visibleErrors.map((error) => (
<li key={error}>{error}</li>
))}
</ul>
) : null}
</section>
);
}
function validateSourceDimensions(width: number, height: number): Dimensions {
if (
!Number.isSafeInteger(width) ||
!Number.isSafeInteger(height) ||
width <= 0 ||
height <= 0
) {
throw new RangeError(
'Crop source dimensions must be positive safe integers.'
);
}
return Object.freeze({ width, height });
}
function normalizeMinimumSize(value: number, requireEven: boolean): number {
if (!Number.isSafeInteger(value) || value < 1) {
throw new RangeError('Crop minimum size must be a positive safe integer.');
}
return requireEven && value % 2 !== 0 ? value + 1 : value;
}
function validateCropBounds(
bounds: Dimensions,
minimum: number,
requireEven: boolean
): void {
if (bounds.width < minimum || bounds.height < minimum) {
throw new RangeError(
'Crop source dimensions are smaller than the minimum crop size.'
);
}
if (
requireEven &&
(bounds.width < 2 ||
bounds.height < 2 ||
bounds.width - (bounds.width % 2) < minimum ||
bounds.height - (bounds.height % 2) < minimum)
) {
throw new RangeError(
'Crop source dimensions cannot contain the required even crop.'
);
}
}
function resolveCropValue(
value: CropSettings | undefined,
bounds: Dimensions,
minimum: number,
requireEven: boolean
): CropSettings {
const fallback = normalizeCrop(
{ x: 0, y: 0, width: bounds.width, height: bounds.height },
bounds,
minimum,
requireEven
);
if (value === undefined) return fallback;
return validateCrop(value, bounds, minimum, requireEven).length === 0
? Object.freeze({ ...value })
: fallback;
}
function normalizeCrop(
value: Rectangle,
bounds: Dimensions,
minimum: number,
requireEven: boolean
): CropSettings {
let x = Math.round(value.x);
let y = Math.round(value.y);
let width = Math.round(value.width);
let height = Math.round(value.height);
width = Math.max(minimum, Math.min(width, bounds.width));
height = Math.max(minimum, Math.min(height, bounds.height));
x = clamp(Math.round(x), 0, bounds.width - width);
y = clamp(Math.round(y), 0, bounds.height - height);
if (requireEven) {
width -= width % 2;
height -= height % 2;
x -= x % 2;
y -= y % 2;
width = Math.max(minimum, width);
height = Math.max(minimum, height);
x = Math.min(x, bounds.width - width);
y = Math.min(y, bounds.height - height);
}
return Object.freeze({ x, y, width, height });
}
function validateCrop(
crop: CropSettings,
bounds: Dimensions,
minimum: number,
requireEven: boolean
): string[] {
const errors: string[] = [];
for (const [field, value] of Object.entries(crop)) {
if (!Number.isSafeInteger(value)) {
errors.push(`${capitalize(field)} must be a whole number.`);
}
}
if (crop.x < 0 || crop.y < 0) {
errors.push('X and Y must not be negative.');
}
if (crop.width < minimum || crop.height < minimum) {
errors.push(`Width and height must be at least ${String(minimum)} pixels.`);
}
if (crop.x + crop.width > bounds.width) {
errors.push('The crop extends beyond the source width.');
}
if (crop.y + crop.height > bounds.height) {
errors.push('The crop extends beyond the source height.');
}
if (
requireEven &&
(crop.x % 2 !== 0 ||
crop.y % 2 !== 0 ||
crop.width % 2 !== 0 ||
crop.height % 2 !== 0)
) {
errors.push('X, Y, width and height must be even for this output.');
}
return errors;
}
function cropToDraft(crop: CropSettings): Record<CropField, string> {
return {
x: String(crop.x),
y: String(crop.y),
width: String(crop.width),
height: String(crop.height),
};
}
function createCropSourceKey(
crop: CropSettings,
source: Dimensions,
requireEven: boolean
): string {
return [
crop.x,
crop.y,
crop.width,
crop.height,
source.width,
source.height,
requireEven ? 1 : 0,
].join(':');
}
function validateCropDraft(
draft: Record<CropField, string>,
bounds: Dimensions,
minimum: number,
requireEven: boolean
): { crop?: CropSettings; errors: string[] } {
const values = Object.fromEntries(
Object.entries(draft).map(([field, value]) => [field, Number(value)])
) as unknown as CropSettings;
const errors = Object.values(draft).some((value) => value.trim() === '')
? ['All crop fields are required.']
: validateCrop(values, bounds, minimum, requireEven);
return errors.length === 0
? { crop: Object.freeze({ ...values }), errors }
: { errors };
}
function getDisplayedDimensions(
source: Dimensions,
orientation: Orientation
): Dimensions {
return orientation === 90 || orientation === 270
? { width: source.height, height: source.width }
: source;
}
function cropToDisplayedRectangle(
crop: CropSettings,
source: Dimensions,
orientation: Orientation
): Rectangle {
switch (orientation) {
case 90:
return {
x: source.height - crop.y - crop.height,
y: crop.x,
width: crop.height,
height: crop.width,
};
case 180:
return {
x: source.width - crop.x - crop.width,
y: source.height - crop.y - crop.height,
width: crop.width,
height: crop.height,
};
case 270:
return {
x: crop.y,
y: source.width - crop.x - crop.width,
width: crop.height,
height: crop.width,
};
default:
return { ...crop };
}
}
function displayedRectangleToCrop(
rectangle: Rectangle,
source: Dimensions,
orientation: Orientation
): Rectangle {
switch (orientation) {
case 90:
return {
x: rectangle.y,
y: source.height - rectangle.x - rectangle.width,
width: rectangle.height,
height: rectangle.width,
};
case 180:
return {
x: source.width - rectangle.x - rectangle.width,
y: source.height - rectangle.y - rectangle.height,
width: rectangle.width,
height: rectangle.height,
};
case 270:
return {
x: source.width - rectangle.y - rectangle.height,
y: rectangle.x,
width: rectangle.height,
height: rectangle.width,
};
default:
return { ...rectangle };
}
}
function rectangleToPercentStyle(
rectangle: Rectangle,
bounds: Dimensions
): React.CSSProperties {
return {
left: `${(rectangle.x / bounds.width) * 100}%`,
top: `${(rectangle.y / bounds.height) * 100}%`,
width: `${(rectangle.width / bounds.width) * 100}%`,
height: `${(rectangle.height / bounds.height) * 100}%`,
};
}
function getOrientedMediaStyle(
source: Dimensions,
displayed: Dimensions,
orientation: Orientation
): React.CSSProperties {
const rotated = orientation === 90 || orientation === 270;
return {
width: rotated ? `${(source.width / displayed.width) * 100}%` : '100%',
height: rotated ? `${(source.height / displayed.height) * 100}%` : '100%',
transform: `translate(-50%, -50%) rotate(${String(orientation)}deg)`,
};
}
function moveRectangle(
rectangle: Rectangle,
deltaX: number,
deltaY: number,
bounds: Dimensions
): Rectangle {
return {
...rectangle,
x: clamp(rectangle.x + deltaX, 0, bounds.width - rectangle.width),
y: clamp(rectangle.y + deltaY, 0, bounds.height - rectangle.height),
};
}
function resizeRectangle(
rectangle: Rectangle,
handle: ResizeHandle,
deltaX: number,
deltaY: number,
bounds: Dimensions,
minimum: number,
ratio?: number
): Rectangle {
if (ratio === undefined) {
const left = handle.includes('w')
? clamp(rectangle.x + deltaX, 0, rectangle.x + rectangle.width - minimum)
: rectangle.x;
const right = handle.includes('e')
? clamp(
rectangle.x + rectangle.width + deltaX,
rectangle.x + minimum,
bounds.width
)
: rectangle.x + rectangle.width;
const top = handle.includes('n')
? clamp(rectangle.y + deltaY, 0, rectangle.y + rectangle.height - minimum)
: rectangle.y;
const bottom = handle.includes('s')
? clamp(
rectangle.y + rectangle.height + deltaY,
rectangle.y + minimum,
bounds.height
)
: rectangle.y + rectangle.height;
return {
x: left,
y: top,
width: right - left,
height: bottom - top,
};
}
if (!Number.isFinite(ratio) || ratio <= 0) return rectangle;
const horizontal = handle.includes('w') ? -1 : handle.includes('e') ? 1 : 0;
const vertical = handle.includes('n') ? -1 : handle.includes('s') ? 1 : 0;
const minimumWidth = Math.max(minimum, minimum * ratio);
if (horizontal !== 0 && vertical !== 0) {
const anchorX =
horizontal < 0 ? rectangle.x + rectangle.width : rectangle.x;
const anchorY = vertical < 0 ? rectangle.y + rectangle.height : rectangle.y;
const movingX =
(horizontal < 0 ? rectangle.x : rectangle.x + rectangle.width) + deltaX;
const movingY =
(vertical < 0 ? rectangle.y : rectangle.y + rectangle.height) + deltaY;
const rawWidth = Math.abs(movingX - anchorX);
const rawHeight = Math.abs(movingY - anchorY);
const requestedWidth =
Math.abs(rawWidth - rectangle.width) >=
Math.abs(rawHeight * ratio - rectangle.width)
? rawWidth
: rawHeight * ratio;
const maxWidthX = horizontal < 0 ? anchorX : bounds.width - anchorX;
const maxHeightY = vertical < 0 ? anchorY : bounds.height - anchorY;
const width = clamp(
requestedWidth,
Math.min(minimumWidth, maxWidthX, maxHeightY * ratio),
Math.min(maxWidthX, maxHeightY * ratio)
);
const height = width / ratio;
return {
x: horizontal < 0 ? anchorX - width : anchorX,
y: vertical < 0 ? anchorY - height : anchorY,
width,
height,
};
}
if (horizontal !== 0) {
const anchorX =
horizontal < 0 ? rectangle.x + rectangle.width : rectangle.x;
const movingX =
(horizontal < 0 ? rectangle.x : rectangle.x + rectangle.width) + deltaX;
const centerY = rectangle.y + rectangle.height / 2;
const maxHeight = 2 * Math.min(centerY, bounds.height - centerY);
const maxWidth = Math.min(
horizontal < 0 ? anchorX : bounds.width - anchorX,
maxHeight * ratio
);
const width = clamp(
Math.abs(movingX - anchorX),
Math.min(minimumWidth, maxWidth),
maxWidth
);
const height = width / ratio;
return {
x: horizontal < 0 ? anchorX - width : anchorX,
y: centerY - height / 2,
width,
height,
};
}
const anchorY = vertical < 0 ? rectangle.y + rectangle.height : rectangle.y;
const movingY =
(vertical < 0 ? rectangle.y : rectangle.y + rectangle.height) + deltaY;
const centerX = rectangle.x + rectangle.width / 2;
const maxWidth = 2 * Math.min(centerX, bounds.width - centerX);
const maxHeight = Math.min(
vertical < 0 ? anchorY : bounds.height - anchorY,
maxWidth / ratio
);
const height = clamp(
Math.abs(movingY - anchorY),
Math.min(minimum, maxHeight),
maxHeight
);
const width = height * ratio;
return {
x: centerX - width / 2,
y: vertical < 0 ? anchorY - height : anchorY,
width,
height,
};
}
function fitRectangleToRatio(
rectangle: Rectangle,
ratio: number,
bounds: Dimensions
): Rectangle {
let width = rectangle.width;
let height = width / ratio;
if (height > rectangle.height) {
height = rectangle.height;
width = height * ratio;
}
if (width > bounds.width) {
width = bounds.width;
height = width / ratio;
}
if (height > bounds.height) {
height = bounds.height;
width = height * ratio;
}
return {
x: clamp(
rectangle.x + (rectangle.width - width) / 2,
0,
bounds.width - width
),
y: clamp(
rectangle.y + (rectangle.height - height) / 2,
0,
bounds.height - height
),
width,
height,
};
}
function arrowDelta(
key: string,
step: number
): { x: number; y: number } | undefined {
switch (key) {
case 'ArrowLeft':
return { x: -step, y: 0 };
case 'ArrowRight':
return { x: step, y: 0 };
case 'ArrowUp':
return { x: 0, y: -step };
case 'ArrowDown':
return { x: 0, y: step };
default:
return undefined;
}
}
function capitalize(value: string): string {
return `${value.charAt(0).toUpperCase()}${value.slice(1)}`;
}
function clamp(value: number, minimum: number, maximum: number): number {
if (maximum < minimum) return maximum;
return Math.min(Math.max(value, minimum), maximum);
}

View File

@@ -0,0 +1,81 @@
import { useRef, useState, type DragEvent } from 'react';
import { Icon } from './Icon';
export interface FileDropZoneProps {
onFiles: (files: File[]) => void;
compact?: boolean;
disabled?: boolean;
}
export function FileDropZone({
onFiles,
compact = false,
disabled = false,
}: FileDropZoneProps) {
const input = useRef<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false);
const receive = (files: FileList | null) => {
const selected = files ? [...files].filter((file) => file.size > 0) : [];
if (selected.length > 0) onFiles(selected);
if (input.current) input.current.value = '';
};
const drop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
setDragging(false);
if (!disabled) receive(event.dataTransfer.files);
};
return (
<div
className={[
'drop-zone',
compact ? 'drop-zone--compact' : '',
dragging ? 'drop-zone--active' : '',
]
.filter(Boolean)
.join(' ')}
onDragEnter={(event) => {
event.preventDefault();
if (!disabled) setDragging(true);
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
setDragging(false);
}
}}
onDrop={drop}
>
<input
ref={input}
className="visually-hidden"
type="file"
accept="audio/*,video/*,.mkv,.mka,.flac,.wav,.ogg,.opus"
multiple
disabled={disabled}
onChange={(event) => receive(event.currentTarget.files)}
/>
<div className="drop-zone__icon">
<Icon name="add" />
</div>
<div>
<strong>{compact ? 'Add media' : 'Drop audio or video here'}</strong>
<span>
{compact
? 'Files stay on this device'
: 'Any FFmpeg-supported local file · nothing is uploaded'}
</span>
</div>
<button
className="button button--secondary"
type="button"
disabled={disabled}
onClick={() => input.current?.click()}
>
Choose files
</button>
</div>
);
}

View File

@@ -0,0 +1,201 @@
import { useEffect, useRef } from 'react';
import applicationLicenseUrl from '../../LICENSE?url&no-inline';
import ffmpegWasmLicenseUrl from '../../LICENSES/ffmpeg.wasm-MIT.txt?url&no-inline';
import gplLicenseUrl from '../../LICENSES/GPL-2.0-or-later.txt?url&no-inline';
import licenseStatusUrl from '../../LICENSES/README.md?url&no-inline';
import sourceRecordUrl from '../../SOURCE.md?url&no-inline';
import thirdPartyNoticesUrl from '../../THIRD_PARTY_NOTICES.md?url&no-inline';
import {
APP_VERSION,
FFMPEG_CORE_VERSION,
FFMPEG_ENGINE_VERSION,
FFMPEG_UTIL_VERSION,
FFMPEG_WRAPPER_VERSION,
} from '../version';
import { Icon } from './Icon';
export interface HelpDialogProps {
open: boolean;
onClose: () => void;
}
export function HelpDialog({ open, onClose }: HelpDialogProps) {
const dialog = useRef<HTMLDialogElement>(null);
useEffect(() => {
const element = dialog.current;
if (!element) return;
if (open && !element.open) element.showModal();
if (!open && element.open) element.close();
}, [open]);
return (
<dialog
ref={dialog}
className="modal"
aria-labelledby="help-title"
onClose={onClose}
onClick={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
<div className="modal__panel">
<header>
<div>
<p className="eyebrow">Local media studio</p>
<h2 id="help-title">Audio & Video Tools</h2>
</div>
<button
className="icon-action"
type="button"
aria-label="Close help"
onClick={onClose}
>
<Icon name="cancel" />
</button>
</header>
<div className="help-grid">
<section>
<span className="step-number">1</span>
<h3>Add media</h3>
<p>
Pick or drop local files. The browser gives FFmpeg direct,
read-only access when WORKERFS is available. If it must use the
bounded memory-copy fallback, the job log reports the exact copied
byte count.
</p>
</section>
<section>
<span className="step-number">2</span>
<h3>Choose an operation</h3>
<p>
Quick Convert produces one output. Edit arranges non-destructive
clips on a sequential timeline.
</p>
</section>
<section>
<span className="step-number">3</span>
<h3>Export locally</h3>
<p>
The FFmpeg WebAssembly core runs here. Source media is never
uploaded and the result is saved by your browser.
</p>
</section>
</div>
<section className="about-licenses" aria-labelledby="licenses-title">
<header>
<div>
<p className="eyebrow">Versions, source and legal context</p>
<h3 id="licenses-title">About &amp; licences</h3>
</div>
<span>av-tools {APP_VERSION}</span>
</header>
<dl className="about-version-grid">
<div>
<dt>FFmpeg engine</dt>
<dd>{FFMPEG_ENGINE_VERSION}</dd>
</div>
<div>
<dt>ffmpeg.wasm wrapper</dt>
<dd>{FFMPEG_WRAPPER_VERSION}</dd>
</div>
<div>
<dt>FFmpeg WASM cores</dt>
<dd>{FFMPEG_CORE_VERSION} · ST &amp; MT</dd>
</div>
<div>
<dt>ffmpeg.wasm utilities</dt>
<dd>{FFMPEG_UTIL_VERSION}</dd>
</div>
</dl>
<div className="about-license-grid">
<section>
<h4>Application licence</h4>
<p>
<strong>
Copyright © 2026 Albrecht Degering. GPL-3.0-or-later.
</strong>{' '}
This program comes with absolutely no warranty. You may
redistribute and modify it under GPL version 3 or, at your
option, a later version. Third-party components retain their own
notices and terms.
</p>
<div className="about-link-list">
<a
href={applicationLicenseUrl}
target="_blank"
rel="noreferrer"
>
Application GPL
</a>
<a href={licenseStatusUrl} target="_blank" rel="noreferrer">
Licence guide
</a>
<a href={sourceRecordUrl} target="_blank" rel="noreferrer">
Source &amp; reproducibility record
</a>
</div>
</section>
<section>
<h4>Bundled runtime notices</h4>
<ul>
<li>
ffmpeg.wasm wrapper and utilities MIT
<a
href={ffmpegWasmLicenseUrl}
target="_blank"
rel="noreferrer"
>
MIT text
</a>
</li>
<li>
FFmpeg single- and multithread cores package declares
GPL-2.0-or-later
<a href={gplLicenseUrl} target="_blank" rel="noreferrer">
GPL text
</a>
</li>
<li>
FFmpeg, linked codecs, Toolbox SDK, React and other runtime
packages retain their respective terms.
<a
href={thirdPartyNoticesUrl}
target="_blank"
rel="noreferrer"
>
Complete inventory
</a>
</li>
</ul>
</section>
</div>
</section>
<aside className="privacy-callout">
<Icon name="check" />
<div>
<strong>Private by architecture</strong>
<p>
No backend, analytics, third-party scripts, remote fonts, or
automatic remote media requests. Source files stay inside the
browser session and are not persisted by the app; only regenerable
derivatives may use local browser storage.
</p>
</div>
</aside>
<footer>
<a
href="https://git.add-ideas.de/zemion/av-tools"
target="_blank"
rel="noreferrer"
>
View source
</a>
<button className="button button--primary" onClick={onClose}>
Start working
</button>
</footer>
</div>
</dialog>
);
}

115
src/components/Icon.tsx Normal file
View File

@@ -0,0 +1,115 @@
import type { SVGProps } from 'react';
export type IconName =
| 'add'
| 'audio'
| 'cancel'
| 'check'
| 'chevron'
| 'download'
| 'edit'
| 'file'
| 'info'
| 'play'
| 'queue'
| 'settings'
| 'sparkles'
| 'trash'
| 'video'
| 'warning';
const paths: Record<IconName, React.ReactNode> = {
add: <path d="M12 5v14M5 12h14" />,
audio: (
<>
<path d="M9 18V5l10-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="16" cy="16" r="3" />
</>
),
cancel: <path d="m6 6 12 12M18 6 6 18" />,
check: <path d="m5 12 4 4L19 6" />,
chevron: <path d="m9 18 6-6-6-6" />,
download: (
<>
<path d="M12 3v12m0 0 4-4m-4 4-4-4" />
<path d="M5 20h14" />
</>
),
edit: (
<>
<path d="M4 20h4l11-11-4-4L4 16v4Z" />
<path d="m13.5 6.5 4 4" />
</>
),
file: (
<>
<path d="M6 2h8l4 4v16H6z" />
<path d="M14 2v5h5" />
</>
),
info: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M12 11v6m0-10h.01" />
</>
),
play: <path d="m8 5 11 7-11 7z" />,
queue: (
<>
<path d="M4 6h12M4 12h12M4 18h8" />
<path d="m17 16 3 2-3 2z" />
</>
),
settings: (
<>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-1.6v-.2h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z" />
</>
),
sparkles: (
<>
<path d="m12 3 1.2 3.8L17 8l-3.8 1.2L12 13l-1.2-3.8L7 8l3.8-1.2z" />
<path d="m18 14 .8 2.2L21 17l-2.2.8L18 20l-.8-2.2L15 17l2.2-.8z" />
<path d="m5 12 .7 1.8 1.8.7-1.8.7L5 17l-.7-1.8-1.8-.7 1.8-.7z" />
</>
),
trash: (
<>
<path d="M4 7h16M9 3h6l1 4H8zM7 7l1 14h8l1-14" />
<path d="M10 11v6m4-6v6" />
</>
),
video: (
<>
<rect x="3" y="5" width="14" height="14" rx="2" />
<path d="m17 10 4-2v8l-4-2z" />
</>
),
warning: (
<>
<path d="M12 3 2.7 20h18.6z" />
<path d="M12 9v5m0 3h.01" />
</>
),
};
export function Icon({
name,
...props
}: { name: IconName } & SVGProps<SVGSVGElement>) {
return (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
{paths[name]}
</svg>
);
}

1415
src/components/Inspector.tsx Normal file

File diff suppressed because it is too large Load Diff

495
src/components/JobPanel.tsx Normal file
View File

@@ -0,0 +1,495 @@
import { useEffect, useRef, useState } from 'react';
import { formatBytes, formatDuration } from '../app/application-state';
import type { ExportReport, ExportResult } from '../export';
import type { EngineState } from '../ffmpeg/ffmpeg.types';
import type { MediaJob } from '../jobs';
import {
assessGeneratedResultPreview,
formatTimecode,
type MediaProbe,
} from '../media';
import { Icon } from './Icon';
export interface ResultVerification {
readonly probe?: MediaProbe;
readonly warning?: string;
}
export interface JobPanelProps {
engineState: EngineState;
jobs: readonly MediaJob[];
results: readonly ExportResult[];
selectedResultId?: string;
verifications: Readonly<Record<string, ResultVerification>>;
report?: ExportReport;
logs: readonly string[];
error?: string;
queuedCount?: number;
onCancel: () => void;
onCancelJob: (job: MediaJob) => void;
onRetryJob: (job: MediaJob) => void;
onClearJobHistory: () => void;
onSave: (result: ExportResult) => void;
onSaveZip: (results: readonly ExportResult[]) => void;
onSaveReport: () => void;
onPreview: (result: ExportResult) => void;
onRemove: (result: ExportResult) => void;
onClearResults: () => void;
}
export function JobPanel({
engineState,
jobs,
results,
selectedResultId,
verifications,
report,
logs,
error,
queuedCount = 0,
onCancel,
onCancelJob,
onRetryJob,
onClearJobHistory,
onSave,
onSaveZip,
onSaveReport,
onPreview,
onRemove,
onClearResults,
}: JobPanelProps) {
const [selectedResultIds, setSelectedResultIds] = useState<
ReadonlySet<string>
>(() => new Set());
const knownResultIds = useRef<ReadonlySet<string>>(new Set());
useEffect(() => {
setSelectedResultIds((current) => {
const next = new Set(
results
.filter(
(result) =>
current.has(result.id) || !knownResultIds.current.has(result.id)
)
.map((result) => result.id)
);
knownResultIds.current = new Set(results.map((result) => result.id));
return next;
});
}, [results]);
const running = engineState.status === 'running';
if (
!running &&
queuedCount === 0 &&
jobs.length === 0 &&
results.length === 0 &&
!error &&
logs.length === 0
) {
return null;
}
return (
<section className="panel job-panel" aria-labelledby="jobs-title">
<header className="panel__header">
<div>
<p className="eyebrow">Local FFmpeg queue</p>
<h2 id="jobs-title">
{running
? 'Processing one operation'
: results.length === 1
? 'Current output'
: 'Current outputs'}
</h2>
</div>
{queuedCount > 0 ? (
<span className="job-panel__queue-count" role="status">
{queuedCount} waiting
</span>
) : null}
{running ? (
<button
className="button button--danger"
type="button"
onClick={onCancel}
>
<Icon name="cancel" />
Cancel & recover
</button>
) : null}
</header>
<div className="job-panel__body" aria-live="polite">
{queuedCount > 0 ? (
<p className="job-queue-note">
Additional operations are retained in memory and start one at a
time. Closing this page discards waiting source handles.
</p>
) : null}
{running ? (
<div className="job-progress">
<div>
<strong>{engineState.operation}</strong>
<span>
FFmpeg progress is an estimate; completion requires exit code
zero.
</span>
</div>
<span>
{engineState.progress === undefined
? 'Working…'
: `${Math.round(engineState.progress * 100)}%`}
{engineState.elapsedSeconds !== undefined
? ` · ${Math.round(engineState.elapsedSeconds)} s`
: ''}
{engineState.speed !== undefined
? ` · ${engineState.speed.toFixed(2)}×`
: ''}
</span>
<progress value={engineState.progress} max="1" />
</div>
) : null}
{jobs.length > 0 ? (
<section className="job-history" aria-labelledby="job-history-title">
<header>
<h3 id="job-history-title">Job history</h3>
{jobs.some((job) => isTerminalJob(job)) ? (
<button
className="button button--quiet"
type="button"
onClick={onClearJobHistory}
>
Clear finished
</button>
) : null}
</header>
<div className="job-history__list">
{jobs.map((job) => (
<article className="job-record" key={job.id}>
<div className="job-record__summary">
<div>
<strong>{job.operation}</strong>
<span>
{job.status.replace('-', ' ')}
{job.startedAt
? ` · ${formatJobTime(job.startedAt)}`
: ''}
</span>
</div>
<b>{Math.round(job.progress * 100)}%</b>
{job.status === 'queued' ? (
<button
className="button button--quiet"
type="button"
onClick={() => onCancelJob(job)}
>
Cancel waiting
</button>
) : null}
{job.status === 'failed' || job.status === 'cancelled' ? (
<button
className="button button--quiet"
type="button"
onClick={() => onRetryJob(job)}
>
Retry
</button>
) : null}
</div>
<progress value={job.progress} max="1" />
{job.error ? (
<small className="job-record__error">
{job.error.message}
</small>
) : null}
{job.steps.length > 1 ? (
<details>
<summary>{job.steps.length} local steps</summary>
<ol>
{job.steps.map((step) => (
<li key={step.id}>
<span>{step.name}</span>
<span>
{step.status} · {Math.round(step.progress * 100)}%
</span>
</li>
))}
</ol>
</details>
) : null}
</article>
))}
</div>
</section>
) : null}
{error ? (
<div className="notice notice--error" role="alert">
<Icon name="warning" />
<div>
<strong>Output failed</strong>
<span>{error}</span>
</div>
</div>
) : null}
{results.length > 0 ? (
<>
{results.length > 1 ? (
<label className="result-select-all">
<input
type="checkbox"
checked={selectedResultIds.size === results.length}
onChange={(event) =>
setSelectedResultIds(
event.currentTarget.checked
? new Set(results.map((result) => result.id))
: new Set()
)
}
/>
Select all generated results for ZIP
</label>
) : null}
<div className="result-list">
{results.map((result) => {
const verification = verifications[result.id];
const preview = generatedResultPreview(
result,
verification?.probe
);
return (
<article
className={`result-card${selectedResultId === result.id ? ' is-selected' : ''}`}
key={result.id}
>
{results.length > 1 ? (
<input
className="result-card__select"
type="checkbox"
aria-label={`Select ${result.fileName} for ZIP`}
checked={selectedResultIds.has(result.id)}
onChange={(event) => {
const checked = event.currentTarget.checked;
setSelectedResultIds((current) => {
const next = new Set(current);
if (checked) next.add(result.id);
else next.delete(result.id);
return next;
});
}}
/>
) : null}
<span className="result-card__icon">
<Icon name="check" />
</span>
<div>
<strong>{result.fileName}</strong>
<span>
{formatBytes(result.size)} ·{' '}
{verification?.warning
? 'created · verification warning'
: verification?.probe
? 'created · verified'
: 'completed locally'}
{verification?.probe?.durationSeconds !== undefined
? ` · ${formatDuration(verification.probe.durationSeconds)}`
: ''}
{verification?.probe
? ` · ${verification.probe.streams.length} verified stream${verification.probe.streams.length === 1 ? '' : 's'}`
: ''}
</span>
{result.timeRange ? (
<small className="result-card__range">
Segment range{' '}
{formatTimecode(result.timeRange.startSeconds)}
{formatTimecode(result.timeRange.endSeconds)} ·{' '}
{formatTimecode(
result.timeRange.endSeconds -
result.timeRange.startSeconds
)}{' '}
duration
</small>
) : null}
{verification?.warning ? (
<small className="result-card__warning">
{verification.warning}
</small>
) : null}
{!preview.previewable && preview.kind !== 'other' ? (
<small className="result-card__warning">
Download only: {preview.reason}
</small>
) : null}
{verification?.probe ? (
<details className="result-verification">
<summary>Round-trip probe details</summary>
<dl>
<div>
<dt>Container tags</dt>
<dd>
{formatTagSummary(verification.probe.tags)}
</dd>
</div>
<div>
<dt>Chapters</dt>
<dd>
{verification.probe.chapters.length === 0
? 'None'
: verification.probe.chapters
.map(
(chapter) =>
chapter.title ??
`Chapter ${chapter.id + 1}`
)
.join(', ')}
</dd>
</div>
{verification.probe.streams.map((stream) => (
<div key={stream.index}>
<dt>
{stream.type} #{stream.index}
</dt>
<dd>
{stream.codecName ?? 'unknown codec'}
{stream.language
? ` · ${stream.language}`
: ''}
{stream.title ? ` · ${stream.title}` : ''}
{` · ${formatTagSummary(stream.tags)}`}
{` · disposition: ${enabledDispositions(
stream.disposition
)}`}
</dd>
</div>
))}
</dl>
</details>
) : null}
</div>
{preview.previewable ? (
<button
className="button button--quiet"
type="button"
aria-pressed={selectedResultId === result.id}
onClick={() => onPreview(result)}
>
Preview
</button>
) : null}
<button
className="button button--primary"
type="button"
aria-label={
results.length === 1
? 'Save result'
: `Save ${result.fileName}`
}
onClick={() => onSave(result)}
>
<Icon name="download" />
{results.length === 1 ? 'Save result' : 'Save'}
</button>
<button
className="icon-action"
type="button"
aria-label={`Remove ${result.fileName}`}
onClick={() => onRemove(result)}
>
<Icon name="trash" />
</button>
</article>
);
})}
</div>
<div className="result-actions">
{results.length > 1 ? (
<button
className="button button--secondary"
type="button"
disabled={selectedResultIds.size === 0}
onClick={() =>
onSaveZip(
results.filter((result) =>
selectedResultIds.has(result.id)
)
)
}
>
<Icon name="download" />
Download selected as ZIP ({selectedResultIds.size})
</button>
) : null}
{report ? (
<button
className="button button--quiet"
type="button"
onClick={onSaveReport}
>
Export validation report
</button>
) : null}
<button
className="button button--quiet"
type="button"
onClick={onClearResults}
>
Clear generated results
</button>
</div>
</>
) : null}
{logs.length > 0 ? (
<details className="diagnostics">
<summary>FFmpeg diagnostics ({logs.length} recent lines)</summary>
<pre>{logs.join('\n')}</pre>
</details>
) : null}
</div>
</section>
);
}
function generatedResultPreview(
result: ExportResult,
probe: MediaProbe | undefined
) {
const element = document.createElement(
result.mimeType.startsWith('video/') ? 'video' : 'audio'
);
return assessGeneratedResultPreview(
{
fileName: result.fileName,
mimeType: result.mimeType,
...(probe ? { probe } : {}),
},
(mimeType) => element.canPlayType(mimeType)
);
}
function isTerminalJob(job: MediaJob): boolean {
return (
job.status === 'completed' ||
job.status === 'failed' ||
job.status === 'cancelled'
);
}
function formatJobTime(value: string): string {
const date = new Date(value);
return Number.isNaN(date.valueOf())
? value
: date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function formatTagSummary(tags: Readonly<Record<string, string>>): string {
const entries = Object.entries(tags);
return entries.length === 0
? 'None reported'
: entries.map(([key, value]) => `${key}=${value}`).join(' · ');
}
function enabledDispositions(
dispositions: Readonly<Record<string, boolean>>
): string {
const enabled = Object.entries(dispositions)
.filter(([, value]) => value)
.map(([key]) => key);
return enabled.length > 0 ? enabled.join(', ') : 'none';
}

Some files were not shown because too many files have changed in this diff Show More